From af29fdce77a2a3ff5bb7146f8070355277a8635e Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 20 Jul 2026 15:26:31 +0900 Subject: [PATCH 01/10] Draft remote storage HTTP API v1 --- server/spec/v1.md | 347 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 server/spec/v1.md diff --git a/server/spec/v1.md b/server/spec/v1.md new file mode 100644 index 00000000..0bb1d11a --- /dev/null +++ b/server/spec/v1.md @@ -0,0 +1,347 @@ +# agmsg Remote Storage HTTP API v1 + +Status: draft for protocol review + +This document is the normative contract between agmsg storage drivers and the +self-hosted reference server. The words MUST, MUST NOT, SHOULD, and MAY are to +be interpreted as described by RFC 2119. + +## Scope and conventions + +V1 defines four endpoints: + +- `POST /v1/messages` +- `GET /v1/messages?after=` +- `GET /v1/members` +- `GET /v1/health` + +JSON is used for every request and response except requests without a body. +JSON field names are case-sensitive. Unknown request fields MUST be rejected +with `400 invalid-request`, so clients do not silently believe an unsupported +field was stored. + +Every team-scoped request MUST include: + +```http +Agmsg-Protocol-Version: 1 +Agmsg-Team: +``` + +Every response, including errors, MUST include: + +```http +Agmsg-Protocol-Version: 1 +``` + +`GET /v1/health` does not require either request header because it is also the +version-discovery and orchestration probe. A missing, malformed, or unsupported +version on another endpoint returns `426 unsupported-protocol-version` and the +server's supported versions. Selecting credentials and authenticating access to +the named team are deployment concerns outside this protocol version; a server +MUST NOT treat the `Agmsg-Team` header alone as authorization. + +Timestamps are RFC 3339 strings in UTC. Sequence values in JSON and query +parameters are canonical decimal strings matching `0|[1-9][0-9]*` and MUST fit +in a signed 64-bit integer. They are strings so JavaScript clients never lose +PostgreSQL `BIGINT` precision. A message UUID is a canonical lowercase RFC 4122 +UUID string. + +## Common response fields + +Successful team-scoped responses include: + +```json +{ + "protocol_version": 1, + "team": "example-team", + "min_available_seq": "0" +} +``` + +`min_available_seq` is the smallest valid cursor for the team. It is normally +zero. If retention removes all messages through sequence `N`, it becomes `N`. +A client cursor smaller than this floor cannot be incrementally repaired. + +Errors have one shape: + +```json +{ + "protocol_version": 1, + "error": { + "code": "invalid-request", + "message": "human-readable summary", + "details": {} + } +} +``` + +Clients MUST branch on `error.code`, not on `message`. + +## Message representation + +The client-supplied, immutable message payload is: + +```json +{ + "message_uuid": "018f3f7e-89ab-7def-8123-456789abcdef", + "from_agent": "leader", + "to_agent": "worker-1", + "body": "Run the test suite", + "created_at": "2026-07-20T06:30:00Z" +} +``` + +All five fields are required and MUST be strings. Agent names and `body` MUST +be non-empty. `created_at` identifies client creation time; server receipt time +is separate metadata. The immutable payload for UUID conflict checks is exactly +the four fields other than `message_uuid`. Equality is equality of decoded JSON +string values after timestamp validation and normalization to its RFC 3339 UTC +form; JSON whitespace and object key order are not payload differences. + +A server-returned message adds its canonical team sequence: + +```json +{ + "server_seq": "42", + "message_uuid": "018f3f7e-89ab-7def-8123-456789abcdef", + "from_agent": "leader", + "to_agent": "worker-1", + "body": "Run the test suite", + "created_at": "2026-07-20T06:30:00Z" +} +``` + +`server_seq` and `team_seq` refer to the same value. HTTP schemas use +`server_seq`; the database and ordering rules call it `team_seq`. + +## `POST /v1/messages` + +Stores one atomic batch. The request body is: + +```json +{ + "messages": [ + { + "message_uuid": "018f3f7e-89ab-7def-8123-456789abcdef", + "from_agent": "leader", + "to_agent": "worker-1", + "body": "Run the test suite", + "created_at": "2026-07-20T06:30:00Z" + } + ] +} +``` + +`messages` MUST contain between 1 and 1000 elements. The server validates the +whole request before committing any element. + +Success returns `200` for both new and replayed batches: + +```json +{ + "protocol_version": 1, + "team": "example-team", + "min_available_seq": "0", + "acks": [ + { + "message_uuid": "018f3f7e-89ab-7def-8123-456789abcdef", + "server_seq": "42", + "disposition": "stored" + } + ] +} +``` + +The `acks` array MUST have the same length and order as the input array. Every +input element maps to its canonical `server_seq`; no element may be omitted. +`disposition` is `stored` if this transaction first inserted the UUID and +`duplicate` if an identical UUID/payload already existed. Repeated identical +UUIDs within one request receive the same sequence; only the first occurrence +can be `stored`. + +The entire batch is one database transaction. If any UUID already exists for +the team with a different immutable payload, the server MUST return +`409 message-uuid-conflict` and roll back every insertion and sequence update: + +```json +{ + "protocol_version": 1, + "error": { + "code": "message-uuid-conflict", + "message": "message_uuid already exists with a different payload", + "details": { + "message_uuid": "018f3f7e-89ab-7def-8123-456789abcdef" + } + } +} +``` + +An implementation using `ON CONFLICT DO NOTHING RETURNING` MUST query the +conflicting UUIDs again inside the same transaction and fill the missing ack +mappings. Returning only rows produced by `RETURNING` violates this contract. + +## `GET /v1/messages?after=` + +Returns messages for the team in ascending `server_seq` order, strictly after +the supplied cursor. `after` is required and MUST be a canonical decimal +sequence string as defined above. +The optional `limit` is an integer from 1 through 1000 and defaults to 100. + +```http +GET /v1/messages?after=40&limit=100 +``` + +Success returns `200`: + +```json +{ + "protocol_version": 1, + "team": "example-team", + "min_available_seq": "0", + "messages": [ + { + "server_seq": "41", + "message_uuid": "018f3f7e-89ab-7def-8123-456789abcdef", + "from_agent": "leader", + "to_agent": "worker-1", + "body": "Run the test suite", + "created_at": "2026-07-20T06:30:00Z" + } + ], + "next_after": "41", + "has_more": false +} +``` + +`next_after` is the last returned sequence, or the supplied `after` when the +page is empty. `has_more` states whether a later sequence was visible to the +same database snapshot but excluded by `limit`. + +If `after < min_available_seq`, the server returns `410 resync-required` and no +messages: + +```json +{ + "protocol_version": 1, + "team": "example-team", + "min_available_seq": "100", + "error": { + "code": "resync-required", + "message": "cursor predates retained history", + "details": { + "after": "42", + "min_available_seq": "100" + } + } +} +``` + +The client MUST discard incremental cursor assumptions and perform the +out-of-band full-resync procedure negotiated by its storage driver. V1 does not +define that procedure. + +## `GET /v1/members` + +Returns the current team roster: + +```json +{ + "protocol_version": 1, + "team": "example-team", + "min_available_seq": "0", + "members": [ + { + "name": "worker-1", + "registrations": [ + { + "type": "codex", + "project": "/srv/projects/example" + } + ] + } + ] +} +``` + +Members are sorted by `name`; registrations are sorted by `type`, then +`project`. `name`, `type`, and `project` are non-empty strings. An agent with no +registrations has an empty `registrations` array. V1 is read-only for members; +roster mutation remains outside this API. + +## `GET /v1/health` + +A healthy server returns `200` after a successful database probe: + +```json +{ + "status": "ok", + "protocol": { + "supported_versions": [1] + }, + "database": "ok" +} +``` + +If the process is serving HTTP but its database probe fails, it returns `503` +with `status: "unavailable"`, the same protocol advertisement, and +`database: "unavailable"`. Health responses MUST NOT expose credentials, +connection strings, or raw database errors. + +## Protocol negotiation errors + +Unsupported or absent versions on team-scoped endpoints return `426`: + +```json +{ + "protocol_version": 1, + "error": { + "code": "unsupported-protocol-version", + "message": "a supported Agmsg-Protocol-Version header is required", + "details": { + "requested_version": 2, + "supported_versions": [1] + } + } +} +``` + +The response header uses the server's highest supported version. Clients MUST +NOT silently downgrade a write unless their configuration explicitly allows +that version. + +## Team sequence allocation and visibility + +V1 MUST NOT use a global sequence or a PostgreSQL `BIGSERIAL`/sequence object. +PostgreSQL sequences are non-transactional and can expose gaps or ordering that +does not match commit visibility. + +Each team has one row containing `current_seq` and `min_available_seq`. A write +transaction performs the following steps: + +1. Create the team row if absent, using an idempotent insert. +2. Lock that row with `SELECT ... FOR UPDATE`. +3. Load every UUID in the batch and reject any payload conflict. +4. In input first-occurrence order, increment the locked row's `current_seq` + once for each new UUID and insert that message with the resulting value. +5. Select all UUIDs again inside the transaction and build the complete, + input-ordered ack mapping. +6. Commit the messages, mappings, and updated `current_seq` atomically. + +Transactions for one team serialize on its team row. A later writer cannot +allocate a sequence until the prior holder commits or rolls back, so committed +`team_seq` order is the visibility order for that team. Transactions for +different teams do not share this lock and MAY proceed concurrently. A rollback +MUST leave neither messages nor consumed team sequence values. + +Retention changes to `min_available_seq` MUST lock the same team row and commit +the new floor atomically with deletion/archival of the covered messages. + +## Reference server technology (non-normative) + +The reference implementation lives in the repository's independent `server/` +package rather than the root installer package or Tauri app. The proposed stack +is TypeScript on Node.js 22 LTS with Fastify, `pg`, Zod, and Vitest. PostgreSQL +integration tests run against a real PostgreSQL service. A Dockerfile and +Compose example support self-hosting. These choices are implementation details; +clients are pinned to this protocol document, not to the reference server's +language or framework. From 68e701699caf358575010466dd99e8ae0c427d37 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 20 Jul 2026 15:35:39 +0900 Subject: [PATCH 02/10] Harden remote storage API contract --- server/spec/v1.md | 237 +++++++++++++++++++++++++++++++++------------- 1 file changed, 173 insertions(+), 64 deletions(-) diff --git a/server/spec/v1.md b/server/spec/v1.md index 0bb1d11a..aec5ce6c 100644 --- a/server/spec/v1.md +++ b/server/spec/v1.md @@ -16,15 +16,17 @@ V1 defines four endpoints: - `GET /v1/health` JSON is used for every request and response except requests without a body. -JSON field names are case-sensitive. Unknown request fields MUST be rejected -with `400 invalid-request`, so clients do not silently believe an unsupported -field was stored. +Request bodies MUST use `Content-Type: application/json` and UTF-8, MUST NOT +exceed 2 MiB, and MUST NOT contain duplicate object keys. JSON field names are +case-sensitive. Unknown request fields MUST be rejected with +`400 invalid-request`, so clients do not silently believe an unsupported field +was stored. Clients MUST ignore unknown response fields. Every team-scoped request MUST include: ```http Agmsg-Protocol-Version: 1 -Agmsg-Team: +Agmsg-Team-ID: ``` Every response, including errors, MUST include: @@ -36,15 +38,25 @@ Agmsg-Protocol-Version: 1 `GET /v1/health` does not require either request header because it is also the version-discovery and orchestration probe. A missing, malformed, or unsupported version on another endpoint returns `426 unsupported-protocol-version` and the -server's supported versions. Selecting credentials and authenticating access to -the named team are deployment concerns outside this protocol version; a server -MUST NOT treat the `Agmsg-Team` header alone as authorization. - -Timestamps are RFC 3339 strings in UTC. Sequence values in JSON and query +server's supported versions. The header version MUST equal the URL path version. +Selecting credentials and authenticating access to the team are deployment +concerns outside this protocol version; a server MUST NOT treat the +`Agmsg-Team-ID` header alone as authorization. Credentials MUST NOT be embedded +in that header or in URLs; deployments supply credentials separately (for +example, an `Authorization` header). + +Timestamps MUST use the exact UTC form `YYYY-MM-DDTHH:MM:SS.ffffffZ`, with six +fractional digits and no leap seconds. Sequence values in JSON and query parameters are canonical decimal strings matching `0|[1-9][0-9]*` and MUST fit in a signed 64-bit integer. They are strings so JavaScript clients never lose -PostgreSQL `BIGINT` precision. A message UUID is a canonical lowercase RFC 4122 -UUID string. +PostgreSQL `BIGINT` precision. Team IDs and message UUIDs MUST be canonical +lowercase UUIDv7 values as specified by RFC 9562. A team ID is never reused, +including after deletion and recreation of a team with the same display name. + +Clients MUST key cursors by `(server origin, team_id, protocol version)` and +MUST stop if a response `team_id` differs from the configured ID. Clients MUST +NOT automatically follow redirects or forward authorization/team headers to +another origin. ## Common response fields @@ -53,11 +65,16 @@ Successful team-scoped responses include: ```json { "protocol_version": 1, - "team": "example-team", + "team_id": "018f3f7e-0000-7000-8000-000000000001", + "team_name": "example-team", "min_available_seq": "0" } ``` +`team_id` is the immutable stream identity. `team_name` is a mutable display +label only: 1–128 Unicode scalar values normalized to NFC and compared +case-sensitively. It MUST NOT identify cursor state. + `min_available_seq` is the smallest valid cursor for the team. It is normally zero. If retention removes all messages through sequence `N`, it becomes `N`. A client cursor smaller than this floor cannot be incrementally repaired. @@ -77,37 +94,58 @@ Errors have one shape: Clients MUST branch on `error.code`, not on `message`. -## Message representation +## Message representation and encryption envelope -The client-supplied, immutable message payload is: +The client-supplied immutable message is: ```json { - "message_uuid": "018f3f7e-89ab-7def-8123-456789abcdef", - "from_agent": "leader", - "to_agent": "worker-1", - "body": "Run the test suite", - "created_at": "2026-07-20T06:30:00Z" + "id": "018f3f7e-89ab-7def-8123-456789abcdef", + "created_at": "2026-07-20T06:30:00.000000Z", + "envelope": { + "v": 1, + "cipher": "none", + "blob": "eyJmcm9tX2FnZW50IjoibGVhZGVyIiwidG9fYWdlbnQiOiJ3b3JrZXItMSIsImJvZHkiOiJSdW4gdGhlIHRlc3Qgc3VpdGUifQ==" + } } ``` -All five fields are required and MUST be strings. Agent names and `body` MUST -be non-empty. `created_at` identifies client creation time; server receipt time -is separate metadata. The immutable payload for UUID conflict checks is exactly -the four fields other than `message_uuid`. Equality is equality of decoded JSON -string values after timestamp validation and normalization to its RFC 3339 UTC -form; JSON whitespace and object key order are not payload differences. +`id`, `created_at`, and all three envelope fields are required. `v` is the +envelope format version and is the integer `1`. `cipher` is `none` for the v1 +driver; future drivers may use additional lowercase scheme identifiers matching +`[a-z0-9][a-z0-9._-]{0,63}` without changing this HTTP or database contract. +`blob` is 1–1,398,104 ASCII characters of canonical padded RFC 4648 base64 +(representing at most 1 MiB). The server validates only this outer syntax and +length; it does not decode the bytes. + +The server MUST treat `cipher` and `blob` as opaque values. It MUST NOT decode, +parse, index, filter, log, or validate plaintext inside `blob`. The server-visible +message fields are only team authorization context, `id`, `server_seq`, and +`created_at`. In particular, sender, recipient, and body live inside the blob; +recipient filtering occurs locally after team-stream synchronization. + +For `cipher: "none"`, the decoded blob is UTF-8 JSON containing the current +plaintext driver payload (`from_agent`, `to_agent`, and `body`). Clients produce +and consume that format, but the server remains unaware of it. Future E2EE +changes only `cipher` and blob bytes; it does not change endpoints, message +columns, ordering, or ack semantics. + +UUID conflict equality compares the canonical `created_at`, envelope `v`, +`cipher`, and exact canonical `blob` string. JSON whitespace outside the base64 +string is irrelevant. A server-returned message adds its canonical team sequence: ```json { "server_seq": "42", - "message_uuid": "018f3f7e-89ab-7def-8123-456789abcdef", - "from_agent": "leader", - "to_agent": "worker-1", - "body": "Run the test suite", - "created_at": "2026-07-20T06:30:00Z" + "id": "018f3f7e-89ab-7def-8123-456789abcdef", + "created_at": "2026-07-20T06:30:00.000000Z", + "envelope": { + "v": 1, + "cipher": "none", + "blob": "eyJmcm9tX2FnZW50IjoibGVhZGVyIiwidG9fYWdlbnQiOiJ3b3JrZXItMSIsImJvZHkiOiJSdW4gdGhlIHRlc3Qgc3VpdGUifQ==" + } } ``` @@ -122,11 +160,13 @@ Stores one atomic batch. The request body is: { "messages": [ { - "message_uuid": "018f3f7e-89ab-7def-8123-456789abcdef", - "from_agent": "leader", - "to_agent": "worker-1", - "body": "Run the test suite", - "created_at": "2026-07-20T06:30:00Z" + "id": "018f3f7e-89ab-7def-8123-456789abcdef", + "created_at": "2026-07-20T06:30:00.000000Z", + "envelope": { + "v": 1, + "cipher": "none", + "blob": "eyJmcm9tX2FnZW50IjoibGVhZGVyIiwidG9fYWdlbnQiOiJ3b3JrZXItMSIsImJvZHkiOiJSdW4gdGhlIHRlc3Qgc3VpdGUifQ==" + } } ] } @@ -140,11 +180,12 @@ Success returns `200` for both new and replayed batches: ```json { "protocol_version": 1, - "team": "example-team", + "team_id": "018f3f7e-0000-7000-8000-000000000001", + "team_name": "example-team", "min_available_seq": "0", "acks": [ { - "message_uuid": "018f3f7e-89ab-7def-8123-456789abcdef", + "id": "018f3f7e-89ab-7def-8123-456789abcdef", "server_seq": "42", "disposition": "stored" } @@ -160,7 +201,8 @@ UUIDs within one request receive the same sequence; only the first occurrence can be `stored`. The entire batch is one database transaction. If any UUID already exists for -the team with a different immutable payload, the server MUST return +the team with a different immutable payload, or the same batch repeats a UUID +with differing payloads, the server MUST return `409 message-uuid-conflict` and roll back every insertion and sequence update: ```json @@ -168,9 +210,9 @@ the team with a different immutable payload, the server MUST return "protocol_version": 1, "error": { "code": "message-uuid-conflict", - "message": "message_uuid already exists with a different payload", + "message": "message id already exists with a different payload", "details": { - "message_uuid": "018f3f7e-89ab-7def-8123-456789abcdef" + "id": "018f3f7e-89ab-7def-8123-456789abcdef" } } } @@ -196,16 +238,19 @@ Success returns `200`: ```json { "protocol_version": 1, - "team": "example-team", + "team_id": "018f3f7e-0000-7000-8000-000000000001", + "team_name": "example-team", "min_available_seq": "0", "messages": [ { "server_seq": "41", - "message_uuid": "018f3f7e-89ab-7def-8123-456789abcdef", - "from_agent": "leader", - "to_agent": "worker-1", - "body": "Run the test suite", - "created_at": "2026-07-20T06:30:00Z" + "id": "018f3f7e-89ab-7def-8123-456789abcdef", + "created_at": "2026-07-20T06:30:00.000000Z", + "envelope": { + "v": 1, + "cipher": "none", + "blob": "eyJmcm9tX2FnZW50IjoibGVhZGVyIiwidG9fYWdlbnQiOiJ3b3JrZXItMSIsImJvZHkiOiJSdW4gdGhlIHRlc3Qgc3VpdGUifQ==" + } } ], "next_after": "41", @@ -215,7 +260,8 @@ Success returns `200`: `next_after` is the last returned sequence, or the supplied `after` when the page is empty. `has_more` states whether a later sequence was visible to the -same database snapshot but excluded by `limit`. +same database snapshot but excluded by `limit`. The floor check, message page, +and `has_more` probe MUST use one database transaction and snapshot. If `after < min_available_seq`, the server returns `410 resync-required` and no messages: @@ -223,7 +269,8 @@ messages: ```json { "protocol_version": 1, - "team": "example-team", + "team_id": "018f3f7e-0000-7000-8000-000000000001", + "team_name": "example-team", "min_available_seq": "100", "error": { "code": "resync-required", @@ -236,9 +283,10 @@ messages: } ``` -The client MUST discard incremental cursor assumptions and perform the -out-of-band full-resync procedure negotiated by its storage driver. V1 does not -define that procedure. +The client MUST enter a terminal `resync-required` state, stop incremental +sync, and surface operator action. It MUST NOT reset the cursor or discard local +state automatically. V1 provides no full-snapshot operation; recovery is a +manual or driver-specific operation outside this protocol. ## `GET /v1/members` @@ -247,15 +295,19 @@ Returns the current team roster: ```json { "protocol_version": 1, - "team": "example-team", + "team_id": "018f3f7e-0000-7000-8000-000000000001", + "team_name": "example-team", "min_available_seq": "0", + "members_revision": "7", "members": [ { + "member_id": "018f3f7e-0000-7000-8000-000000000010", "name": "worker-1", "registrations": [ { - "type": "codex", - "project": "/srv/projects/example" + "registration_id": "018f3f7e-0000-7000-8000-000000000011", + "installation_id": "018f3f7e-0000-7000-8000-000000000012", + "type": "codex" } ] } @@ -263,14 +315,35 @@ Returns the current team roster: } ``` -Members are sorted by `name`; registrations are sorted by `type`, then -`project`. `name`, `type`, and `project` are non-empty strings. An agent with no -registrations has an empty `registrations` array. V1 is read-only for members; -roster mutation remains outside this API. +Members are sorted by `member_id`; registrations are sorted by +`registration_id`. All IDs are immutable UUIDv7 values. `name` follows the +agent-name rules above, `type` is a non-empty driver type string, and an agent +with no registrations has an empty array. `installation_id` identifies the +installation that owns a registration. Machine-local project paths MUST NOT be +uploaded or returned. + +V1 HTTP is read-only for members. The authoritative source is an operator +provisioning document applied by the reference server's local admin CLI. That +document contains `team_id`, `team_name`, and the complete `members` array in +the response schema above. Applying it is an atomic replace for that team: +unchanged IDs update mutable fields, new IDs are created, omitted IDs are +removed, and `members_revision` increments transactionally. The server MUST NOT +infer members from message senders or recipients, and MUST NOT require direct +database edits. Other implementations MAY use another administrative control +plane, but MUST provide the same ID/lifecycle semantics and GET representation. ## `GET /v1/health` -A healthy server returns `200` after a successful database probe: +This endpoint is a readiness and version-discovery probe, not a process +liveness signal. Orchestrators MUST NOT use its `503` response alone as a +reason to restart the process during a database outage. + +A ready server returns `200` after a successful database probe and includes +the v1 response header: + +```http +Agmsg-Protocol-Version: 1 +``` ```json { @@ -305,9 +378,33 @@ Unsupported or absent versions on team-scoped endpoints return `426`: } ``` -The response header uses the server's highest supported version. Clients MUST -NOT silently downgrade a write unless their configuration explicitly allows -that version. +The response header identifies the version that encoded the response envelope, +so `/v1` responses always use `Agmsg-Protocol-Version: 1`, even when the server +also supports newer versions. Supported alternatives are advertised only in +`details.supported_versions`. Clients MUST NOT silently downgrade a write. + +## Error and retry semantics + +All application-generated non-2xx responses use the common error envelope. + +| HTTP | Error code | Meaning | Client action | +| --- | --- | --- | --- | +| 400 | `invalid-request` | Malformed JSON, header, team ID, cursor, or schema | Do not retry unchanged | +| 401 | `unauthenticated` | Credentials absent or invalid | Stop and refresh credentials | +| 403 | `forbidden` | Credentials cannot access this team | Stop | +| 404 | `team-not-found` | Immutable team ID is not provisioned | Stop; never create implicitly | +| 409 | `message-uuid-conflict` | UUID payload differs | Stop and surface corruption/conflict | +| 410 | `resync-required` | Cursor is below retention floor | Enter terminal resync state | +| 413 | `request-too-large` | Body exceeds 2 MiB | Split/reduce without changing UUIDs | +| 426 | `unsupported-protocol-version` | Header absent or differs from path/supported versions | Stop or explicitly reconfigure | +| 429 | `rate-limited` | Temporary admission limit | Retry with backoff and `Retry-After` | +| 503 | `unavailable` | Temporary server/database failure | Retry with backoff | +| 507 | `sequence-exhausted` | Team sequence reached signed BIGINT maximum | Stop writes; reads remain available | + +A POST timeout, transport loss, or `5xx` has unknown outcome. The client MUST +retry the same UUID/payload batch and rely on complete ack mapping; it MUST NOT +mint replacement UUIDs. `409` is not retryable. Servers SHOULD include +`Retry-After` on `429` and MAY include it on `503`. ## Team sequence allocation and visibility @@ -318,7 +415,8 @@ does not match commit visibility. Each team has one row containing `current_seq` and `min_available_seq`. A write transaction performs the following steps: -1. Create the team row if absent, using an idempotent insert. +1. Verify that operator provisioning created the immutable team row; otherwise + return `404 team-not-found` without creating it from an untrusted header. 2. Lock that row with `SELECT ... FOR UPDATE`. 3. Load every UUID in the batch and reject any payload conflict. 4. In input first-occurrence order, increment the locked row's `current_seq` @@ -333,8 +431,19 @@ allocate a sequence until the prior holder commits or rolls back, so committed different teams do not share this lock and MAY proceed concurrently. A rollback MUST leave neither messages nor consumed team sequence values. +If `current_seq` is `9223372036854775807`, the server MUST reject new UUIDs with +`507 sequence-exhausted` and MUST NOT wrap, become negative, or partially store +the batch. Duplicate UUID lookups and existing reads remain available. + Retention changes to `min_available_seq` MUST lock the same team row and commit -the new floor atomically with deletion/archival of the covered messages. +the new floor atomically with deletion/archival of the covered delivery rows. +The server MUST retain, for the lifetime of the immutable team ID, a dedupe +tombstone containing `(team_id, id, created_at, envelope, original team_seq)`. +Retention MUST NOT permit a replayed UUID to acquire a new +sequence. An identical replay returns `duplicate` with its original sequence; +a differing replay remains `409`, even when its delivery row is below the pull +floor. V1 intentionally specifies permanent idempotency metadata and advertises +no finite replay window. ## Reference server technology (non-normative) From 30f72e137bdb9e875332d3567f6794b5767f1f08 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 20 Jul 2026 15:54:04 +0900 Subject: [PATCH 03/10] Pin remote envelope and capability semantics --- server/spec/v1.md | 335 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 276 insertions(+), 59 deletions(-) diff --git a/server/spec/v1.md b/server/spec/v1.md index aec5ce6c..ecd87658 100644 --- a/server/spec/v1.md +++ b/server/spec/v1.md @@ -8,16 +8,19 @@ be interpreted as described by RFC 2119. ## Scope and conventions -V1 defines four endpoints: +V1 defines five endpoints: - `POST /v1/messages` - `GET /v1/messages?after=` - `GET /v1/members` +- `GET /v1/capabilities` - `GET /v1/health` JSON is used for every request and response except requests without a body. Request bodies MUST use `Content-Type: application/json` and UTF-8, MUST NOT -exceed 2 MiB, and MUST NOT contain duplicate object keys. JSON field names are +exceed 2 MiB as counted from streamed request bytes, and MUST NOT contain +duplicate object keys. V1 rejects any `Content-Encoding` other than `identity`. +JSON field names are case-sensitive. Unknown request fields MUST be rejected with `400 invalid-request`, so clients do not silently believe an unsupported field was stored. Clients MUST ignore unknown response fields. @@ -49,12 +52,35 @@ Timestamps MUST use the exact UTC form `YYYY-MM-DDTHH:MM:SS.ffffffZ`, with six fractional digits and no leap seconds. Sequence values in JSON and query parameters are canonical decimal strings matching `0|[1-9][0-9]*` and MUST fit in a signed 64-bit integer. They are strings so JavaScript clients never lose -PostgreSQL `BIGINT` precision. Team IDs and message UUIDs MUST be canonical -lowercase UUIDv7 values as specified by RFC 9562. A team ID is never reused, -including after deletion and recreation of a team with the same display name. - -Clients MUST key cursors by `(server origin, team_id, protocol version)` and -MUST stop if a response `team_id` differs from the configured ID. Clients MUST +PostgreSQL `BIGINT` precision. Team IDs and server instance IDs MUST be +canonical lowercase UUIDv7 values as specified by RFC 9562. A team ID is never +reused, including after deletion and recreation with the same display name. + +Agent-name strings are first normalized to NFC and then MUST contain 1–128 +Unicode scalar values. They MUST NOT start with `-`, equal `.` or `..`, contain +any of `.`, `/`, `\`, `"`, `[`, or `]`, or contain U+0000–U+001F or U+007F. +These rules pin remote member projection to the local agmsg agent-name safety +contract while retaining non-ASCII names. + +Message `id` is a client-generated opaque 128-bit UUID value represented as a +canonical lowercase UUIDv4 (122 random payload bits plus the required UUID +version and variant bits). It MUST be generated with a cryptographically secure +random number generator, be unique within the team stream, be generated once +with the envelope, and be durably stored before POST. Local driver IDs remain +local; clients persist the local-ID/wire-ID mapping in reconciliation state and +MUST NOT regenerate a wire ID during retry. When importing a remote message, a +client assigns a local ID according to its local driver contract and durably +maps it to the unchanged wire ID. + +Each database deployment has an immutable, canonical lowercase UUIDv7 +`server_instance_id`, created once and persisted with the database. Restoring or +moving the same database retains it; initializing a new database creates a new +one. Reverse-proxy aliases for one deployment therefore share the ID, while a +replacement database at the same URL does not. + +Clients MUST key cursors by `(server_instance_id, team_id, protocol version)` +and MUST stop if a response ID differs from its stored binding. Before the first +team request, a client discovers `server_instance_id` from health. Clients MUST NOT automatically follow redirects or forward authorization/team headers to another origin. @@ -65,6 +91,7 @@ Successful team-scoped responses include: ```json { "protocol_version": 1, + "server_instance_id": "018f3f7e-0000-7000-8000-000000000000", "team_id": "018f3f7e-0000-7000-8000-000000000001", "team_name": "example-team", "min_available_seq": "0" @@ -72,8 +99,8 @@ Successful team-scoped responses include: ``` `team_id` is the immutable stream identity. `team_name` is a mutable display -label only: 1–128 Unicode scalar values normalized to NFC and compared -case-sensitively. It MUST NOT identify cursor state. +label only. It is first normalized to NFC and then MUST contain 1–128 Unicode +scalar values; comparison is case-sensitive. It MUST NOT identify cursor state. `min_available_seq` is the smallest valid cursor for the team. It is normally zero. If retention removes all messages through sequence `N`, it becomes `N`. @@ -94,63 +121,165 @@ Errors have one shape: Clients MUST branch on `error.code`, not on `message`. +Errors produced before database/team resolution (`400`, `401`, malformed +version `426`) omit `server_instance_id` and `team_id`. Once the database is +resolved, every application error includes `server_instance_id`; once the team +is resolved, it also includes `team_id`. Thus `403`, `409`, `410`, `429`, and +team-scoped `503` include both IDs. `404 team-not-found` includes +`server_instance_id` and the requested `team_id`. Clients apply the same ID +mismatch stop rule to success and error responses. + ## Message representation and encryption envelope The client-supplied immutable message is: ```json { - "id": "018f3f7e-89ab-7def-8123-456789abcdef", - "created_at": "2026-07-20T06:30:00.000000Z", + "id": "550e8400-e29b-41d4-a716-446655440000", "envelope": { "v": 1, "cipher": "none", - "blob": "eyJmcm9tX2FnZW50IjoibGVhZGVyIiwidG9fYWdlbnQiOiJ3b3JrZXItMSIsImJvZHkiOiJSdW4gdGhlIHRlc3Qgc3VpdGUifQ==" + "key_id": null, + "blob": "eyJib2R5IjoiUnVuIHRoZSB0ZXN0IHN1aXRlIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDctMjBUMDY6MzA6MDAuMDAwMDAwWiIsImZyb21fYWdlbnQiOiJsZWFkZXIiLCJ0b19hZ2VudCI6Indvcmtlci0xIn0=" } } ``` -`id`, `created_at`, and all three envelope fields are required. `v` is the -envelope format version and is the integer `1`. `cipher` is `none` for the v1 +`id` and all four envelope fields are required. `v` is the envelope format +version and is the integer `1`. `cipher` is `none` for the v1 driver; future drivers may use additional lowercase scheme identifiers matching `[a-z0-9][a-z0-9._-]{0,63}` without changing this HTTP or database contract. +`key_id` is null for `none`; encrypted schemes use a non-empty, scheme-defined +key/epoch identifier so readers never trial-decrypt against unbounded keys. `blob` is 1–1,398,104 ASCII characters of canonical padded RFC 4648 base64 -(representing at most 1 MiB). The server validates only this outer syntax and -length; it does not decode the bytes. +(representing at most 1 MiB). The server MAY base64-decode solely to verify +canonical padding/size and compute the idempotency digest. It MUST treat the +resulting bytes as opaque and MUST NOT persist or log the decoded form. + +The server MUST treat `blob` contents as opaque. It MUST NOT decode, parse, +index, filter, log, or validate plaintext inside `blob`. The only +application-message fields visible to the server are team authorization +context, `id`, `server_seq`, and server-assigned `server_received_at`. Envelope +admission metadata (`v`, `cipher`, `key_id`, and blob byte length) is also +necessarily visible for capability and policy enforcement, but is not a +routing or search surface. Client creation time, sender, recipient, and body +live inside the blob; recipient filtering occurs locally after team-stream +synchronization. + +For `cipher: "none"`, decoded bytes MUST be RFC 8785 JSON Canonicalization +Scheme (JCS) encoding of exactly this object (no unknown or duplicate keys): -The server MUST treat `cipher` and `blob` as opaque values. It MUST NOT decode, -parse, index, filter, log, or validate plaintext inside `blob`. The server-visible -message fields are only team authorization context, `id`, `server_seq`, and -`created_at`. In particular, sender, recipient, and body live inside the blob; -recipient filtering occurs locally after team-stream synchronization. - -For `cipher: "none"`, the decoded blob is UTF-8 JSON containing the current -plaintext driver payload (`from_agent`, `to_agent`, and `body`). Clients produce -and consume that format, but the server remains unaware of it. Future E2EE -changes only `cipher` and blob bytes; it does not change endpoints, message -columns, ordering, or ack semantics. +```json +{"body":"Run the test suite","created_at":"2026-07-20T06:30:00.000000Z","from_agent":"leader","to_agent":"worker-1"} +``` -UUID conflict equality compares the canonical `created_at`, envelope `v`, -`cipher`, and exact canonical `blob` string. JSON whitespace outside the base64 -string is irrelevant. +All fields are required strings. `created_at` follows the canonical timestamp +rule. `from_agent` and `to_agent` follow the agent-name rules above. `body` is +non-empty and at most 1,000,000 UTF-8 bytes before JSON encoding. Clients +produce and validate this plaintext format, but the server remains unaware of +it. + +Before the first POST, a client MUST durably store the exact `id`, `v`, +`cipher`, `key_id`, and `blob`. Every retry, crash recovery, and compaction replay +MUST reuse those exact values; it MUST NOT reserialize, re-encode, or re-encrypt +the same ID. This remains required for randomized future encryption. + +V1 clients MUST emit `cipher: "none"`; a server may recognize additional cipher +profiles while continuing to store their blobs opaquely. A v1 client receiving +an unknown cipher MUST +durably quarantine the unchanged envelope, surface an unsupported-cipher state, +and may advance the team cursor only after quarantine is durable. It MUST NOT +discard or interpret the blob. Future ciphers require separately pinned scheme +profiles, capability handling, key lifecycle, AEAD associated-data rules, and +padding policy. Those contracts are reserved, not defined by v1. The envelope +boundary keeps HTTP message columns/endpoints stable; client crypto contracts +are not claimed to remain unchanged. + +Any future authenticated-encryption profile MUST bind this wire `id` as the +message-UUID component of its AEAD associated data. A local driver ID MUST NOT +be used for that purpose because it differs across replicas. + +UUID conflict equality is keyed by the wire `id` and compares exact canonical +envelope bytes: RFC 8785 JCS encoding of the object containing `v`, `cipher`, +`key_id`, and `blob`. JSON whitespace and input key order are irrelevant, but +all envelope values are byte-stable. Server-assigned `server_received_at` is not +part of conflict equality. A server-returned message adds its canonical team sequence: ```json { "server_seq": "42", - "id": "018f3f7e-89ab-7def-8123-456789abcdef", - "created_at": "2026-07-20T06:30:00.000000Z", + "id": "550e8400-e29b-41d4-a716-446655440000", + "server_received_at": "2026-07-20T06:30:01.000000Z", "envelope": { "v": 1, "cipher": "none", - "blob": "eyJmcm9tX2FnZW50IjoibGVhZGVyIiwidG9fYWdlbnQiOiJ3b3JrZXItMSIsImJvZHkiOiJSdW4gdGhlIHRlc3Qgc3VpdGUifQ==" + "key_id": null, + "blob": "eyJib2R5IjoiUnVuIHRoZSB0ZXN0IHN1aXRlIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDctMjBUMDY6MzA6MDAuMDAwMDAwWiIsImZyb21fYWdlbnQiOiJsZWFkZXIiLCJ0b19hZ2VudCI6Indvcmtlci0xIn0=" } } ``` `server_seq` and `team_seq` refer to the same value. HTTP schemas use `server_seq`; the database and ordering rules call it `team_seq`. +`server_received_at` is assigned once by the server when a new ID is inserted, +uses the canonical timestamp format, and never changes on duplicate replay. It +is ordering/diagnostic metadata only; sequence remains authoritative. + +## `GET /v1/capabilities` + +This authenticated, team-scoped endpoint advertises the current write policy. +It is deliberately separate from unauthenticated health and says nothing about +which ciphers a particular client can read. + +```json +{ + "protocol_version": 1, + "server_instance_id": "018f3f7e-0000-7000-8000-000000000000", + "team_id": "018f3f7e-0000-7000-8000-000000000001", + "team_name": "example-team", + "min_available_seq": "0", + "accepted_envelope_versions": [1], + "write_allowed_ciphers": ["none"], + "policy_revision": "0", + "effective_from_seq": "1", + "max_blob_bytes": "1048576", + "policy_history": [ + { + "policy_revision": "0", + "effective_from_seq": "1", + "accepted_envelope_versions": [1], + "write_allowed_ciphers": ["none"] + } + ] +} +``` + +`policy_revision` is a server-assigned canonical decimal string, initially `0`, +incremented atomically whenever policy changes. `effective_from_seq` is the +first team sequence governed by this policy revision. Sequences begin at `1`, +so a newly provisioned team's revision `0` is effective from `1`. These values +let readers distinguish permitted legacy plaintext from a post-policy +downgrade. `policy_history` is the complete, ascending list of revisions for +the immutable team ID, including the current revision. The server MUST retain +and return this history for the team's lifetime so clients with durable older +quarantine can evaluate the policy effective at any sequence. Its final entry +MUST exactly match the response's current `policy_revision`, +`effective_from_seq`, `accepted_envelope_versions`, and +`write_allowed_ciphers` fields. +`max_blob_bytes` is decoded opaque-byte size and MUST NOT exceed the protocol +maximum. + +Capability reads and POST policy enforcement MUST use the same authoritative +team policy state. Servers only advertise write admission. Client read/decrypt +capability is local state and MUST NOT be inferred from this response. +The current fields and complete `policy_history` MUST be read from one database +transaction and snapshot. +Policy mutation locks the team row, increments `policy_revision`, and records +`effective_from_seq = current_seq + 1` atomically. A policy change whose next +revision or `effective_from_seq` would exceed signed BIGINT maximum MUST fail +atomically rather than wrap. ## `POST /v1/messages` @@ -160,12 +289,12 @@ Stores one atomic batch. The request body is: { "messages": [ { - "id": "018f3f7e-89ab-7def-8123-456789abcdef", - "created_at": "2026-07-20T06:30:00.000000Z", + "id": "550e8400-e29b-41d4-a716-446655440000", "envelope": { "v": 1, "cipher": "none", - "blob": "eyJmcm9tX2FnZW50IjoibGVhZGVyIiwidG9fYWdlbnQiOiJ3b3JrZXItMSIsImJvZHkiOiJSdW4gdGhlIHRlc3Qgc3VpdGUifQ==" + "key_id": null, + "blob": "eyJib2R5IjoiUnVuIHRoZSB0ZXN0IHN1aXRlIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDctMjBUMDY6MzA6MDAuMDAwMDAwWiIsImZyb21fYWdlbnQiOiJsZWFkZXIiLCJ0b19hZ2VudCI6Indvcmtlci0xIn0=" } } ] @@ -175,17 +304,38 @@ Stores one atomic batch. The request body is: `messages` MUST contain between 1 and 1000 elements. The server validates the whole request before committing any element. +Inside the single transaction, processing order is normative: + +1. Authenticate/authorize `team_id`, lock the team row, and look up every input + ID (including permanent tombstones). +2. For an existing ID, compare exact canonical envelope bytes. An equal replay + receives its original duplicate ack regardless of current cipher policy; an + unequal replay makes the whole batch `409`. +3. For new IDs only, reject an envelope version/cipher the server cannot store + with `422 unsupported-cipher`. If the cipher is recognized but disallowed by + current team policy, reject with `403 cipher-policy-violation`. +4. Only after every element passes those steps may the server allocate sequences + and insert new IDs. + +Both cipher errors include `write_allowed_ciphers`, `policy_revision`, and the +offending `id`, `v`, and `cipher` in `error.details`. An +`unsupported-cipher` error also includes `accepted_envelope_versions`. This +ordering preserves idempotent replay of legacy ciphertext after policy changes +without allowing new downgrade writes. + Success returns `200` for both new and replayed batches: ```json { "protocol_version": 1, + "server_instance_id": "018f3f7e-0000-7000-8000-000000000000", "team_id": "018f3f7e-0000-7000-8000-000000000001", "team_name": "example-team", "min_available_seq": "0", + "policy_revision": "0", "acks": [ { - "id": "018f3f7e-89ab-7def-8123-456789abcdef", + "id": "550e8400-e29b-41d4-a716-446655440000", "server_seq": "42", "disposition": "stored" } @@ -208,11 +358,13 @@ with differing payloads, the server MUST return ```json { "protocol_version": 1, + "server_instance_id": "018f3f7e-0000-7000-8000-000000000000", + "team_id": "018f3f7e-0000-7000-8000-000000000001", "error": { "code": "message-uuid-conflict", "message": "message id already exists with a different payload", "details": { - "id": "018f3f7e-89ab-7def-8123-456789abcdef" + "id": "550e8400-e29b-41d4-a716-446655440000" } } } @@ -238,18 +390,20 @@ Success returns `200`: ```json { "protocol_version": 1, + "server_instance_id": "018f3f7e-0000-7000-8000-000000000000", "team_id": "018f3f7e-0000-7000-8000-000000000001", "team_name": "example-team", "min_available_seq": "0", "messages": [ { "server_seq": "41", - "id": "018f3f7e-89ab-7def-8123-456789abcdef", - "created_at": "2026-07-20T06:30:00.000000Z", + "id": "550e8400-e29b-41d4-a716-446655440000", + "server_received_at": "2026-07-20T06:30:01.000000Z", "envelope": { "v": 1, "cipher": "none", - "blob": "eyJmcm9tX2FnZW50IjoibGVhZGVyIiwidG9fYWdlbnQiOiJ3b3JrZXItMSIsImJvZHkiOiJSdW4gdGhlIHRlc3Qgc3VpdGUifQ==" + "key_id": null, + "blob": "eyJib2R5IjoiUnVuIHRoZSB0ZXN0IHN1aXRlIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDctMjBUMDY6MzA6MDAuMDAwMDAwWiIsImZyb21fYWdlbnQiOiJsZWFkZXIiLCJ0b19hZ2VudCI6Indvcmtlci0xIn0=" } } ], @@ -269,6 +423,7 @@ messages: ```json { "protocol_version": 1, + "server_instance_id": "018f3f7e-0000-7000-8000-000000000000", "team_id": "018f3f7e-0000-7000-8000-000000000001", "team_name": "example-team", "min_available_seq": "100", @@ -288,6 +443,31 @@ sync, and surface operator action. It MUST NOT reset the cursor or discard local state automatically. V1 provides no full-snapshot operation; recovery is a manual or driver-specific operation outside this protocol. +### Pull state layers + +Clients MUST keep three independent progress layers: + +1. **Transport:** durably quarantine every envelope together with wire ID and + `server_seq`, then advance the transport cursor. A crash must not lose an + envelope whose sequence was acknowledged locally. +2. **Decrypt/import:** process quarantined envelopes into states such as + `pending_key`, `unsupported_cipher`, `authentication_failed`, or `malformed`. + Key/capability changes may reprocess this layer without rewinding transport. +3. **Read:** user/agent read state advances independently of both transport and + decrypt/import state. + +Readers use capability policy history (`policy_revision` and +`effective_from_seq`) to distinguish allowed legacy `none` envelopes from a +post-policy downgrade attempt. + +Because routing fields are opaque, the server cannot provide recipient routing, +search, or per-recipient delivery. Clients download the team stream and project +recipients locally; wake notification is team-wide. E2EE protects blob contents, +not metadata: team relationship, random wire ID, server receipt time, envelope +version, cipher identifier, key identifier, blob size, traffic frequency, and +sequence remain visible. Random UUIDv4 removes avoidable embedded generation +time but does not make metadata private. + ## `GET /v1/members` Returns the current team roster: @@ -295,6 +475,7 @@ Returns the current team roster: ```json { "protocol_version": 1, + "server_instance_id": "018f3f7e-0000-7000-8000-000000000000", "team_id": "018f3f7e-0000-7000-8000-000000000001", "team_name": "example-team", "min_available_seq": "0", @@ -317,7 +498,7 @@ Returns the current team roster: Members are sorted by `member_id`; registrations are sorted by `registration_id`. All IDs are immutable UUIDv7 values. `name` follows the -agent-name rules above, `type` is a non-empty driver type string, and an agent +agent-name rules above. `type` is a non-empty driver type string, and an agent with no registrations has an empty array. `installation_id` identifies the installation that owns a registration. Machine-local project paths MUST NOT be uploaded or returned. @@ -325,12 +506,25 @@ uploaded or returned. V1 HTTP is read-only for members. The authoritative source is an operator provisioning document applied by the reference server's local admin CLI. That document contains `team_id`, `team_name`, and the complete `members` array in -the response schema above. Applying it is an atomic replace for that team: +the response schema above; input MUST NOT contain `members_revision`. Applying +it is an atomic replace for that team: unchanged IDs update mutable fields, new IDs are created, omitted IDs are removed, and `members_revision` increments transactionally. The server MUST NOT infer members from message senders or recipients, and MUST NOT require direct database edits. Other implementations MAY use another administrative control plane, but MUST provide the same ID/lifecycle semantics and GET representation. +`members_revision` is a server-assigned signed-BIGINT canonical decimal string, +initially `0`. Provisioning at its maximum MUST fail atomically rather than wrap. + +Within a team, member IDs, normalized member names, and registration IDs MUST be +unique. A registration belongs to exactly one member. Reusing one +`installation_id` across multiple registrations is allowed. Provisioning MUST +reject the entire document on a duplicate or ownership conflict. Renaming a +member retains its `member_id`; removing a member or registration permanently +retires its ID, which MUST NOT later identify a different object. A normalized +member name, once assigned, MUST NOT later be assigned to a different +`member_id`; this prevents historical opaque sender/recipient names from being +projected onto a new identity. ## `GET /v1/health` @@ -348,6 +542,7 @@ Agmsg-Protocol-Version: 1 ```json { "status": "ok", + "server_instance_id": "018f3f7e-0000-7000-8000-000000000000", "protocol": { "supported_versions": [1] }, @@ -385,26 +580,36 @@ also supports newer versions. Supported alternatives are advertised only in ## Error and retry semantics -All application-generated non-2xx responses use the common error envelope. +All application-generated non-2xx responses use the common error envelope, +except the unauthenticated readiness shape from `GET /v1/health`. | HTTP | Error code | Meaning | Client action | | --- | --- | --- | --- | | 400 | `invalid-request` | Malformed JSON, header, team ID, cursor, or schema | Do not retry unchanged | | 401 | `unauthenticated` | Credentials absent or invalid | Stop and refresh credentials | | 403 | `forbidden` | Credentials cannot access this team | Stop | +| 403 | `cipher-policy-violation` | Recognized cipher is not currently write-allowed | Refresh capabilities; do not retry unchanged | | 404 | `team-not-found` | Immutable team ID is not provisioned | Stop; never create implicitly | | 409 | `message-uuid-conflict` | UUID payload differs | Stop and surface corruption/conflict | | 410 | `resync-required` | Cursor is below retention floor | Enter terminal resync state | -| 413 | `request-too-large` | Body exceeds 2 MiB | Split/reduce without changing UUIDs | +| 413 | `request-too-large` | Body exceeds 2 MiB | Split/reduce while preserving exact durable IDs/envelopes | +| 422 | `unsupported-cipher` | Envelope version/cipher is not supported by this server | Stop or upgrade/reconfigure | | 426 | `unsupported-protocol-version` | Header absent or differs from path/supported versions | Stop or explicitly reconfigure | | 429 | `rate-limited` | Temporary admission limit | Retry with backoff and `Retry-After` | +| 500 | `internal-error` | Temporary unclassified server failure | Retry exact request with backoff | +| 502 | `unavailable` | Temporary upstream failure | Retry exact request with backoff | | 503 | `unavailable` | Temporary server/database failure | Retry with backoff | +| 504 | `unavailable` | Temporary upstream timeout | Retry exact request with backoff | | 507 | `sequence-exhausted` | Team sequence reached signed BIGINT maximum | Stop writes; reads remain available | -A POST timeout, transport loss, or `5xx` has unknown outcome. The client MUST -retry the same UUID/payload batch and rely on complete ack mapping; it MUST NOT -mint replacement UUIDs. `409` is not retryable. Servers SHOULD include +A POST timeout, transport loss, or `500`, `502`, `503`, or `504` has unknown +outcome. The client MUST retry the exact durable ID/envelope batch and rely on +complete ack mapping; it MUST NOT mint replacement UUIDs. `409`, `422`, and +`507` are definitive and non-retryable. Servers SHOULD include `Retry-After` on `429` and MAY include it on `503`. +An HTTP intermediary may generate `502`, `503`, or `504` without the protocol +error envelope; clients still apply the status-based retry rule and MUST verify +all binding IDs on the eventual protocol response. ## Team sequence allocation and visibility @@ -418,12 +623,15 @@ transaction performs the following steps: 1. Verify that operator provisioning created the immutable team row; otherwise return `404 team-not-found` without creating it from an untrusted header. 2. Lock that row with `SELECT ... FOR UPDATE`. -3. Load every UUID in the batch and reject any payload conflict. -4. In input first-occurrence order, increment the locked row's `current_seq` +3. Load every UUID in the batch, reject conflicts, enforce new-ID policy, and + count distinct new IDs as `K` (duplicate occurrences count once). +4. Before allocation, if `K > 9223372036854775807 - current_seq`, reject the + whole batch with `507 sequence-exhausted`. +5. In input first-occurrence order, increment the locked row's `current_seq` once for each new UUID and insert that message with the resulting value. -5. Select all UUIDs again inside the transaction and build the complete, +6. Select all UUIDs again inside the transaction and build the complete, input-ordered ack mapping. -6. Commit the messages, mappings, and updated `current_seq` atomically. +7. Commit the messages, mappings, and updated `current_seq` atomically. Transactions for one team serialize on its team row. A later writer cannot allocate a sequence until the prior holder commits or rolls back, so committed @@ -431,19 +639,28 @@ allocate a sequence until the prior holder commits or rolls back, so committed different teams do not share this lock and MAY proceed concurrently. A rollback MUST leave neither messages nor consumed team sequence values. -If `current_seq` is `9223372036854775807`, the server MUST reject new UUIDs with -`507 sequence-exhausted` and MUST NOT wrap, become negative, or partially store -the batch. Duplicate UUID lookups and existing reads remain available. +The preflight covers both an already-exhausted counter and a batch crossing the +boundary. The server MUST NOT wrap, become negative, or rely on a database +overflow error. Duplicate UUID lookups and existing reads remain available. Retention changes to `min_available_seq` MUST lock the same team row and commit the new floor atomically with deletion/archival of the covered delivery rows. The server MUST retain, for the lifetime of the immutable team ID, a dedupe -tombstone containing `(team_id, id, created_at, envelope, original team_seq)`. +tombstone containing `(team_id, id, envelope_digest, original team_seq)`. +`envelope_digest` is SHA-256 over: ASCII `agmsg-envelope-v1` followed by a zero +byte; envelope `v` as unsigned 32-bit big-endian; unsigned 32-bit big-endian +length plus UTF-8 bytes of `cipher`; one byte `0` for null `key_id`, otherwise +byte `1` then its length+UTF-8 bytes; and unsigned 32-bit big-endian length plus +the base64-decoded opaque blob bytes. This exact length-prefixed preimage is the +canonical immutable payload digest. Decoded bytes used for hashing are not +persisted or logged. + Retention MUST NOT permit a replayed UUID to acquire a new sequence. An identical replay returns `duplicate` with its original sequence; a differing replay remains `409`, even when its delivery row is below the pull -floor. V1 intentionally specifies permanent idempotency metadata and advertises -no finite replay window. +floor. V1 intentionally retains only permanent digest/idempotency metadata, not +the envelope/body, and advertises no finite replay window. SHA-256 collision +risk is accepted as the protocol's idempotency tradeoff. ## Reference server technology (non-normative) From 7a0a03edc5bf89a68209e6c0e7ab513ec94abe87 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 20 Jul 2026 15:55:05 +0900 Subject: [PATCH 04/10] Clarify remote identity and policy history --- server/spec/v1.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/server/spec/v1.md b/server/spec/v1.md index ecd87658..0805a4c8 100644 --- a/server/spec/v1.md +++ b/server/spec/v1.md @@ -66,11 +66,13 @@ Message `id` is a client-generated opaque 128-bit UUID value represented as a canonical lowercase UUIDv4 (122 random payload bits plus the required UUID version and variant bits). It MUST be generated with a cryptographically secure random number generator, be unique within the team stream, be generated once -with the envelope, and be durably stored before POST. Local driver IDs remain -local; clients persist the local-ID/wire-ID mapping in reconciliation state and -MUST NOT regenerate a wire ID during retry. When importing a remote message, a -client assigns a local ID according to its local driver contract and durably -maps it to the unchanged wire ID. +with the envelope, and be durably stored before POST. A timestamp-bearing UUID, +including UUIDv7, MUST NOT be used as the wire ID. Local driver IDs remain local +and continue to follow the local driver's UUIDv7 contract; clients persist the +local-ID/wire-ID mapping in reconciliation state and MUST NOT regenerate a wire +ID during retry. When importing a remote message, a client assigns a local ID +according to its local driver contract and durably maps it to the unchanged +wire ID. Each database deployment has an immutable, canonical lowercase UUIDv7 `server_instance_id`, created once and persisted with the database. Restoring or @@ -268,6 +270,10 @@ quarantine can evaluate the policy effective at any sequence. Its final entry MUST exactly match the response's current `policy_revision`, `effective_from_seq`, `accepted_envelope_versions`, and `write_allowed_ciphers` fields. +For a message at sequence `S`, the effective policy is the history entry with +the greatest `policy_revision` among entries whose `effective_from_seq <= S`. +Multiple policy changes before the next message may therefore share one +`effective_from_seq` boundary. `max_blob_bytes` is decoded opaque-byte size and MUST NOT exceed the protocol maximum. @@ -552,7 +558,9 @@ Agmsg-Protocol-Version: 1 If the process is serving HTTP but its database probe fails, it returns `503` with `status: "unavailable"`, the same protocol advertisement, and -`database: "unavailable"`. Health responses MUST NOT expose credentials, +`database: "unavailable"`. It omits `server_instance_id` if that persisted value +could not be read, and clients MUST NOT create or change a cursor binding from +an unavailable response. Health responses MUST NOT expose credentials, connection strings, or raw database errors. ## Protocol negotiation errors From eee0a7dbb36b385114c8baa2a7f9b31dc7132990 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 20 Jul 2026 16:00:54 +0900 Subject: [PATCH 05/10] Close remote reconciliation and policy gaps --- server/spec/v1.md | 92 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 20 deletions(-) diff --git a/server/spec/v1.md b/server/spec/v1.md index 0805a4c8..c9051a1a 100644 --- a/server/spec/v1.md +++ b/server/spec/v1.md @@ -69,10 +69,12 @@ random number generator, be unique within the team stream, be generated once with the envelope, and be durably stored before POST. A timestamp-bearing UUID, including UUIDv7, MUST NOT be used as the wire ID. Local driver IDs remain local and continue to follow the local driver's UUIDv7 contract; clients persist the -local-ID/wire-ID mapping in reconciliation state and MUST NOT regenerate a wire -ID during retry. When importing a remote message, a client assigns a local ID -according to its local driver contract and durably maps it to the unchanged -wire ID. +local-ID/wire-ID mapping together with the immutable canonical envelope in +reconciliation state and MUST NOT regenerate a wire ID during retry. A pulled +wire ID with no mapping receives a new local ID under the local driver contract, +and that mapping is made durable as part of import. A pulled wire ID that +already has a mapping follows the echo-reconciliation rules under Pull state +layers and MUST NOT receive another local ID. Each database deployment has an immutable, canonical lowercase UUIDv7 `server_instance_id`, created once and persisted with the database. Restoring or @@ -151,8 +153,11 @@ The client-supplied immutable message is: version and is the integer `1`. `cipher` is `none` for the v1 driver; future drivers may use additional lowercase scheme identifiers matching `[a-z0-9][a-z0-9._-]{0,63}` without changing this HTTP or database contract. -`key_id` is null for `none`; encrypted schemes use a non-empty, scheme-defined -key/epoch identifier so readers never trial-decrypt against unbounded keys. +`key_id` MUST be either JSON null or a JSON string already normalized to NFC. +A non-null value is 1–256 UTF-8 bytes, contains no U+0000–U+001F or U+007F, and +is compared by exact UTF-8 bytes without case folding. It MUST be null for +`none`; encrypted schemes use a non-null scheme-defined key/epoch identifier so +readers never trial-decrypt against unbounded keys. `blob` is 1–1,398,104 ASCII characters of canonical padded RFC 4648 base64 (representing at most 1 MiB). The server MAY base64-decode solely to verify canonical padding/size and compute the idempotency digest. It MUST treat the @@ -179,7 +184,10 @@ All fields are required strings. `created_at` follows the canonical timestamp rule. `from_agent` and `to_agent` follow the agent-name rules above. `body` is non-empty and at most 1,000,000 UTF-8 bytes before JSON encoding. Clients produce and validate this plaintext format, but the server remains unaware of -it. +it. The complete JCS-encoded plaintext, after JSON escaping, MUST fit within +the smaller of the protocol's 1 MiB decoded-blob limit and the authenticated +capability response's `max_blob_bytes`. The body limit alone is not sufficient +admission validation. Before the first POST, a client MUST durably store the exact `id`, `v`, `cipher`, `key_id`, and `blob`. Every retry, crash recovery, and compaction replay @@ -235,6 +243,17 @@ This authenticated, team-scoped endpoint advertises the current write policy. It is deliberately separate from unauthenticated health and says nothing about which ciphers a particular client can read. +Independently of server policy, every client sync configuration MUST durably +pin a local `minimum_security_mode` to its `(server_instance_id, team_id)` +binding. V1 defines `plaintext-allowed` and `e2ee-required`; the latter excludes +`none`. Initial binding requires an explicit local/operator choice, and a server +response MUST NOT silently create, lower, or replace it. An approved server +rebind retains the pin unless the operator explicitly changes it. For new +messages, the effective write set is the intersection of the server's +`write_allowed_ciphers`, locally supported ciphers, and ciphers satisfying the +local minimum. An empty intersection is a terminal configuration state, not a +reason to downgrade. + ```json { "protocol_version": 1, @@ -263,11 +282,15 @@ incremented atomically whenever policy changes. `effective_from_seq` is the first team sequence governed by this policy revision. Sequences begin at `1`, so a newly provisioned team's revision `0` is effective from `1`. These values let readers distinguish permitted legacy plaintext from a post-policy -downgrade. `policy_history` is the complete, ascending list of revisions for -the immutable team ID, including the current revision. The server MUST retain -and return this history for the team's lifetime so clients with durable older -quarantine can evaluate the policy effective at any sequence. Its final entry -MUST exactly match the response's current `policy_revision`, +downgrade. `policy_history` is the complete, ascending effective history for the +immutable team ID, including the current revision. If multiple revisions share +one `effective_from_seq`, only the greatest revision is effective and the server +MUST collapse superseded entries in this response. At most 4096 effective +history entries may exist for a team; an administrative policy mutation that +would create another distinct boundary MUST fail atomically. The server MUST +retain and return this bounded history for the team's lifetime so clients with +durable older quarantine can evaluate the policy effective at any sequence. +Its final entry MUST exactly match the response's current `policy_revision`, `effective_from_seq`, `accepted_envelope_versions`, and `write_allowed_ciphers` fields. For a message at sequence `S`, the effective policy is the history entry with @@ -287,6 +310,13 @@ Policy mutation locks the team row, increments `policy_revision`, and records revision or `effective_from_seq` would exceed signed BIGINT maximum MUST fail atomically rather than wrap. +Before creating or POSTing a new envelope, a client MUST fetch this +authenticated response, verify its server/team binding, and preflight the +effective write set. A v1 client supports only `none`; if the server disallows +`none` or the local mode is `e2ee-required`, it MUST stop without generating or +POSTing a plaintext envelope. A later POST policy race may still return `403`, +which the client handles as specified below. + ## `POST /v1/messages` Stores one atomic batch. The request body is: @@ -453,9 +483,10 @@ manual or driver-specific operation outside this protocol. Clients MUST keep three independent progress layers: -1. **Transport:** durably quarantine every envelope together with wire ID and - `server_seq`, then advance the transport cursor. A crash must not lose an - envelope whose sequence was acknowledged locally. +1. **Transport:** durably quarantine every envelope together with wire ID, + `server_seq`, server policy evaluation, and local security evaluation before + advancing the transport cursor. A crash must not lose an envelope whose + sequence was acknowledged locally. 2. **Decrypt/import:** process quarantined envelopes into states such as `pending_key`, `unsupported_cipher`, `authentication_failed`, or `malformed`. Key/capability changes may reprocess this layer without rewinding transport. @@ -464,7 +495,28 @@ Clients MUST keep three independent progress layers: Readers use capability policy history (`policy_revision` and `effective_from_seq`) to distinguish allowed legacy `none` envelopes from a -post-policy downgrade attempt. +post-policy downgrade attempt. They MUST also apply the durable local +`minimum_security_mode`. An envelope that violates the effective server policy +at its `server_seq` or the local minimum enters durable `policy_violation` with +the reason recorded. It MUST NOT be imported or exposed as readable, although +the transport cursor may advance after that quarantine record is durable. + +Pull echo reconciliation is normative. After durable quarantine, the client +looks up the wire ID in reconciliation state: + +- If a mapping exists and its canonical immutable envelope matches, the client + MUST NOT create a local ID or event. It durably records `server_seq` and remote + observation/confirmation on the already-mapped local record. +- If a mapping exists but points to a different immutable envelope, the client + enters durable `corrupt_state`, MUST NOT import or read the message, and MUST + surface operator action. +- Only if no mapping exists may the client allocate one local ID, durably store + the local-ID/wire-ID/envelope mapping, and import the message once. + +The transport cursor may advance only after the applicable reconciliation or +import is durable, or after a durable blocking quarantine state such as +`pending_key`, `unsupported_cipher`, `authentication_failed`, `malformed`, +`policy_violation`, or `corrupt_state` has been recorded. Because routing fields are opaque, the server cannot provide recipient routing, search, or per-recipient delivery. Clients download the team stream and project @@ -658,10 +710,10 @@ tombstone containing `(team_id, id, envelope_digest, original team_seq)`. `envelope_digest` is SHA-256 over: ASCII `agmsg-envelope-v1` followed by a zero byte; envelope `v` as unsigned 32-bit big-endian; unsigned 32-bit big-endian length plus UTF-8 bytes of `cipher`; one byte `0` for null `key_id`, otherwise -byte `1` then its length+UTF-8 bytes; and unsigned 32-bit big-endian length plus -the base64-decoded opaque blob bytes. This exact length-prefixed preimage is the -canonical immutable payload digest. Decoded bytes used for hashing are not -persisted or logged. +byte `1` then its unsigned 32-bit big-endian byte length plus exact UTF-8 bytes; +and unsigned 32-bit big-endian length plus the base64-decoded opaque blob bytes. +This exact length-prefixed preimage is the canonical immutable payload digest. +Decoded bytes used for hashing are not persisted or logged. Retention MUST NOT permit a replayed UUID to acquire a new sequence. An identical replay returns `duplicate` with its original sequence; From d1a74dbc62965b2aeaba46db604ed6c1e4f4ff9a Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 20 Jul 2026 16:03:53 +0900 Subject: [PATCH 06/10] Version client security policy by sequence --- server/spec/v1.md | 70 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 19 deletions(-) diff --git a/server/spec/v1.md b/server/spec/v1.md index c9051a1a..9c87f9cf 100644 --- a/server/spec/v1.md +++ b/server/spec/v1.md @@ -244,15 +244,30 @@ It is deliberately separate from unauthenticated health and says nothing about which ciphers a particular client can read. Independently of server policy, every client sync configuration MUST durably -pin a local `minimum_security_mode` to its `(server_instance_id, team_id)` -binding. V1 defines `plaintext-allowed` and `e2ee-required`; the latter excludes -`none`. Initial binding requires an explicit local/operator choice, and a server -response MUST NOT silently create, lower, or replace it. An approved server -rebind retains the pin unless the operator explicitly changes it. For new -messages, the effective write set is the intersection of the server's -`write_allowed_ciphers`, locally supported ciphers, and ciphers satisfying the -local minimum. An empty intersection is a terminal configuration state, not a -reason to downgrade. +pin an append-only local security history to its `(server_instance_id, team_id)` +binding. Each entry contains canonical decimal `local_security_revision` and +`effective_from_seq` plus `minimum_security_mode`. V1 defines +`plaintext-allowed` and `e2ee-required`; the latter excludes `none`. The initial +entry has revision `0`, is effective from sequence `1`, and requires an explicit +local/operator mode choice. A server response MUST NOT silently create, lower, +or replace this history. An operator-approved replacement binding MUST +explicitly establish a new history and MUST NOT silently lower the prior +security posture. + +The history is part of durable sync configuration, not ephemeral device state. +A new device for an existing binding MUST import the established history from +trusted configuration or require explicit operator provisioning; it MUST NOT +reconstruct history from only the server's current policy or silently apply the +current local mode back to sequence `1`. + +For a message at sequence `S`, the effective local minimum is the entry with the +greatest local revision whose `effective_from_seq <= S`. Same-boundary changes +collapse to the greatest revision. Clients MUST bound effective local history +to 4096 entries just like server policy history and MUST reject a further +distinct-boundary change atomically. For new messages, the effective write set +is the intersection of the server's `write_allowed_ciphers`, locally supported +ciphers, and ciphers satisfying the effective local minimum. An empty +intersection is a terminal configuration state, not a reason to downgrade. ```json { @@ -261,6 +276,8 @@ reason to downgrade. "team_id": "018f3f7e-0000-7000-8000-000000000001", "team_name": "example-team", "min_available_seq": "0", + "current_seq": "0", + "next_sequence_boundary": "1", "accepted_envelope_versions": [1], "write_allowed_ciphers": ["none"], "policy_revision": "0", @@ -300,11 +317,24 @@ Multiple policy changes before the next message may therefore share one `max_blob_bytes` is decoded opaque-byte size and MUST NOT exceed the protocol maximum. +`current_seq` is the team's current committed sequence at this snapshot. +`next_sequence_boundary` is `current_seq + 1`, or null when `current_seq` is the +signed BIGINT maximum. Both non-null values use the canonical sequence-string +format. A normal local security-mode change MUST fetch a fresh authenticated +capability snapshot, verify its binding, and durably append the next local +revision with `effective_from_seq = next_sequence_boundary`. It MUST fail if +that boundary is null. This makes plaintext-to-E2EE transitions prospective: +valid earlier plaintext keeps its earlier local policy. A request to hide or +reclassify earlier plaintext is a separate explicit retroactive local action; +it MUST NOT rewrite the normal effective history. + Capability reads and POST policy enforcement MUST use the same authoritative team policy state. Servers only advertise write admission. Client read/decrypt capability is local state and MUST NOT be inferred from this response. -The current fields and complete `policy_history` MUST be read from one database -transaction and snapshot. +The sequence boundary, current fields, and complete `policy_history` MUST be +read from one database transaction and snapshot. Capability responses MUST send +`Cache-Control: no-store`; clients MUST NOT use a cached response to choose a +local effective boundary. Policy mutation locks the team row, increments `policy_revision`, and records `effective_from_seq = current_seq + 1` atomically. A policy change whose next revision or `effective_from_seq` would exceed signed BIGINT maximum MUST fail @@ -312,10 +342,11 @@ atomically rather than wrap. Before creating or POSTing a new envelope, a client MUST fetch this authenticated response, verify its server/team binding, and preflight the -effective write set. A v1 client supports only `none`; if the server disallows -`none` or the local mode is `e2ee-required`, it MUST stop without generating or -POSTing a plaintext envelope. A later POST policy race may still return `403`, -which the client handles as specified below. +effective write set for `next_sequence_boundary`. A v1 client supports only +`none`; if the server disallows `none` or the effective local mode is +`e2ee-required`, it MUST stop without generating or POSTing a plaintext +envelope. A later POST policy race may still return `403`, which the client +handles as specified below. ## `POST /v1/messages` @@ -496,10 +527,11 @@ Clients MUST keep three independent progress layers: Readers use capability policy history (`policy_revision` and `effective_from_seq`) to distinguish allowed legacy `none` envelopes from a post-policy downgrade attempt. They MUST also apply the durable local -`minimum_security_mode`. An envelope that violates the effective server policy -at its `server_seq` or the local minimum enters durable `policy_violation` with -the reason recorded. It MUST NOT be imported or exposed as readable, although -the transport cursor may advance after that quarantine record is durable. +security-history entry effective at the message's `server_seq`. An envelope +that violates the effective server policy or effective local minimum at its +sequence enters durable `policy_violation` with the reason recorded. It MUST +NOT be imported or exposed as readable, although the transport cursor may +advance after that quarantine record is durable. Pull echo reconciliation is normative. After durable quarantine, the client looks up the wire ID in reconciliation state: From c6aa72469ebd7721638dc6728c27ab47e400d217 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 20 Jul 2026 16:20:59 +0900 Subject: [PATCH 07/10] Add PostgreSQL remote storage reference server --- .github/workflows/tests.yml | 65 +- server/.dockerignore | 4 + server/.gitignore | 3 + server/Dockerfile | 18 + server/README.md | 64 + server/compose.yaml | 30 + server/example/team.json | 17 + server/migrations/001_initial.sql | 89 + server/package-lock.json | 2990 ++++++++++++++++++ server/package.json | 32 + server/src/app.ts | 183 ++ server/src/config.ts | 30 + server/src/db.ts | 55 + server/src/errors.ts | 27 + server/src/index.ts | 17 + server/src/migrate.ts | 10 + server/src/protocol.ts | 137 + server/src/provision.ts | 189 ++ server/src/storage.ts | 446 +++ server/src/types/json-dup-key-validator.d.ts | 7 + server/test/server.integration.test.ts | 409 +++ server/tsconfig.build.json | 5 + server/tsconfig.json | 19 + server/vitest.config.ts | 8 + 24 files changed, 4853 insertions(+), 1 deletion(-) create mode 100644 server/.dockerignore create mode 100644 server/.gitignore create mode 100644 server/Dockerfile create mode 100644 server/README.md create mode 100644 server/compose.yaml create mode 100644 server/example/team.json create mode 100644 server/migrations/001_initial.sql create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/src/app.ts create mode 100644 server/src/config.ts create mode 100644 server/src/db.ts create mode 100644 server/src/errors.ts create mode 100644 server/src/index.ts create mode 100644 server/src/migrate.ts create mode 100644 server/src/protocol.ts create mode 100644 server/src/provision.ts create mode 100644 server/src/storage.ts create mode 100644 server/src/types/json-dup-key-validator.d.ts create mode 100644 server/test/server.integration.test.ts create mode 100644 server/tsconfig.build.json create mode 100644 server/tsconfig.json create mode 100644 server/vitest.config.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 734e9d6a..8a9e089c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,6 +43,7 @@ jobs: outputs: docs_only: ${{ steps.detect.outputs.docs_only }} app_changed: ${{ steps.detect.outputs.app_changed }} + server_changed: ${{ steps.detect.outputs.server_changed }} steps: - uses: actions/checkout@v4 with: @@ -58,6 +59,7 @@ jobs: echo "Event is '$EVENT' (not pull_request) — running the full suite." echo "docs_only=false" >> "$GITHUB_OUTPUT" echo "app_changed=true" >> "$GITHUB_OUTPUT" + echo "server_changed=true" >> "$GITHUB_OUTPUT" exit 0 fi changed=$(git diff --name-only "$BASE_SHA...$HEAD_SHA") @@ -65,21 +67,24 @@ jobs: echo "$changed" docs_only=true app_changed=false + server_changed=false while IFS= read -r f; do [ -z "$f" ] && continue case "$f" in docs/*) ;; site/*) ;; app/*) app_changed=true ;; + server/*) docs_only=false; server_changed=true ;; llms.txt|llms-full.txt) ;; README.md|CHANGELOG.md|CONTRIBUTING.md|PRIVACY.md|ARCHITECTURE.md|RELEASING.md|LICENSE) ;; # NOTE: SKILL.md is intentionally not here — the suite tests it. *) docs_only=false ;; esac done <<< "$changed" - echo "Result: docs_only=$docs_only app_changed=$app_changed" + echo "Result: docs_only=$docs_only app_changed=$app_changed server_changed=$server_changed" echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT" echo "app_changed=$app_changed" >> "$GITHUB_OUTPUT" + echo "server_changed=$server_changed" >> "$GITHUB_OUTPUT" bats: name: bats (${{ matrix.os }}) @@ -284,6 +289,64 @@ jobs: if: needs.changes.outputs.app_changed != 'false' run: cargo test --manifest-path src-tauri/Cargo.toml + # PostgreSQL-backed contract tests for the independent reference server. + # Like app-check, this always reports and skips heavy work only when the diff + # provably does not touch server/. + server-check: + name: remote server + needs: changes + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 10 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_DB: agmsg_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d agmsg_test" + --health-interval 2s + --health-timeout 2s + --health-retries 20 + defaults: + run: + working-directory: server + steps: + - uses: actions/checkout@v4 + + - name: No server changes — skipping checks + if: needs.changes.outputs.server_changed == 'false' + run: echo "Diff does not touch server/ — reporting green without running server checks." + + - uses: actions/setup-node@v4 + if: needs.changes.outputs.server_changed != 'false' + with: + node-version: 22 + cache: npm + cache-dependency-path: server/package-lock.json + + - name: Install dependencies + if: needs.changes.outputs.server_changed != 'false' + run: npm ci + + - name: Typecheck + if: needs.changes.outputs.server_changed != 'false' + run: npm run typecheck + + - name: PostgreSQL integration tests + if: needs.changes.outputs.server_changed != 'false' + env: + TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/agmsg_test + run: npm test + + - name: Build + if: needs.changes.outputs.server_changed != 'false' + run: npm run build + # Windows leg for the app: the command layer's bash resolution and path # conversion are all behind cfg(windows) and had never run in CI — the very # 0.1.1→0.1.3 regressions (WSL bash.exe, backslash argv). This runs cargo test diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 00000000..1fc037f1 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,4 @@ +dist +node_modules +test +.env diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 00000000..deed335b --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.env diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 00000000..8f2682be --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,18 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY tsconfig.json ./ +COPY migrations ./migrations +COPY src ./src +RUN npm run build + +FROM node:22-alpine +ENV NODE_ENV=production +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev +COPY --from=build /app/dist ./dist +USER node +EXPOSE 8787 +CMD ["node", "dist/src/index.js"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 00000000..d5c8a040 --- /dev/null +++ b/server/README.md @@ -0,0 +1,64 @@ +# agmsg remote storage reference server + +This directory contains the thin, self-hosted PostgreSQL reference +implementation of the [HTTP API v1 contract](spec/v1.md). It is independent of +the root installer package and desktop app. + +The server currently implements the v1 plaintext envelope (`cipher: "none"`). +It stores envelope blobs opaquely and does not inspect sender, recipient, body, +or client creation time. Authentication is a deployment concern in the protocol; +this reference uses one required bearer token and still authorizes every +team-scoped operation against an immutable `Agmsg-Team-ID`. + +## Run with Compose + +Change the token and database password in `compose.yaml` before exposing the +service outside a local development machine, then run: + +```sh +docker compose up --build +``` + +`GET /v1/health` is available without credentials. The message, member, and +capability endpoints require these headers: + +```http +Authorization: Bearer +Agmsg-Protocol-Version: 1 +Agmsg-Team-ID: +``` + +## Run from source + +Node.js 22 and PostgreSQL 17 are the reference versions. + +```sh +npm install +export DATABASE_URL=postgresql://localhost/agmsg +export AGMSG_SERVER_TOKEN='replace-with-at-least-16-bytes' +npm run migrate +npm run provision -- example/team.json +npm run dev +``` + +The server also runs the idempotent migration at startup. Team creation and +roster mutation remain outside HTTP v1: the provisioning command atomically +applies the complete operator-owned roster manifest. IDs and retired names are +checked against permanent identity history before replacement. + +## Verify + +Integration tests use an isolated, randomly named PostgreSQL schema. The test +only removes the schema it created and validates its generated name first. + +```sh +export TEST_DATABASE_URL=postgresql://localhost/agmsg_test +npm run typecheck +npm test +npm run build +``` + +The integration suite covers atomic batch rollback, complete input-order ack +mapping, transactional team sequence allocation, UUID conflict handling, +retention tombstones, cursor floors, capability snapshots, roster reads, and +strict JSON framing. diff --git a/server/compose.yaml b/server/compose.yaml new file mode 100644 index 00000000..4f647f43 --- /dev/null +++ b/server/compose.yaml @@ -0,0 +1,30 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: agmsg + POSTGRES_USER: agmsg + POSTGRES_PASSWORD: agmsg-local-only + healthcheck: + test: ["CMD-SHELL", "pg_isready -U agmsg -d agmsg"] + interval: 2s + timeout: 2s + retries: 20 + volumes: + - agmsg-postgres:/var/lib/postgresql/data + + server: + build: . + depends_on: + postgres: + condition: service_healthy + environment: + DATABASE_URL: postgresql://agmsg:agmsg-local-only@postgres:5432/agmsg + AGMSG_SERVER_TOKEN: change-this-development-token + HOST: 0.0.0.0 + PORT: 8787 + ports: + - "8787:8787" + +volumes: + agmsg-postgres: diff --git a/server/example/team.json b/server/example/team.json new file mode 100644 index 00000000..c467d24f --- /dev/null +++ b/server/example/team.json @@ -0,0 +1,17 @@ +{ + "team_id": "018f3f7e-0000-7000-8000-000000000001", + "team_name": "example-team", + "members": [ + { + "member_id": "018f3f7e-0000-7000-8000-000000000010", + "name": "worker-1", + "registrations": [ + { + "registration_id": "018f3f7e-0000-7000-8000-000000000011", + "installation_id": "018f3f7e-0000-7000-8000-000000000012", + "type": "codex" + } + ] + } + ] +} diff --git a/server/migrations/001_initial.sql b/server/migrations/001_initial.sql new file mode 100644 index 00000000..e501a4a6 --- /dev/null +++ b/server/migrations/001_initial.sql @@ -0,0 +1,89 @@ +CREATE TABLE IF NOT EXISTS server_metadata ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + server_instance_id UUID NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() +); + +CREATE TABLE IF NOT EXISTS teams ( + team_id UUID PRIMARY KEY, + team_name TEXT NOT NULL, + current_seq BIGINT NOT NULL DEFAULT 0 CHECK (current_seq >= 0), + min_available_seq BIGINT NOT NULL DEFAULT 0, + policy_revision BIGINT NOT NULL DEFAULT 0 CHECK (policy_revision >= 0), + accepted_envelope_versions INTEGER[] NOT NULL DEFAULT ARRAY[1], + write_allowed_ciphers TEXT[] NOT NULL DEFAULT ARRAY['none']::TEXT[], + max_blob_bytes INTEGER NOT NULL DEFAULT 1048576 + CHECK (max_blob_bytes BETWEEN 1 AND 1048576), + members_revision BIGINT NOT NULL DEFAULT 0 CHECK (members_revision >= 0), + CHECK (min_available_seq >= 0 AND min_available_seq <= current_seq) +); + +CREATE TABLE IF NOT EXISTS team_policy_history ( + team_id UUID NOT NULL REFERENCES teams(team_id) ON DELETE RESTRICT, + policy_revision BIGINT NOT NULL CHECK (policy_revision >= 0), + effective_from_seq BIGINT NOT NULL CHECK (effective_from_seq >= 1), + accepted_envelope_versions INTEGER[] NOT NULL, + write_allowed_ciphers TEXT[] NOT NULL, + PRIMARY KEY (team_id, policy_revision) +); + +CREATE INDEX IF NOT EXISTS team_policy_history_effective_idx + ON team_policy_history(team_id, effective_from_seq, policy_revision DESC); + +CREATE TABLE IF NOT EXISTS messages ( + team_id UUID NOT NULL REFERENCES teams(team_id) ON DELETE RESTRICT, + id UUID NOT NULL, + team_seq BIGINT NOT NULL CHECK (team_seq >= 1), + server_received_at TIMESTAMPTZ(6) NOT NULL DEFAULT clock_timestamp(), + envelope_v INTEGER NOT NULL, + cipher TEXT NOT NULL, + key_id TEXT, + blob TEXT NOT NULL, + envelope_digest BYTEA NOT NULL, + PRIMARY KEY (team_id, id), + UNIQUE (team_id, team_seq) +); + +CREATE TABLE IF NOT EXISTS message_tombstones ( + team_id UUID NOT NULL REFERENCES teams(team_id) ON DELETE RESTRICT, + id UUID NOT NULL, + original_team_seq BIGINT NOT NULL CHECK (original_team_seq >= 1), + envelope_digest BYTEA NOT NULL, + PRIMARY KEY (team_id, id) +); + +CREATE TABLE IF NOT EXISTS members ( + team_id UUID NOT NULL REFERENCES teams(team_id) ON DELETE RESTRICT, + member_id UUID NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (team_id, member_id), + UNIQUE (team_id, name) +); + +CREATE TABLE IF NOT EXISTS member_identity_history ( + team_id UUID NOT NULL REFERENCES teams(team_id) ON DELETE RESTRICT, + member_id UUID NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (team_id, name) +); + +CREATE INDEX IF NOT EXISTS member_identity_history_member_idx + ON member_identity_history(team_id, member_id); + +CREATE TABLE IF NOT EXISTS registrations ( + team_id UUID NOT NULL, + registration_id UUID NOT NULL, + member_id UUID NOT NULL, + installation_id UUID NOT NULL, + type TEXT NOT NULL, + PRIMARY KEY (team_id, registration_id), + FOREIGN KEY (team_id, member_id) + REFERENCES members(team_id, member_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS registration_identity_history ( + team_id UUID NOT NULL REFERENCES teams(team_id) ON DELETE RESTRICT, + registration_id UUID NOT NULL, + member_id UUID NOT NULL, + PRIMARY KEY (team_id, registration_id) +); diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 00000000..bbb40ddc --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,2990 @@ +{ + "name": "@agmsg/reference-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@agmsg/reference-server", + "version": "0.1.0", + "dependencies": { + "fastify": "5.10.0", + "json-dup-key-validator": "1.0.3", + "pg": "8.22.0", + "uuid": "14.0.1", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "@types/pg": "8.20.0", + "tsx": "4.23.1", + "typescript": "7.0.2", + "vitest": "4.1.10" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/backslash": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/backslash/-/backslash-0.2.2.tgz", + "integrity": "sha512-PKRYPE2LLTtNUYz1dszquxKSBs6XyLyJRHgFpY5rlq7y3DscDx239k5Gm+zenoY47OU4CApan1o0k2R8ptZC1Q==" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz", + "integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-dup-key-validator": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/json-dup-key-validator/-/json-dup-key-validator-1.0.3.tgz", + "integrity": "sha512-JvJcV01JSiO7LRz7DY1Fpzn4wX2rJ3dfNTiAfnlvLNdhhnm0Pgdvhi2SGpENrZn7eSg26Ps3TPhOcuD/a4STXQ==", + "license": "MIT", + "dependencies": { + "backslash": "^0.2.0" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 00000000..56a7691b --- /dev/null +++ b/server/package.json @@ -0,0 +1,32 @@ +{ + "name": "@agmsg/reference-server", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json && cp -R migrations dist/migrations", + "dev": "tsx watch src/index.ts", + "migrate": "tsx src/migrate.ts", + "provision": "tsx src/provision.ts", + "start": "node dist/index.js", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "fastify": "5.10.0", + "json-dup-key-validator": "1.0.3", + "pg": "8.22.0", + "uuid": "14.0.1", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "@types/pg": "8.20.0", + "tsx": "4.23.1", + "typescript": "7.0.2", + "vitest": "4.1.10" + } +} diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 00000000..a46ec0fd --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,183 @@ +import { timingSafeEqual } from "node:crypto"; +import Fastify, { + type FastifyInstance, + type FastifyRequest, +} from "fastify"; +import * as duplicateKeyJson from "json-dup-key-validator"; +import type { Pool } from "pg"; +import { ZodError, z } from "zod"; +import type { Config } from "./config.js"; +import { errorBody, ProtocolError } from "./errors.js"; +import { + MAX_REQUEST_BYTES, + messagesQuerySchema, + parseSequence, + postMessagesSchema, + uuidV7Schema, +} from "./protocol.js"; +import { + getCapabilities, + getMembers, + getMessages, + health, + postMessages, +} from "./storage.js"; + +const emptyQuerySchema = z.object({}).strict(); + +function tokenMatches(expected: string, actual: string): boolean { + const left = Buffer.from(expected); + const right = Buffer.from(actual); + return left.length === right.length && timingSafeEqual(left, right); +} + +function scopedTeamId(request: FastifyRequest, token: string): string { + const version = request.headers["agmsg-protocol-version"]; + if (version !== "1") { + throw new ProtocolError( + 426, + "unsupported-protocol-version", + "Agmsg-Protocol-Version must match /v1", + { requested_version: version ?? null, supported_versions: [1] }, + ); + } + const authorization = request.headers.authorization; + if ( + typeof authorization !== "string" || + !authorization.startsWith("Bearer ") || + !tokenMatches(token, authorization.slice("Bearer ".length)) + ) { + throw new ProtocolError(401, "unauthenticated", "valid credentials are required"); + } + const parsed = uuidV7Schema.safeParse(request.headers["agmsg-team-id"]); + if (!parsed.success) { + throw new ProtocolError(400, "invalid-request", "Agmsg-Team-ID is invalid"); + } + return parsed.data; +} + +export function createApp(pool: Pool, config: Config): FastifyInstance { + const app = Fastify({ + logger: config.logLevel === "silent" ? false : { level: config.logLevel }, + bodyLimit: MAX_REQUEST_BYTES, + }); + + app.removeContentTypeParser("application/json"); + app.addContentTypeParser( + "application/json", + { parseAs: "buffer" }, + (_request, body, done) => { + try { + const bytes = typeof body === "string" ? Buffer.from(body) : body; + const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + done(null, duplicateKeyJson.parse(source, false)); + } catch (error) { + const parsingError = error instanceof Error ? error : new Error("invalid JSON"); + Object.assign(parsingError, { statusCode: 400 }); + done(parsingError, undefined); + } + }, + ); + + app.addHook("onRequest", async (request) => { + const encoding = request.headers["content-encoding"]; + if (encoding !== undefined && encoding !== "identity") { + throw new ProtocolError( + 400, + "invalid-request", + "Content-Encoding must be identity", + ); + } + }); + + app.addHook("onSend", async (_request, reply, payload) => { + reply.header("Agmsg-Protocol-Version", "1"); + return payload; + }); + + app.setErrorHandler((error, _request, reply) => { + if (error instanceof ProtocolError) { + void reply.status(error.statusCode).send(errorBody(error)); + return; + } + if ( + error instanceof ZodError || + error instanceof SyntaxError || + statusCode(error) === 400 || + statusCode(error) === 415 + ) { + const protocolError = new ProtocolError( + 400, + "invalid-request", + "request body, query, or JSON framing is invalid", + ); + void reply.status(400).send(errorBody(protocolError)); + return; + } + if (statusCode(error) === 413) { + const protocolError = new ProtocolError( + 413, + "request-too-large", + "request body exceeds 2 MiB", + ); + void reply.status(413).send(errorBody(protocolError)); + return; + } + requestLog(reply, error); + const protocolError = new ProtocolError( + 500, + "internal-error", + "an internal server error occurred", + ); + void reply.status(500).send(errorBody(protocolError)); + }); + + app.get("/v1/health", async (_request, reply) => { + try { + return await health(pool); + } catch { + return reply.status(503).send({ + status: "unavailable", + protocol: { supported_versions: [1] }, + database: "unavailable", + }); + } + }); + + app.get("/v1/capabilities", async (request, reply) => { + emptyQuerySchema.parse(request.query); + const teamId = scopedTeamId(request, config.token); + reply.header("Cache-Control", "no-store"); + return getCapabilities(pool, teamId); + }); + + app.get("/v1/members", async (request) => { + emptyQuerySchema.parse(request.query); + return getMembers(pool, scopedTeamId(request, config.token)); + }); + + app.get("/v1/messages", async (request) => { + const teamId = scopedTeamId(request, config.token); + const query = messagesQuerySchema.parse(request.query); + return getMessages(pool, teamId, parseSequence(query.after), query.limit); + }); + + app.post("/v1/messages", async (request) => { + const teamId = scopedTeamId(request, config.token); + const body = postMessagesSchema.parse(request.body); + return postMessages(pool, teamId, body.messages); + }); + + return app; +} + +function requestLog(reply: { log: { error: (value: unknown) => void } }, error: unknown) { + reply.log.error(error); +} + +function statusCode(error: unknown): number | undefined { + if (typeof error !== "object" || error === null || !("statusCode" in error)) { + return undefined; + } + return typeof error.statusCode === "number" ? error.statusCode : undefined; +} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 00000000..2760abf5 --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +const environmentSchema = z.object({ + DATABASE_URL: z.string().min(1), + AGMSG_SERVER_TOKEN: z.string().min(16), + HOST: z.string().default("127.0.0.1"), + PORT: z.coerce.number().int().min(1).max(65535).default(8787), + LOG_LEVEL: z + .enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]) + .default("info"), +}); + +export type Config = { + databaseUrl: string; + token: string; + host: string; + port: number; + logLevel: z.infer["LOG_LEVEL"]; +}; + +export function loadConfig(environment: NodeJS.ProcessEnv = process.env): Config { + const parsed = environmentSchema.parse(environment); + return { + databaseUrl: parsed.DATABASE_URL, + token: parsed.AGMSG_SERVER_TOKEN, + host: parsed.HOST, + port: parsed.PORT, + logLevel: parsed.LOG_LEVEL, + }; +} diff --git a/server/src/db.ts b/server/src/db.ts new file mode 100644 index 00000000..59216515 --- /dev/null +++ b/server/src/db.ts @@ -0,0 +1,55 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { Pool, type PoolClient } from "pg"; +import { v7 as uuidv7 } from "uuid"; + +export function createPool(connectionString: string): Pool { + return new Pool({ connectionString, max: 10 }); +} + +export async function migrate(pool: Pool): Promise { + const migrationPath = fileURLToPath( + new URL("../migrations/001_initial.sql", import.meta.url), + ); + const sql = await readFile(migrationPath, "utf8"); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query(sql); + await client.query( + `INSERT INTO server_metadata (singleton, server_instance_id) + VALUES (TRUE, $1) + ON CONFLICT (singleton) DO NOTHING`, + [uuidv7()], + ); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function inTransaction( + pool: Pool, + operation: (client: PoolClient) => Promise, + options: { readOnly?: boolean; repeatableRead?: boolean } = {}, +): Promise { + const client = await pool.connect(); + try { + const clauses = [ + options.repeatableRead ? "ISOLATION LEVEL REPEATABLE READ" : "", + options.readOnly ? "READ ONLY" : "", + ].filter(Boolean); + await client.query(`BEGIN${clauses.length > 0 ? ` ${clauses.join(" ")}` : ""}`); + const value = await operation(client); + await client.query("COMMIT"); + return value; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} diff --git a/server/src/errors.ts b/server/src/errors.ts new file mode 100644 index 00000000..f3f9f7e3 --- /dev/null +++ b/server/src/errors.ts @@ -0,0 +1,27 @@ +export class ProtocolError extends Error { + constructor( + readonly statusCode: number, + readonly code: string, + message: string, + readonly details: Record = {}, + readonly binding: { serverInstanceId?: string; teamId?: string } = {}, + ) { + super(message); + this.name = "ProtocolError"; + } +} + +export function errorBody(error: ProtocolError): Record { + return { + protocol_version: 1, + ...(error.binding.serverInstanceId + ? { server_instance_id: error.binding.serverInstanceId } + : {}), + ...(error.binding.teamId ? { team_id: error.binding.teamId } : {}), + error: { + code: error.code, + message: error.message, + details: error.details, + }, + }; +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 00000000..f28e27f0 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,17 @@ +import { createApp } from "./app.js"; +import { loadConfig } from "./config.js"; +import { createPool, migrate } from "./db.js"; + +const config = loadConfig(); +const pool = createPool(config.databaseUrl); +await migrate(pool); + +const app = createApp(pool, config); +const shutdown = async () => { + await app.close(); + await pool.end(); +}; +process.once("SIGINT", () => void shutdown()); +process.once("SIGTERM", () => void shutdown()); + +await app.listen({ host: config.host, port: config.port }); diff --git a/server/src/migrate.ts b/server/src/migrate.ts new file mode 100644 index 00000000..7c8fab6c --- /dev/null +++ b/server/src/migrate.ts @@ -0,0 +1,10 @@ +import { loadConfig } from "./config.js"; +import { createPool, migrate } from "./db.js"; + +const config = loadConfig(); +const pool = createPool(config.databaseUrl); +try { + await migrate(pool); +} finally { + await pool.end(); +} diff --git a/server/src/protocol.ts b/server/src/protocol.ts new file mode 100644 index 00000000..4ea15ee2 --- /dev/null +++ b/server/src/protocol.ts @@ -0,0 +1,137 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; + +export const MAX_SEQUENCE = 9_223_372_036_854_775_807n; +export const MAX_REQUEST_BYTES = 2 * 1024 * 1024; +export const MAX_BLOB_BYTES = 1024 * 1024; + +const uuidV4Pattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const uuidV7Pattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const sequencePattern = /^(0|[1-9][0-9]*)$/; +const cipherPattern = /^[a-z0-9][a-z0-9._-]{0,63}$/; +const timestampPattern = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/; + +export const uuidV4Schema = z.string().regex(uuidV4Pattern); +export const uuidV7Schema = z.string().regex(uuidV7Pattern); +export const timestampSchema = z.string().regex(timestampPattern); + +export const sequenceSchema = z.string().regex(sequencePattern).refine((value) => { + try { + return BigInt(value) <= MAX_SEQUENCE; + } catch { + return false; + } +}); + +export function parseSequence(value: string): bigint { + return BigInt(sequenceSchema.parse(value)); +} + +function canonicalBlob(value: string): boolean { + if (value.length < 1 || value.length > 1_398_104) return false; + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + return false; + } + const bytes = Buffer.from(value, "base64"); + return bytes.length <= MAX_BLOB_BYTES && bytes.toString("base64") === value; +} + +const keyIdSchema = z.union([ + z.null(), + z.string().refine((value) => { + const bytes = Buffer.byteLength(value, "utf8"); + return ( + value === value.normalize("NFC") && + bytes >= 1 && + bytes <= 256 && + !/[\u0000-\u001f\u007f]/u.test(value) + ); + }), +]); + +export const envelopeSchema = z + .object({ + v: z.number().int().nonnegative(), + cipher: z.string().regex(cipherPattern), + key_id: keyIdSchema, + blob: z.string().refine(canonicalBlob), + }) + .strict() + .superRefine((value, context) => { + if (value.cipher === "none" && value.key_id !== null) { + context.addIssue({ + code: "custom", + path: ["key_id"], + message: "key_id must be null for cipher none", + }); + } + }); + +export const messageInputSchema = z + .object({ id: uuidV4Schema, envelope: envelopeSchema }) + .strict(); + +export const postMessagesSchema = z + .object({ messages: z.array(messageInputSchema).min(1).max(1000) }) + .strict(); + +export type Envelope = z.infer; +export type MessageInput = z.infer; + +export const messagesQuerySchema = z.object({ + after: sequenceSchema, + limit: z.preprocess( + (value) => value ?? "100", + z + .string() + .regex(/^[1-9][0-9]*$/) + .transform(Number) + .refine((value) => value >= 1 && value <= 1000), + ), +}).strict(); + +export const agentNameSchema = z.string().refine((value) => { + const scalars = [...value]; + return ( + value === value.normalize("NFC") && + scalars.length >= 1 && + scalars.length <= 128 && + !value.startsWith("-") && + value !== "." && + value !== ".." && + !/[./\\"\[\]\u0000-\u001f\u007f]/u.test(value) + ); +}); + +function u32(value: number): Buffer { + const buffer = Buffer.allocUnsafe(4); + buffer.writeUInt32BE(value); + return buffer; +} + +function sized(value: Buffer): Buffer { + return Buffer.concat([u32(value.length), value]); +} + +export function envelopeDigest(envelope: Envelope): Buffer { + const cipher = Buffer.from(envelope.cipher, "utf8"); + const blob = Buffer.from(envelope.blob, "base64"); + const key = + envelope.key_id === null + ? Buffer.from([0]) + : Buffer.concat([Buffer.from([1]), sized(Buffer.from(envelope.key_id, "utf8"))]); + return createHash("sha256") + .update( + Buffer.concat([ + Buffer.from("agmsg-envelope-v1\0", "ascii"), + u32(envelope.v), + sized(cipher), + key, + sized(blob), + ]), + ) + .digest(); +} diff --git a/server/src/provision.ts b/server/src/provision.ts new file mode 100644 index 00000000..e7941b04 --- /dev/null +++ b/server/src/provision.ts @@ -0,0 +1,189 @@ +import { readFile } from "node:fs/promises"; +import * as duplicateKeyJson from "json-dup-key-validator"; +import { z } from "zod"; +import { loadConfig } from "./config.js"; +import { createPool, inTransaction, migrate } from "./db.js"; +import { agentNameSchema, MAX_SEQUENCE, uuidV7Schema } from "./protocol.js"; + +const registrationSchema = z + .object({ + registration_id: uuidV7Schema, + installation_id: uuidV7Schema, + type: z.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9._-]*$/), + }) + .strict(); + +const memberSchema = z + .object({ + member_id: uuidV7Schema, + name: agentNameSchema, + registrations: z.array(registrationSchema), + }) + .strict(); + +const manifestSchema = z + .object({ + team_id: uuidV7Schema, + team_name: z + .string() + .refine( + (value) => + value === value.normalize("NFC") && + [...value].length >= 1 && + [...value].length <= 128, + ), + members: z.array(memberSchema), + }) + .strict(); + +const manifestPath = process.argv[2]; +if (!manifestPath) { + throw new Error("Usage: npm run provision -- "); +} + +const manifestSource = await readFile(manifestPath, "utf8"); +const manifest = manifestSchema.parse(duplicateKeyJson.parse(manifestSource, false)); +const memberIds = new Set(); +const memberNames = new Set(); +const registrationIds = new Set(); +for (const member of manifest.members) { + if (memberIds.has(member.member_id) || memberNames.has(member.name)) { + throw new Error("manifest contains duplicate member ID or name"); + } + memberIds.add(member.member_id); + memberNames.add(member.name); + for (const registration of member.registrations) { + if (registrationIds.has(registration.registration_id)) { + throw new Error("manifest contains a duplicate registration ID"); + } + registrationIds.add(registration.registration_id); + } +} + +const config = loadConfig(); +const pool = createPool(config.databaseUrl); +try { + await migrate(pool); + const revision = await inTransaction(pool, async (client) => { + const existing = await client.query<{ members_revision: string }>( + `SELECT members_revision::text FROM teams + WHERE team_id = $1 FOR UPDATE`, + [manifest.team_id], + ); + const currentMembers = new Set( + ( + await client.query<{ member_id: string }>( + "SELECT member_id::text FROM members WHERE team_id = $1", + [manifest.team_id], + ) + ).rows.map((row) => row.member_id), + ); + const currentRegistrations = new Set( + ( + await client.query<{ registration_id: string }>( + "SELECT registration_id::text FROM registrations WHERE team_id = $1", + [manifest.team_id], + ) + ).rows.map((row) => row.registration_id), + ); + + let nextRevision = 0n; + if (existing.rows[0]) { + const current = BigInt(existing.rows[0].members_revision); + if (current === MAX_SEQUENCE) throw new Error("members revision is exhausted"); + nextRevision = current + 1n; + await client.query( + "UPDATE teams SET team_name = $2, members_revision = $3 WHERE team_id = $1", + [manifest.team_id, manifest.team_name, nextRevision.toString()], + ); + } else { + await client.query( + `INSERT INTO teams (team_id, team_name, members_revision) + VALUES ($1, $2, 0)`, + [manifest.team_id, manifest.team_name], + ); + await client.query( + `INSERT INTO team_policy_history + (team_id, policy_revision, effective_from_seq, + accepted_envelope_versions, write_allowed_ciphers) + VALUES ($1, 0, 1, ARRAY[1], ARRAY['none']::TEXT[])`, + [manifest.team_id], + ); + } + + for (const member of manifest.members) { + const memberSeen = await client.query<{ present: boolean }>( + `SELECT TRUE AS present FROM member_identity_history + WHERE team_id = $1 AND member_id = $2 LIMIT 1`, + [manifest.team_id, member.member_id], + ); + if (memberSeen.rows[0] && !currentMembers.has(member.member_id)) { + throw new Error(`member ${member.member_id} is retired`); + } + const nameOwner = await client.query<{ member_id: string }>( + `SELECT member_id::text FROM member_identity_history + WHERE team_id = $1 AND name = $2`, + [manifest.team_id, member.name], + ); + if (nameOwner.rows[0] && nameOwner.rows[0].member_id !== member.member_id) { + throw new Error(`member name ${member.name} is retired by another member`); + } + await client.query( + `INSERT INTO member_identity_history (team_id, member_id, name) + VALUES ($1, $2, $3) ON CONFLICT (team_id, name) DO NOTHING`, + [manifest.team_id, member.member_id, member.name], + ); + for (const registration of member.registrations) { + const owner = await client.query<{ member_id: string }>( + `SELECT member_id::text FROM registration_identity_history + WHERE team_id = $1 AND registration_id = $2`, + [manifest.team_id, registration.registration_id], + ); + if (owner.rows[0] && !currentRegistrations.has(registration.registration_id)) { + throw new Error(`registration ${registration.registration_id} is retired`); + } + if (owner.rows[0] && owner.rows[0].member_id !== member.member_id) { + throw new Error( + `registration ${registration.registration_id} is retired by another member`, + ); + } + await client.query( + `INSERT INTO registration_identity_history + (team_id, registration_id, member_id) + VALUES ($1, $2, $3) + ON CONFLICT (team_id, registration_id) DO NOTHING`, + [manifest.team_id, registration.registration_id, member.member_id], + ); + } + } + + await client.query("DELETE FROM registrations WHERE team_id = $1", [manifest.team_id]); + await client.query("DELETE FROM members WHERE team_id = $1", [manifest.team_id]); + for (const member of manifest.members) { + await client.query( + "INSERT INTO members (team_id, member_id, name) VALUES ($1, $2, $3)", + [manifest.team_id, member.member_id, member.name], + ); + for (const registration of member.registrations) { + await client.query( + `INSERT INTO registrations + (team_id, registration_id, member_id, installation_id, type) + VALUES ($1, $2, $3, $4, $5)`, + [ + manifest.team_id, + registration.registration_id, + member.member_id, + registration.installation_id, + registration.type, + ], + ); + } + } + return nextRevision.toString(); + }); + process.stdout.write( + `${JSON.stringify({ team_id: manifest.team_id, members_revision: revision })}\n`, + ); +} finally { + await pool.end(); +} diff --git a/server/src/storage.ts b/server/src/storage.ts new file mode 100644 index 00000000..d049de36 --- /dev/null +++ b/server/src/storage.ts @@ -0,0 +1,446 @@ +import type { Pool, PoolClient } from "pg"; +import { ProtocolError } from "./errors.js"; +import { + MAX_SEQUENCE, + envelopeDigest, + type Envelope, + type MessageInput, +} from "./protocol.js"; +import { inTransaction } from "./db.js"; + +type TeamRow = { + team_id: string; + team_name: string; + current_seq: string; + min_available_seq: string; + policy_revision: string; + accepted_envelope_versions: number[]; + write_allowed_ciphers: string[]; + max_blob_bytes: number; + members_revision: string; +}; + +type LiveMessageRow = { + id: string; + team_seq: string; + server_received_at: string; + envelope_v: number; + cipher: string; + key_id: string | null; + blob: string; + envelope_digest: Buffer; +}; + +type ExistingRecord = + | { kind: "live"; row: LiveMessageRow } + | { kind: "tombstone"; sequence: string; digest: Buffer }; + +const timestampSql = `to_char(server_received_at AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`; + +async function serverInstanceId(client: PoolClient): Promise { + const result = await client.query<{ server_instance_id: string }>( + "SELECT server_instance_id::text FROM server_metadata WHERE singleton = TRUE", + ); + const id = result.rows[0]?.server_instance_id; + if (!id) throw new Error("server metadata is not initialized"); + return id; +} + +async function teamRow( + client: PoolClient, + id: string, + lock = false, +): Promise { + const result = await client.query( + `SELECT team_id::text, team_name, current_seq::text, min_available_seq::text, + policy_revision::text, accepted_envelope_versions, + write_allowed_ciphers, max_blob_bytes, members_revision::text + FROM teams WHERE team_id = $1${lock ? " FOR UPDATE" : ""}`, + [id], + ); + return result.rows[0]; +} + +function common(serverId: string, team: TeamRow): Record { + return { + protocol_version: 1, + server_instance_id: serverId, + team_id: team.team_id, + team_name: team.team_name, + min_available_seq: team.min_available_seq, + }; +} + +function notFound(serverId: string, teamId: string): ProtocolError { + return new ProtocolError(404, "team-not-found", "team is not provisioned", {}, { + serverInstanceId: serverId, + teamId, + }); +} + +function envelopeMatches(row: LiveMessageRow, envelope: Envelope): boolean { + return ( + row.envelope_v === envelope.v && + row.cipher === envelope.cipher && + row.key_id === envelope.key_id && + row.blob === envelope.blob + ); +} + +function inputFingerprint(message: MessageInput): string { + return JSON.stringify([ + message.envelope.v, + message.envelope.cipher, + message.envelope.key_id, + message.envelope.blob, + ]); +} + +export async function postMessages( + pool: Pool, + teamId: string, + messages: MessageInput[], +): Promise> { + return inTransaction(pool, async (client) => { + const serverId = await serverInstanceId(client); + const team = await teamRow(client, teamId, true); + if (!team) throw notFound(serverId, teamId); + const binding = { serverInstanceId: serverId, teamId }; + + const firstById = new Map(); + for (const message of messages) { + const first = firstById.get(message.id); + if (first && inputFingerprint(first) !== inputFingerprint(message)) { + throw new ProtocolError( + 409, + "message-uuid-conflict", + "message id is repeated with a different payload", + { id: message.id }, + binding, + ); + } + firstById.set(message.id, first ?? message); + } + + const ids = [...firstById.keys()]; + const liveResult = await client.query( + `SELECT id::text, team_seq::text, ${timestampSql} AS server_received_at, + envelope_v, cipher, key_id, blob, envelope_digest + FROM messages WHERE team_id = $1 AND id = ANY($2::uuid[])`, + [teamId, ids], + ); + const tombstoneResult = await client.query<{ + id: string; + original_team_seq: string; + envelope_digest: Buffer; + }>( + `SELECT id::text, original_team_seq::text, envelope_digest + FROM message_tombstones WHERE team_id = $1 AND id = ANY($2::uuid[])`, + [teamId, ids], + ); + const existing = new Map(); + for (const row of liveResult.rows) existing.set(row.id, { kind: "live", row }); + for (const row of tombstoneResult.rows) { + existing.set(row.id, { + kind: "tombstone", + sequence: row.original_team_seq, + digest: row.envelope_digest, + }); + } + + for (const [id, message] of firstById) { + const record = existing.get(id); + if (!record) continue; + const matches = + record.kind === "live" + ? envelopeMatches(record.row, message.envelope) + : record.digest.equals(envelopeDigest(message.envelope)); + if (!matches) { + throw new ProtocolError( + 409, + "message-uuid-conflict", + "message id already exists with a different payload", + { id }, + binding, + ); + } + } + + const fresh = [...firstById.values()].filter((message) => !existing.has(message.id)); + for (const message of fresh) { + const { envelope, id } = message; + if (envelope.v !== 1 || envelope.cipher !== "none") { + throw new ProtocolError( + 422, + "unsupported-cipher", + "envelope version or cipher is not supported", + { + id, + v: envelope.v, + cipher: envelope.cipher, + accepted_envelope_versions: team.accepted_envelope_versions, + write_allowed_ciphers: team.write_allowed_ciphers, + policy_revision: team.policy_revision, + }, + binding, + ); + } + if ( + !team.accepted_envelope_versions.includes(envelope.v) || + !team.write_allowed_ciphers.includes(envelope.cipher) + ) { + throw new ProtocolError( + 403, + "cipher-policy-violation", + "cipher is not currently write-allowed", + { + id, + v: envelope.v, + cipher: envelope.cipher, + write_allowed_ciphers: team.write_allowed_ciphers, + policy_revision: team.policy_revision, + }, + binding, + ); + } + if (Buffer.from(envelope.blob, "base64").length > team.max_blob_bytes) { + throw new ProtocolError( + 413, + "request-too-large", + "message blob exceeds the team capability limit", + { id, max_blob_bytes: String(team.max_blob_bytes) }, + binding, + ); + } + } + + let next = BigInt(team.current_seq); + if (BigInt(fresh.length) > MAX_SEQUENCE - next) { + throw new ProtocolError( + 507, + "sequence-exhausted", + "team sequence is exhausted", + {}, + binding, + ); + } + + const inserted = new Map(); + for (const message of fresh) { + next += 1n; + const digest = envelopeDigest(message.envelope); + const result = await client.query( + `INSERT INTO messages + (team_id, id, team_seq, envelope_v, cipher, key_id, blob, envelope_digest) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id::text, team_seq::text, ${timestampSql} AS server_received_at, + envelope_v, cipher, key_id, blob, envelope_digest`, + [ + teamId, + message.id, + next.toString(), + message.envelope.v, + message.envelope.cipher, + message.envelope.key_id, + message.envelope.blob, + digest, + ], + ); + const row = result.rows[0]; + if (!row) throw new Error("insert did not return a message"); + inserted.set(message.id, row); + existing.set(message.id, { kind: "live", row }); + } + if (fresh.length > 0) { + await client.query("UPDATE teams SET current_seq = $2 WHERE team_id = $1", [ + teamId, + next.toString(), + ]); + } + + const seen = new Set(); + const acks = messages.map((message) => { + const record = existing.get(message.id); + if (!record) throw new Error("missing canonical ack record"); + const stored = inserted.has(message.id) && !seen.has(message.id); + seen.add(message.id); + return { + id: message.id, + server_seq: record.kind === "live" ? record.row.team_seq : record.sequence, + disposition: stored ? "stored" : "duplicate", + }; + }); + + return { + ...common(serverId, { ...team, current_seq: next.toString() }), + policy_revision: team.policy_revision, + acks, + }; + }); +} + +export async function getMessages( + pool: Pool, + teamId: string, + after: bigint, + limit: number, +): Promise> { + return inTransaction( + pool, + async (client) => { + const serverId = await serverInstanceId(client); + const team = await teamRow(client, teamId); + if (!team) throw notFound(serverId, teamId); + if (after < BigInt(team.min_available_seq)) { + throw new ProtocolError( + 410, + "resync-required", + "cursor predates retained history", + { after: after.toString(), min_available_seq: team.min_available_seq }, + { serverInstanceId: serverId, teamId }, + ); + } + const result = await client.query( + `SELECT id::text, team_seq::text, ${timestampSql} AS server_received_at, + envelope_v, cipher, key_id, blob, envelope_digest + FROM messages + WHERE team_id = $1 AND team_seq > $2 + ORDER BY team_seq ASC + LIMIT $3`, + [teamId, after.toString(), limit + 1], + ); + const hasMore = result.rows.length > limit; + const page = result.rows.slice(0, limit); + return { + ...common(serverId, team), + messages: page.map((row) => ({ + server_seq: row.team_seq, + id: row.id, + server_received_at: row.server_received_at, + envelope: { + v: row.envelope_v, + cipher: row.cipher, + key_id: row.key_id, + blob: row.blob, + }, + })), + next_after: page.at(-1)?.team_seq ?? after.toString(), + has_more: hasMore, + }; + }, + { readOnly: true, repeatableRead: true }, + ); +} + +export async function getCapabilities( + pool: Pool, + teamId: string, +): Promise> { + return inTransaction( + pool, + async (client) => { + const serverId = await serverInstanceId(client); + const team = await teamRow(client, teamId); + if (!team) throw notFound(serverId, teamId); + const historyResult = await client.query<{ + policy_revision: string; + effective_from_seq: string; + accepted_envelope_versions: number[]; + write_allowed_ciphers: string[]; + }>( + `SELECT policy_revision::text, effective_from_seq::text, + accepted_envelope_versions, write_allowed_ciphers + FROM ( + SELECT DISTINCT ON (effective_from_seq) + policy_revision, effective_from_seq, + accepted_envelope_versions, write_allowed_ciphers + FROM team_policy_history + WHERE team_id = $1 + ORDER BY effective_from_seq, policy_revision DESC + ) effective + ORDER BY effective_from_seq, policy_revision`, + [teamId], + ); + if (historyResult.rows.length > 4096) { + throw new Error("team policy history exceeds protocol limit"); + } + const current = BigInt(team.current_seq); + return { + ...common(serverId, team), + current_seq: team.current_seq, + next_sequence_boundary: + current === MAX_SEQUENCE ? null : (current + 1n).toString(), + accepted_envelope_versions: team.accepted_envelope_versions, + write_allowed_ciphers: team.write_allowed_ciphers, + policy_revision: team.policy_revision, + effective_from_seq: + historyResult.rows.at(-1)?.effective_from_seq ?? "1", + max_blob_bytes: String(team.max_blob_bytes), + policy_history: historyResult.rows, + }; + }, + { readOnly: true, repeatableRead: true }, + ); +} + +export async function getMembers( + pool: Pool, + teamId: string, +): Promise> { + return inTransaction( + pool, + async (client) => { + const serverId = await serverInstanceId(client); + const team = await teamRow(client, teamId); + if (!team) throw notFound(serverId, teamId); + const members = await client.query<{ member_id: string; name: string }>( + `SELECT member_id::text, name FROM members + WHERE team_id = $1 ORDER BY member_id`, + [teamId], + ); + const registrations = await client.query<{ + registration_id: string; + member_id: string; + installation_id: string; + type: string; + }>( + `SELECT registration_id::text, member_id::text, installation_id::text, type + FROM registrations WHERE team_id = $1 + ORDER BY registration_id`, + [teamId], + ); + return { + ...common(serverId, team), + members_revision: team.members_revision, + members: members.rows.map((member) => ({ + member_id: member.member_id, + name: member.name, + registrations: registrations.rows + .filter((registration) => registration.member_id === member.member_id) + .map(({ member_id: _memberId, ...registration }) => registration), + })), + }; + }, + { readOnly: true, repeatableRead: true }, + ); +} + +export async function health(pool: Pool): Promise<{ + status: "ok"; + server_instance_id: string; + protocol: { supported_versions: number[] }; + database: "ok"; +}> { + const client = await pool.connect(); + try { + return { + status: "ok", + server_instance_id: await serverInstanceId(client), + protocol: { supported_versions: [1] }, + database: "ok", + }; + } finally { + client.release(); + } +} diff --git a/server/src/types/json-dup-key-validator.d.ts b/server/src/types/json-dup-key-validator.d.ts new file mode 100644 index 00000000..b4f068f2 --- /dev/null +++ b/server/src/types/json-dup-key-validator.d.ts @@ -0,0 +1,7 @@ +declare module "json-dup-key-validator" { + export function parse(source: string, allowDuplicatedKeys?: boolean): unknown; + export function validate( + source: string, + allowDuplicatedKeys?: boolean, + ): Error | undefined; +} diff --git a/server/test/server.integration.test.ts b/server/test/server.integration.test.ts new file mode 100644 index 00000000..d629bd3c --- /dev/null +++ b/server/test/server.integration.test.ts @@ -0,0 +1,409 @@ +import { randomBytes } from "node:crypto"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { Pool } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createApp } from "../src/app.js"; +import type { Config } from "../src/config.js"; +import { migrate } from "../src/db.js"; + +const databaseUrl = process.env.TEST_DATABASE_URL; +const describeDatabase = databaseUrl ? describe : describe.skip; +const execFileAsync = promisify(execFile); + +describeDatabase("remote storage HTTP API v1", () => { + const schema = `agmsg_test_${randomBytes(8).toString("hex")}`; + const token = "integration-test-token-32-bytes"; + const teamId = "018f3f7e-0000-7000-8000-000000000001"; + const memberId = "018f3f7e-0000-7000-8000-000000000010"; + const registrationId = "018f3f7e-0000-7000-8000-000000000011"; + const installationId = "018f3f7e-0000-7000-8000-000000000012"; + let admin: Pool; + let pool: Pool; + let app: ReturnType; + + const config: Config = { + databaseUrl: databaseUrl ?? "", + token, + host: "127.0.0.1", + port: 8787, + logLevel: "silent", + }; + + const headers = { + authorization: `Bearer ${token}`, + "agmsg-protocol-version": "1", + "agmsg-team-id": teamId, + }; + + beforeAll(async () => { + admin = new Pool({ connectionString: databaseUrl }); + await admin.query(`CREATE SCHEMA ${schema}`); + pool = new Pool({ + connectionString: databaseUrl, + options: `-c search_path=${schema}`, + }); + await migrate(pool); + await pool.query( + `INSERT INTO teams (team_id, team_name) VALUES ($1, 'example-team')`, + [teamId], + ); + await pool.query( + `INSERT INTO team_policy_history + (team_id, policy_revision, effective_from_seq, + accepted_envelope_versions, write_allowed_ciphers) + VALUES ($1, 0, 1, ARRAY[1], ARRAY['none']::TEXT[])`, + [teamId], + ); + await pool.query( + `INSERT INTO members (team_id, member_id, name) + VALUES ($1, $2, 'worker-1')`, + [teamId, memberId], + ); + await pool.query( + `INSERT INTO registrations + (team_id, registration_id, member_id, installation_id, type) + VALUES ($1, $2, $3, $4, 'codex')`, + [teamId, registrationId, memberId, installationId], + ); + app = createApp(pool, config); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + await pool.end(); + if (!/^agmsg_test_[0-9a-f]{16}$/.test(schema)) { + throw new Error("refusing to remove an unexpected test schema"); + } + await admin.query(`DROP SCHEMA ${schema} CASCADE`); + await admin.end(); + }); + + function message(id: string, text: string) { + return { + id, + envelope: { + v: 1, + cipher: "none", + key_id: null, + blob: Buffer.from(text).toString("base64"), + }, + }; + } + + it("reports readiness and fixes the response protocol version", async () => { + const response = await app.inject({ method: "GET", url: "/v1/health" }); + expect(response.statusCode).toBe(200); + expect(response.headers["agmsg-protocol-version"]).toBe("1"); + expect(response.json()).toMatchObject({ + status: "ok", + protocol: { supported_versions: [1] }, + database: "ok", + }); + }); + + it("requires matching protocol, credentials, and immutable team ID", async () => { + const noVersion = await app.inject({ + method: "GET", + url: "/v1/members", + headers: { authorization: `Bearer ${token}`, "agmsg-team-id": teamId }, + }); + expect(noVersion.statusCode).toBe(426); + expect(noVersion.json().error.code).toBe("unsupported-protocol-version"); + + const noAuth = await app.inject({ + method: "GET", + url: "/v1/members", + headers: { + "agmsg-protocol-version": "1", + "agmsg-team-id": teamId, + }, + }); + expect(noAuth.statusCode).toBe(401); + }); + + it("stores a batch atomically and returns complete input-order acknowledgements", async () => { + const first = message("550e8400-e29b-41d4-a716-446655440000", "first"); + const response = await app.inject({ + method: "POST", + url: "/v1/messages", + headers, + payload: { messages: [first, first] }, + }); + expect(response.statusCode).toBe(200); + expect(response.json().acks).toEqual([ + { id: first.id, server_seq: "1", disposition: "stored" }, + { id: first.id, server_seq: "1", disposition: "duplicate" }, + ]); + + const replay = await app.inject({ + method: "POST", + url: "/v1/messages", + headers, + payload: { messages: [first] }, + }); + expect(replay.json().acks[0]).toEqual({ + id: first.id, + server_seq: "1", + disposition: "duplicate", + }); + + const conflict = await app.inject({ + method: "POST", + url: "/v1/messages", + headers, + payload: { + messages: [ + message("750e8400-e29b-41d4-a716-446655440001", "would-roll-back"), + message(first.id, "different"), + ], + }, + }); + expect(conflict.statusCode).toBe(409); + expect(conflict.json()).toMatchObject({ + team_id: teamId, + error: { code: "message-uuid-conflict" }, + }); + + const count = await pool.query<{ count: string }>( + "SELECT count(*)::text AS count FROM messages WHERE team_id = $1", + [teamId], + ); + expect(count.rows[0]?.count).toBe("1"); + }); + + it("allocates team sequence without a rollback gap and pages one snapshot", async () => { + const second = message("750e8400-e29b-41d4-a716-446655440002", "second"); + const stored = await app.inject({ + method: "POST", + url: "/v1/messages", + headers, + payload: { messages: [second] }, + }); + expect(stored.json().acks[0].server_seq).toBe("2"); + + const page = await app.inject({ + method: "GET", + url: "/v1/messages?after=0&limit=1", + headers, + }); + expect(page.statusCode).toBe(200); + expect(page.json()).toMatchObject({ + next_after: "1", + has_more: true, + messages: [{ server_seq: "1" }], + }); + expect(page.json().messages[0].server_received_at).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/, + ); + }); + + it("advertises one-snapshot capabilities and operator-provisioned members", async () => { + const capabilities = await app.inject({ + method: "GET", + url: "/v1/capabilities", + headers, + }); + expect(capabilities.statusCode).toBe(200); + expect(capabilities.headers["cache-control"]).toBe("no-store"); + expect(capabilities.json()).toMatchObject({ + current_seq: "2", + next_sequence_boundary: "3", + accepted_envelope_versions: [1], + write_allowed_ciphers: ["none"], + policy_revision: "0", + effective_from_seq: "1", + policy_history: [{ policy_revision: "0", effective_from_seq: "1" }], + }); + + const members = await app.inject({ + method: "GET", + url: "/v1/members", + headers, + }); + expect(members.json()).toMatchObject({ + members_revision: "0", + members: [ + { + member_id: memberId, + name: "worker-1", + registrations: [{ registration_id: registrationId, type: "codex" }], + }, + ], + }); + }); + + it("serializes concurrent writers on the team row", async () => { + const writes = await Promise.all( + [ + message("750e8400-e29b-41d4-a716-446655440005", "concurrent-a"), + message("750e8400-e29b-41d4-a716-446655440006", "concurrent-b"), + ].map((entry) => + app.inject({ + method: "POST", + url: "/v1/messages", + headers, + payload: { messages: [entry] }, + }), + ), + ); + expect(writes.map((response) => response.statusCode)).toEqual([200, 200]); + expect( + writes + .map((response) => response.json().acks[0].server_seq) + .sort((left, right) => Number(left) - Number(right)), + ).toEqual(["3", "4"]); + }); + + it("keeps retained IDs idempotent while enforcing the pull floor", async () => { + const moved = await pool.query<{ + id: string; + team_seq: string; + envelope_digest: Buffer; + }>( + `DELETE FROM messages WHERE team_id = $1 AND team_seq = 1 + RETURNING id::text, team_seq::text, envelope_digest`, + [teamId], + ); + const row = moved.rows[0]; + expect(row).toBeDefined(); + await pool.query( + `INSERT INTO message_tombstones + (team_id, id, original_team_seq, envelope_digest) + VALUES ($1, $2, $3, $4)`, + [teamId, row?.id, row?.team_seq, row?.envelope_digest], + ); + await pool.query("UPDATE teams SET min_available_seq = 1 WHERE team_id = $1", [ + teamId, + ]); + + const belowFloor = await app.inject({ + method: "GET", + url: "/v1/messages?after=0", + headers, + }); + expect(belowFloor.statusCode).toBe(410); + expect(belowFloor.json().error.code).toBe("resync-required"); + + const replay = await app.inject({ + method: "POST", + url: "/v1/messages", + headers, + payload: { + messages: [message("550e8400-e29b-41d4-a716-446655440000", "first")], + }, + }); + expect(replay.json().acks[0]).toMatchObject({ + server_seq: "1", + disposition: "duplicate", + }); + }); + + it("atomically provisions the operator roster and permanently retires IDs", async () => { + const directory = await mkdtemp(join(tmpdir(), "agmsg-provision-test-")); + const manifestPath = join(directory, "team.json"); + const provisionTeam = "018f3f7e-0000-7000-8000-000000000101"; + const provisionMember = "018f3f7e-0000-7000-8000-000000000110"; + const provisionRegistration = "018f3f7e-0000-7000-8000-000000000111"; + const connection = new URL(databaseUrl ?? ""); + connection.searchParams.set("options", `-c search_path=${schema}`); + const environment = { + ...process.env, + DATABASE_URL: connection.toString(), + AGMSG_SERVER_TOKEN: token, + }; + const runProvision = () => + execFileAsync( + process.execPath, + ["node_modules/tsx/dist/cli.mjs", "src/provision.ts", manifestPath], + { cwd: process.cwd(), env: environment }, + ); + + try { + const member = { + member_id: provisionMember, + name: "provisioned-worker", + registrations: [ + { + registration_id: provisionRegistration, + installation_id: "018f3f7e-0000-7000-8000-000000000112", + type: "codex", + }, + ], + }; + await writeFile( + manifestPath, + JSON.stringify({ + team_id: provisionTeam, + team_name: "provisioned-team", + members: [member], + }), + ); + const first = await runProvision(); + expect(JSON.parse(first.stdout)).toMatchObject({ members_revision: "0" }); + + await writeFile( + manifestPath, + JSON.stringify({ + team_id: provisionTeam, + team_name: "provisioned-team", + members: [], + }), + ); + const second = await runProvision(); + expect(JSON.parse(second.stdout)).toMatchObject({ members_revision: "1" }); + + await writeFile( + manifestPath, + JSON.stringify({ + team_id: provisionTeam, + team_name: "provisioned-team", + members: [member], + }), + ); + await expect(runProvision()).rejects.toThrow(/retired/); + } finally { + if (!directory.startsWith(join(tmpdir(), "agmsg-provision-test-"))) { + throw new Error("refusing to remove an unexpected temporary directory"); + } + await rm(directory, { recursive: true }); + } + }); + + it("rejects duplicate JSON keys and rolls back a sequence-crossing batch", async () => { + const duplicateKeys = await app.inject({ + method: "POST", + url: "/v1/messages", + headers: { ...headers, "content-type": "application/json" }, + payload: '{"messages":[],"messages":[]}', + }); + expect(duplicateKeys.statusCode).toBe(400); + + await pool.query( + "UPDATE teams SET current_seq = 9223372036854775806 WHERE team_id = $1", + [teamId], + ); + const exhausted = await app.inject({ + method: "POST", + url: "/v1/messages", + headers, + payload: { + messages: [ + message("750e8400-e29b-41d4-a716-446655440003", "a"), + message("750e8400-e29b-41d4-a716-446655440004", "b"), + ], + }, + }); + expect(exhausted.statusCode).toBe(507); + expect(exhausted.json().error.code).toBe("sequence-exhausted"); + const rows = await pool.query<{ count: string }>( + "SELECT count(*)::text AS count FROM messages WHERE id = ANY($1::uuid[])", + [["750e8400-e29b-41d4-a716-446655440003", "750e8400-e29b-41d4-a716-446655440004"]], + ); + expect(rows.rows[0]?.count).toBe("0"); + }); +}); diff --git a/server/tsconfig.build.json b/server/tsconfig.build.json new file mode 100644 index 00000000..840014bb --- /dev/null +++ b/server/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "exclude": ["test"] +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 00000000..0449bc0e --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": ".", + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/server/vitest.config.ts b/server/vitest.config.ts new file mode 100644 index 00000000..9126c1ee --- /dev/null +++ b/server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + exclude: ["dist/**", "node_modules/**"], + }, +}); From 047139930fc4993ce09267291a5569de340d3775 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 20 Jul 2026 16:27:12 +0900 Subject: [PATCH 08/10] Make retention atomic and harden server packaging --- .github/workflows/tests.yml | 48 +++++++++++ server/Dockerfile | 2 +- server/README.md | 8 ++ server/package.json | 3 +- server/src/protocol.ts | 2 +- server/src/retain.ts | 15 ++++ server/src/storage.ts | 53 ++++++++++++ server/test/server.integration.test.ts | 108 ++++++++++++++++++++----- 8 files changed, 218 insertions(+), 21 deletions(-) create mode 100644 server/src/retain.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8a9e089c..9a1cee52 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -347,6 +347,54 @@ jobs: if: needs.changes.outputs.server_changed != 'false' run: npm run build + - name: Compiled start smoke test + if: needs.changes.outputs.server_changed != 'false' + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/agmsg_test + AGMSG_SERVER_TOKEN: ci-compiled-start-token + LOG_LEVEL: silent + run: | + npm start > "$RUNNER_TEMP/agmsg-server.log" 2>&1 & + server_pid=$! + cleanup() { + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + } + trap cleanup EXIT + for _attempt in $(seq 1 20); do + if curl --fail --silent http://127.0.0.1:8787/v1/health >/dev/null; then + exit 0 + fi + sleep 1 + done + cat "$RUNNER_TEMP/agmsg-server.log" + exit 1 + + - name: Docker image and health smoke test + if: needs.changes.outputs.server_changed != 'false' + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/agmsg_test + AGMSG_SERVER_TOKEN: ci-docker-start-token + CONTAINER_NAME: agmsg-reference-ci-${{ github.run_id }} + run: | + docker build -t agmsg-reference-server:ci . + docker run --detach --name "$CONTAINER_NAME" --network host \ + --env DATABASE_URL --env AGMSG_SERVER_TOKEN \ + --env HOST=127.0.0.1 --env PORT=8788 --env LOG_LEVEL=silent \ + agmsg-reference-server:ci + cleanup() { + docker rm --force "$CONTAINER_NAME" >/dev/null 2>&1 || true + } + trap cleanup EXIT + for _attempt in $(seq 1 20); do + if curl --fail --silent http://127.0.0.1:8788/v1/health >/dev/null; then + exit 0 + fi + sleep 1 + done + docker logs "$CONTAINER_NAME" + exit 1 + # Windows leg for the app: the command layer's bash resolution and path # conversion are all behind cfg(windows) and had never run in CI — the very # 0.1.1→0.1.3 regressions (WSL bash.exe, backslash argv). This runs cargo test diff --git a/server/Dockerfile b/server/Dockerfile index 8f2682be..c07b24a4 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -2,7 +2,7 @@ FROM node:22-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci -COPY tsconfig.json ./ +COPY tsconfig.json tsconfig.build.json ./ COPY migrations ./migrations COPY src ./src RUN npm run build diff --git a/server/README.md b/server/README.md index d5c8a040..a0ddd6ac 100644 --- a/server/README.md +++ b/server/README.md @@ -46,6 +46,14 @@ roster mutation remain outside HTTP v1: the provisioning command atomically applies the complete operator-owned roster manifest. IDs and retired names are checked against permanent identity history before replacement. +Retention is also an operator operation. It atomically creates permanent +idempotency tombstones, removes the covered delivery prefix, and advances the +team cursor floor while holding the same team-row lock as writers: + +```sh +npm run retain -- +``` + ## Verify Integration tests use an isolated, randomly named PostgreSQL schema. The test diff --git a/server/package.json b/server/package.json index 56a7691b..6f307a5c 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,8 @@ "dev": "tsx watch src/index.ts", "migrate": "tsx src/migrate.ts", "provision": "tsx src/provision.ts", - "start": "node dist/index.js", + "retain": "tsx src/retain.ts", + "start": "node dist/src/index.js", "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/server/src/protocol.ts b/server/src/protocol.ts index 4ea15ee2..7bda1235 100644 --- a/server/src/protocol.ts +++ b/server/src/protocol.ts @@ -54,7 +54,7 @@ const keyIdSchema = z.union([ export const envelopeSchema = z .object({ - v: z.number().int().nonnegative(), + v: z.number().int().min(0).max(0xffff_ffff), cipher: z.string().regex(cipherPattern), key_id: keyIdSchema, blob: z.string().refine(canonicalBlob), diff --git a/server/src/retain.ts b/server/src/retain.ts new file mode 100644 index 00000000..de6f2052 --- /dev/null +++ b/server/src/retain.ts @@ -0,0 +1,15 @@ +import { loadConfig } from "./config.js"; +import { createPool, migrate } from "./db.js"; +import { parseSequence, uuidV7Schema } from "./protocol.js"; +import { retainThrough } from "./storage.js"; + +const teamId = uuidV7Schema.parse(process.argv[2]); +const through = parseSequence(process.argv[3] ?? ""); +const config = loadConfig(); +const pool = createPool(config.databaseUrl); +try { + await migrate(pool); + process.stdout.write(`${JSON.stringify(await retainThrough(pool, teamId, through))}\n`); +} finally { + await pool.end(); +} diff --git a/server/src/storage.ts b/server/src/storage.ts index d049de36..545527dc 100644 --- a/server/src/storage.ts +++ b/server/src/storage.ts @@ -280,6 +280,59 @@ export async function postMessages( }); } +export async function retainThrough( + pool: Pool, + teamId: string, + through: bigint, +): Promise> { + return inTransaction(pool, async (client) => { + const serverId = await serverInstanceId(client); + const team = await teamRow(client, teamId, true); + if (!team) throw notFound(serverId, teamId); + const currentFloor = BigInt(team.min_available_seq); + const currentSequence = BigInt(team.current_seq); + if (through < currentFloor || through > currentSequence) { + throw new ProtocolError( + 400, + "invalid-request", + "retention floor must be between the current floor and current sequence", + { + through: through.toString(), + min_available_seq: team.min_available_seq, + current_seq: team.current_seq, + }, + { serverInstanceId: serverId, teamId }, + ); + } + + const tombstones = await client.query( + `INSERT INTO message_tombstones + (team_id, id, original_team_seq, envelope_digest) + SELECT team_id, id, team_seq, envelope_digest + FROM messages + WHERE team_id = $1 AND team_seq <= $2 + RETURNING id`, + [teamId, through.toString()], + ); + const deleted = await client.query( + "DELETE FROM messages WHERE team_id = $1 AND team_seq <= $2", + [teamId, through.toString()], + ); + if (deleted.rowCount !== tombstones.rowCount) { + throw new Error("retention tombstone and deletion counts differ"); + } + await client.query( + "UPDATE teams SET min_available_seq = $2 WHERE team_id = $1", + [teamId, through.toString()], + ); + return { + ...common(serverId, { ...team, min_available_seq: through.toString() }), + retained_through: through.toString(), + tombstones_created: String(tombstones.rowCount ?? 0), + }; + }); +} + export async function getMessages( pool: Pool, teamId: string, diff --git a/server/test/server.integration.test.ts b/server/test/server.integration.test.ts index d629bd3c..1ba27e22 100644 --- a/server/test/server.integration.test.ts +++ b/server/test/server.integration.test.ts @@ -9,6 +9,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createApp } from "../src/app.js"; import type { Config } from "../src/config.js"; import { migrate } from "../src/db.js"; +import { retainThrough } from "../src/storage.js"; const databaseUrl = process.env.TEST_DATABASE_URL; const describeDatabase = databaseUrl ? describe : describe.skip; @@ -84,13 +85,19 @@ describeDatabase("remote storage HTTP API v1", () => { }); function message(id: string, text: string) { + const plaintext = JSON.stringify({ + body: text, + created_at: "2026-07-20T06:30:00.000000Z", + from_agent: "leader", + to_agent: "worker-1", + }); return { id, envelope: { v: 1, cipher: "none", key_id: null, - blob: Buffer.from(text).toString("base64"), + blob: Buffer.from(plaintext).toString("base64"), }, }; } @@ -259,27 +266,52 @@ describeDatabase("remote storage HTTP API v1", () => { ).toEqual(["3", "4"]); }); - it("keeps retained IDs idempotent while enforcing the pull floor", async () => { - const moved = await pool.query<{ - id: string; - team_seq: string; - envelope_digest: Buffer; - }>( - `DELETE FROM messages WHERE team_id = $1 AND team_seq = 1 - RETURNING id::text, team_seq::text, envelope_digest`, - [teamId], + it("retains atomically under the writer lock and keeps tombstones idempotent", async () => { + await pool.query( + `CREATE FUNCTION fail_tombstone_insert() RETURNS trigger AS $$ + BEGIN RAISE EXCEPTION 'injected retention failure'; END; + $$ LANGUAGE plpgsql`, ); - const row = moved.rows[0]; - expect(row).toBeDefined(); await pool.query( - `INSERT INTO message_tombstones - (team_id, id, original_team_seq, envelope_digest) - VALUES ($1, $2, $3, $4)`, - [teamId, row?.id, row?.team_seq, row?.envelope_digest], + `CREATE TRIGGER fail_tombstone_insert + BEFORE INSERT ON message_tombstones + FOR EACH ROW EXECUTE FUNCTION fail_tombstone_insert()`, + ); + await expect(retainThrough(pool, teamId, 1n)).rejects.toThrow( + /injected retention failure/, ); - await pool.query("UPDATE teams SET min_available_seq = 1 WHERE team_id = $1", [ - teamId, + const rolledBack = await pool.query<{ + messages: string; + tombstones: string; + floor: string; + }>( + `SELECT + (SELECT count(*)::text FROM messages WHERE team_id = $1) AS messages, + (SELECT count(*)::text FROM message_tombstones WHERE team_id = $1) AS tombstones, + (SELECT min_available_seq::text FROM teams WHERE team_id = $1) AS floor`, + [teamId], + ); + expect(rolledBack.rows[0]).toEqual({ messages: "4", tombstones: "0", floor: "0" }); + await pool.query("DROP TRIGGER fail_tombstone_insert ON message_tombstones"); + await pool.query("DROP FUNCTION fail_tombstone_insert()"); + + const concurrent = message("750e8400-e29b-41d4-a716-446655440007", "after-floor"); + const [retained, posted] = await Promise.all([ + retainThrough(pool, teamId, 4n), + app.inject({ + method: "POST", + url: "/v1/messages", + headers, + payload: { messages: [concurrent] }, + }), ]); + expect(retained).toMatchObject({ + min_available_seq: "4", + retained_through: "4", + tombstones_created: "4", + }); + expect(posted.statusCode).toBe(200); + expect(posted.json().acks[0].server_seq).toBe("5"); const belowFloor = await app.inject({ method: "GET", @@ -301,6 +333,46 @@ describeDatabase("remote storage HTTP API v1", () => { server_seq: "1", disposition: "duplicate", }); + + await pool.query( + "UPDATE teams SET write_allowed_ciphers = ARRAY[]::TEXT[] WHERE team_id = $1", + [teamId], + ); + const duplicateUnderNewPolicy = await app.inject({ + method: "POST", + url: "/v1/messages", + headers, + payload: { + messages: [message("550e8400-e29b-41d4-a716-446655440000", "first")], + }, + }); + expect(duplicateUnderNewPolicy.statusCode).toBe(200); + expect(duplicateUnderNewPolicy.json().acks[0].disposition).toBe("duplicate"); + const rejectedFresh = await app.inject({ + method: "POST", + url: "/v1/messages", + headers, + payload: { messages: [message("750e8400-e29b-41d4-a716-446655440008", "fresh")] }, + }); + expect(rejectedFresh.statusCode).toBe(403); + await pool.query( + "UPDATE teams SET write_allowed_ciphers = ARRAY['none']::TEXT[] WHERE team_id = $1", + [teamId], + ); + + const invalidVersion = message( + "550e8400-e29b-41d4-a716-446655440000", + "first", + ); + invalidVersion.envelope.v = 0x1_0000_0000; + const outOfRange = await app.inject({ + method: "POST", + url: "/v1/messages", + headers, + payload: { messages: [invalidVersion] }, + }); + expect(outOfRange.statusCode).toBe(400); + expect(outOfRange.json().error.code).toBe("invalid-request"); }); it("atomically provisions the operator roster and permanently retires IDs", async () => { From d0d8ccdb3a9894ec636f26c0774fa057522799c4 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 21 Jul 2026 14:59:45 +0900 Subject: [PATCH 09/10] docs: publish PRINCIPLES.md and link it from the README and site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the project's commitments document, approved verbatim by koit (only the draft-status header block is removed; the body is unchanged) — local-first as a design rule, an open sync protocol, no lock-in on your data, structural encryption, and the maintainers' own hosted service framed as one provider among possible providers, not a privileged one. - PRINCIPLES.md at the repo root. - Linked from both README.md and README.ja.md in a new "Principles" / "原則" section. - Published on agmsg.cc at /principles (site/src/pages/principles.astro), rendering the repo-root PRINCIPLES.md at build time so the page and the doc can't drift apart. EN only for now, matching the doc itself. --- PRINCIPLES.md | 63 ++++++++++++++++++++++++ README.ja.md | 4 ++ README.md | 4 ++ site/src/pages/principles.astro | 87 +++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 PRINCIPLES.md create mode 100644 site/src/pages/principles.astro diff --git a/PRINCIPLES.md b/PRINCIPLES.md new file mode 100644 index 00000000..ccebff56 --- /dev/null +++ b/PRINCIPLES.md @@ -0,0 +1,63 @@ +# agmsg principles + +agmsg is an open-source messaging layer for AI agent teams. As the project +grows — including work that makes agmsg usable across machines and, in the +future, hosted services operated by the maintainers — these are the +commitments we hold ourselves to. They are design constraints, not +marketing. Changes to this document are made in the open, with reasoning. + +## 1. The core works on its own + +The agmsg core is open source and fully functional without any server, +account, or hosted service — today's local workflow is not a demo tier. +We will not move existing core functionality behind a hosted offering. + +## 2. Local-first is a design rule, not a feature + +An agent's hot path never waits on a network. Messages commit locally +first; anything remote synchronizes in the background and catches up after +being offline. Remote availability may delay sync — it must never corrupt +or block local work. + +## 3. The protocol is open + +The synchronization protocol is specified in the open, and a self-hostable +reference server is published as open source. Anything that can talk the +protocol is a first-class citizen: a hosted service run by us is one +provider among possible providers, and interoperability is not reserved +for it. + +## 4. Your data leaves with you + +Whatever stores your messages — local files or a remote server — you can +export all of your data, at any time, in an open format. Leaving must +always be a supported path, not a negotiation. + +## 5. Encryption is structural, not bolted on + +The remote protocol carries message contents in sealed envelopes that +servers store without parsing; the server-side schema has no plaintext +message fields to begin with. End-to-end encryption is implemented as a +first-class mode, and we are honest about its limits: it protects +contents, not traffic patterns. + +## 6. Commercial services sell operation, not the software + +The maintainers — and anyone else; the protocol is open — may run paid +services around agmsg. What such services sell is the work of running +things: servers, storage, uptime, organization-level management. The +software itself stays open, and any boundary between open code and a paid +service is drawn in the open. + +## 7. Community changes are judged by open-source value + +Contributions and design changes to the core are evaluated by what they do +for open-source users. Requirements that originate from a hosted or +commercial context must earn their place by having standalone value in the +open-source project, and are declined otherwise. + +--- + +*This document states our current commitments and how we intend to keep +working. It is versioned with the repository; if it ever needs to change, +the change and its reasoning will be public.* diff --git a/README.ja.md b/README.ja.md index 6b6076c7..e16ef23a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -555,6 +555,10 @@ agmsgのプラグイン可能な単位は軸(axis)ごとにグループ化 agmsgでコピペの往復が省けたなら、GitHubスターが他の人にこのプロジェクトを見つけてもらう助けになる。 +## 原則 + +agmsgが成長していく中で自らに課す約束事 — ローカルファーストを設計原則として、同期プロトコルはオープンに、データはいつでも持ち出せる形で、暗号化は構造的に組み込む、など。[PRINCIPLES.md](PRINCIPLES.md)(英語)を参照。 + ## ライセンス MIT diff --git a/README.md b/README.md index ddc01103..9faf2d1e 100644 --- a/README.md +++ b/README.md @@ -591,6 +591,10 @@ See [Design & Architecture](docs/design.md) for developer documentation — iden If agmsg saves you copy-paste round-trips, a GitHub star helps other people find it. +## Principles + +The commitments agmsg holds itself to as it grows — local-first as a design rule, an open sync protocol, no lock-in on your data, structural encryption. See [PRINCIPLES.md](PRINCIPLES.md). + ## License MIT diff --git a/site/src/pages/principles.astro b/site/src/pages/principles.astro new file mode 100644 index 00000000..f0c49d4a --- /dev/null +++ b/site/src/pages/principles.astro @@ -0,0 +1,87 @@ +--- +import "../styles/global.css"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +// Renders the repo-root PRINCIPLES.md so the site and the doc never drift. +// PRINCIPLES.md is EN-only for now (see the doc itself), so this page is +// unprefixed at "/principles" only -- no [lang] variants yet. +const principlesPath = fileURLToPath(new URL("../../../PRINCIPLES.md", import.meta.url)); +const raw = fs.readFileSync(principlesPath, "utf8"); + +// Minimal structural parse, not a general markdown renderer -- PRINCIPLES.md +// has a fixed shape (H1 title, one intro paragraph, numbered "## " sections, +// a closing "---" + one italic paragraph) and rendering it structurally +// keeps this page's logic auditable without adding a markdown dependency. +const withoutTitle = raw.replace(/^#\s+agmsg principles\s*\n+/, ""); +const [body, closing] = withoutTitle.split(/\n---\n/); +const [intro, ...sectionChunks] = body.trim().split(/\n##\s+/); +const sections = sectionChunks.map((chunk) => { + const newlineIndex = chunk.indexOf("\n"); + return { + heading: chunk.slice(0, newlineIndex).trim(), + body: chunk.slice(newlineIndex).trim(), + }; +}); +const closingText = (closing || "").trim().replace(/^\*|\*$/g, ""); + +const siteUrl = "https://agmsg.cc"; +const pageTitle = "Principles — agmsg"; +const pageDesc = "The commitments agmsg holds itself to as the project grows: local-first, an open protocol, no lock-in, structural encryption."; +const canonicalUrl = `${siteUrl}/principles`; +--- + + + + + + + {pageTitle} + + + + + + + + + + + + + + + + + + + + + + + +
+ ← agmsg + +

Principles

+

{intro.trim()}

+ +
    + {sections.map((s) => ( +
  1. +

    {s.heading}

    +

    {s.body}

    +
  2. + ))} +
+ + {closingText && ( +

{closingText}

+ )} + +

+ Source: PRINCIPLES.md in the repo. +

+
+ + From 9e848b62bbd45843e78f8fa71411f2ef0e437212 Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 22 Jul 2026 05:06:41 +0900 Subject: [PATCH 10/10] fix(site): drop ol/li on the principles page to avoid double-announced numbering Section headings already carry their own number ("1. The core works on its own"), so the surrounding
    /
  1. 's implicit list ordinal could be announced alongside it by some screen readers. Plain div/section carries no such semantics; the visible numbering is unchanged since it lives in the heading text. Found in aggie-co1 delta review of PR #448 (non-blocking). --- site/src/pages/principles.astro | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/pages/principles.astro b/site/src/pages/principles.astro index f0c49d4a..3e4cea47 100644 --- a/site/src/pages/principles.astro +++ b/site/src/pages/principles.astro @@ -66,14 +66,14 @@ const canonicalUrl = `${siteUrl}/principles`;

    Principles

    {intro.trim()}

    -
      +
      {sections.map((s) => ( -
    1. +

      {s.heading}

      {s.body}

      -
    2. + ))} -
    + {closingText && (

    {closingText}