From 64c1f458f641926b2e5a53bcd6d2bc962f082246 Mon Sep 17 00:00:00 2001 From: TheodorAdrienIsaak Mattli Date: Tue, 20 Oct 2026 16:15:46 +0200 Subject: [PATCH] Add shared community projects and sponsored measurements --- README.md | 1 + api/openapi.yaml | 2767 ++++++++++++++++- api/spec.go | 5 +- docs/README.md | 1 + docs/api.md | 86 +- docs/communities.md | 166 + docs/development/workflow-authoring.md | 3 + docs/operations/configuration.md | 3 +- docs/operations/executor-discovery.md | 10 +- docs/operations/payments.md | 9 + docs/results.md | 8 +- docs/security.md | 5 +- docs/versions.md | 11 +- .../dispatcher/database/allowances.sql.go | 12 +- .../dispatcher/database/communities.sql.go | 891 ++++++ .../dispatcher/database/measurements.sql.go | 18 +- .../database/migrations/00032_communities.sql | 79 + .../migrations/00033_community_funding.sql | 27 + internal/dispatcher/database/models.go | 76 + .../database/queries/allowances.sql | 12 +- .../database/queries/communities.sql | 187 ++ .../database/queries/measurements.sql | 18 +- .../database/queries/transactions.sql | 10 +- .../dispatcher/database/transactions.sql.go | 10 +- internal/dispatcher/payments/community.go | 254 ++ .../dispatcher/payments/community_test.go | 435 +++ internal/dispatcher/payments/payments.go | 46 +- internal/dispatcher/payments/payments_test.go | 13 + internal/dispatcher/payments/receipts.go | 16 +- .../dispatcher/transport/api/api_models.go | 18 +- internal/dispatcher/transport/api/auth.go | 7 +- .../dispatcher/transport/api/auth_test.go | 134 +- .../transport/api/community_document.go | 144 + .../transport/api/community_document_test.go | 242 ++ .../transport/api/community_funding_test.go | 313 ++ .../transport/api/community_read.go | 49 + .../transport/api/community_read_test.go | 237 ++ .../transport/api/credential_scopes.go | 15 + .../transport/api/handler_cancellation.go | 2 +- .../transport/api/handler_recovery.go | 2 +- .../transport/api/handler_result.go | 2 +- .../transport/api/handlers_communities.go | 652 ++++ .../api/handlers_communities_test.go | 359 +++ .../api/handlers_community_funding.go | 250 ++ .../transport/api/handlers_debuglet.go | 7 +- .../transport/api/handlers_economics.go | 3 + .../transport/api/handlers_measurements.go | 11 +- .../transport/api/handlers_payment.go | 29 +- .../transport/api/openapi_communities_test.go | 126 + .../transport/api/openapi_contract_test.go | 1 + .../transport/api/payment_mode_test.go | 13 +- .../transport/api/policy_bounds_test.go | 1 + internal/dispatcher/transport/api/retry.go | 6 +- internal/dispatcher/transport/api/routes.go | 2 + internal/storagecheck/init_test.go | 109 +- internal/storagecheck/policy.go | 6 +- pkg/wire/communities.go | 62 + pkg/wire/measurements.go | 12 +- pkg/wire/result.go | 4 +- 59 files changed, 7777 insertions(+), 220 deletions(-) create mode 100644 docs/communities.md create mode 100644 internal/dispatcher/database/communities.sql.go create mode 100644 internal/dispatcher/database/migrations/00032_communities.sql create mode 100644 internal/dispatcher/database/migrations/00033_community_funding.sql create mode 100644 internal/dispatcher/database/queries/communities.sql create mode 100644 internal/dispatcher/payments/community.go create mode 100644 internal/dispatcher/payments/community_test.go create mode 100644 internal/dispatcher/transport/api/community_document.go create mode 100644 internal/dispatcher/transport/api/community_document_test.go create mode 100644 internal/dispatcher/transport/api/community_funding_test.go create mode 100644 internal/dispatcher/transport/api/community_read.go create mode 100644 internal/dispatcher/transport/api/community_read_test.go create mode 100644 internal/dispatcher/transport/api/handlers_communities.go create mode 100644 internal/dispatcher/transport/api/handlers_communities_test.go create mode 100644 internal/dispatcher/transport/api/handlers_community_funding.go create mode 100644 internal/dispatcher/transport/api/openapi_communities_test.go create mode 100644 pkg/wire/communities.go diff --git a/README.md b/README.md index 612b1a8a..0667962a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ The versioned guides in [`docs/`](docs/README.md) are published at the [document | Write a Go application client | [Go client library](docs/client.md) | | Write a debuglet | [Write a debuglet](docs/debuglets.md) | | Build visually or with an MCP client | [Workflow and experiment authoring](docs/development/workflow-authoring.md) | +| Share a lab's experiments and results | [Communities](docs/communities.md) | | Integrate with the HTTP API | [OpenAPI contract](api/openapi.yaml) and [API guide](docs/api.md) | | Understand the code and protocol | [Architecture](docs/architecture.md) | | Operate a service | [Managed services](docs/operations/services.md) | diff --git a/api/openapi.yaml b/api/openapi.yaml index eac4205d..fe3927ec 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,8 +1,8 @@ openapi: "3.0.3" info: title: "Debuglet dispatcher HTTP API" - version: "1.22" - description: "Wire contract of the dispatcher's public HTTP API. Bandwidth values are bits per second, timeouts are milliseconds, TESLA delays are seconds, TESLA anchor timestamps are Unix nanoseconds and every other timestamp is Unix seconds unless a field says otherwise. A client states the contract version it requires with the Debuglet-API-Version request header, declared on every operation below; every response carries the version this dispatcher implements in the same header. Requests are authenticated with a server-issued session: a native client presents it as an Authorization bearer token, a browser client in the session_token cookie plus the X-Debuglet-CSRF header on state-changing requests. Each operation states its own 401 and 403. Access to another account's run or payment order answers 404, so a response is no existence oracle. A dispatcher explicitly configured for the local development profile additionally serves the unauthenticated routes below as its own local operator. See docs/api.md for the access matrix and the compatibility and deprecation policy." + version: "1.23" + description: "Wire contract of the dispatcher's public HTTP API. Bandwidth values are bits per second, timeouts are milliseconds, TESLA delays are seconds, TESLA anchor timestamps are Unix nanoseconds and every other timestamp is Unix seconds unless a field says otherwise. A client states the contract version it requires with the Debuglet-API-Version request header, declared on every operation below; every response carries the version this dispatcher implements in the same header. Requests are authenticated with a server-issued session: a native client presents it as an Authorization bearer token, a browser client in the session_token cookie plus the X-Debuglet-CSRF header on state-changing requests. Each operation states its own 401 and 403. Run reads require ownership, operator authority or explicit community sharing to a current member; payment orders and run mutations remain owner-scoped. A hidden resource answers 404. A dispatcher explicitly configured for the local development profile additionally serves the unauthenticated routes below as its own local operator. See docs/api.md for the access matrix and the compatibility and deprecation policy." servers: - url: "/" description: "Dispatcher served at the root of its origin." @@ -689,7 +689,7 @@ paths: get: operationId: "getDebugletResult" summary: "Export a portable retained-run snapshot." - description: "Added in API 1.8. Reads run, immutable admission provenance and retained output from one SQLite snapshot without contacting the executor. Writes portable format 1.1 for ordinary runs and 1.2 for explicit retries, independent of the HTTP API version. Does not wait for completion." + description: "Added in API 1.8. Reads run, immutable admission provenance and retained output from one SQLite snapshot without contacting the executor. Writes portable format 1.1 for ordinary runs and 1.2 for explicit retries, independent of the HTTP API version. Does not wait for completion. API 1.23 also permits current members of a community with which this measurement was explicitly shared; it grants no mutation or private payment access." parameters: - name: "id" in: "path" @@ -738,7 +738,7 @@ paths: schema: $ref: "#/components/schemas/Error" "404": - description: "The run does not exist or belongs to another account." + description: "The run does not exist or is not visible to this account." headers: Debuglet-API-Version: description: "Contract version this dispatcher implements." @@ -3208,7 +3208,7 @@ paths: schema: $ref: "#/components/schemas/Error" "404": - description: "No debuglet with that ID is recorded, or it belongs to another account. The two are deliberately indistinguishable." + description: "No debuglet with that ID is recorded, or it is not visible to this account. The two are deliberately indistinguishable." headers: Debuglet-API-Version: description: "Contract version this dispatcher implements." @@ -3244,7 +3244,7 @@ paths: get: operationId: "getDebugletCancellation" summary: "Inspect a durable cancellation request without retrying it." - description: "Added in API 1.9. Read-only: does not send Abort, select a terminal result or settle payment. Delivery attempts do not confirm receipt. Only a persisted successful Abort reply establishes acknowledgement; a local terminal result can coexist with an unknown remote outcome. Original binding never follows a replacement. No background cancellation replay occurs." + description: "Added in API 1.9. Read-only: does not send Abort, select a terminal result or settle payment. Delivery attempts do not confirm receipt. Only a persisted successful Abort reply establishes acknowledgement; a local terminal result can coexist with an unknown remote outcome. Original binding never follows a replacement. No background cancellation replay occurs. API 1.23 also permits current members of a community with which this measurement was explicitly shared; it grants no mutation or private payment access." parameters: - name: "id" in: "path" @@ -3295,7 +3295,7 @@ paths: schema: $ref: "#/components/schemas/Error" "404": - description: "No cancellation request is recorded for that run, the run is missing, or it belongs to another account. The two are deliberately indistinguishable." + description: "No cancellation request is recorded for that run, the run is missing, or it is not visible to this account. The two are deliberately indistinguishable." headers: Debuglet-API-Version: description: "Contract version this dispatcher implements." @@ -3331,7 +3331,7 @@ paths: get: operationId: "getDebugletRecovery" summary: "Inspect a known run without replaying or changing it." - description: "Separates stored outcome, original control availability and at most one validated, dated executor observation. A replaced observer remains historical. Missing or failed observations have null provenance; absent or filtered replies do not authorize replay. The lookup is bounded to five seconds and never retargets a replacement. See docs/operations/recovery-inspection.md." + description: "Separates stored outcome, original control availability and at most one validated, dated executor observation. A replaced observer remains historical. Missing or failed observations have null provenance; absent or filtered replies do not authorize replay. The lookup is bounded to five seconds and never retargets a replacement. See docs/operations/recovery-inspection.md. API 1.23 also permits current members of a community with which this measurement was explicitly shared; it grants no mutation or private payment access." parameters: - name: "id" in: "path" @@ -3382,7 +3382,7 @@ paths: schema: $ref: "#/components/schemas/Error" "404": - description: "No debuglet with that ID is recorded, or it belongs to another account. The two are deliberately indistinguishable." + description: "No debuglet with that ID is recorded, or it is not visible to this account. The two are deliberately indistinguishable." headers: Debuglet-API-Version: description: "Contract version this dispatcher implements." @@ -3418,7 +3418,7 @@ paths: get: operationId: "getDebugletLogs" summary: "Read one page of a debuglet's stored output." - description: "Entries, workload state and output finality come from one database snapshot. A terminal workload does not imply complete output. Output metadata was added in API 1.6; older servers omit it." + description: "Entries, workload state and output finality come from one database snapshot. A terminal workload does not imply complete output. Output metadata was added in API 1.6; older servers omit it. API 1.23 also permits current members of a community with which this measurement was explicitly shared; it grants no mutation or private payment access." parameters: - name: "id" in: "path" @@ -3486,7 +3486,7 @@ paths: schema: $ref: "#/components/schemas/Error" "404": - description: "No debuglet with that ID is recorded, or it belongs to another account. The two are deliberately indistinguishable." + description: "No debuglet with that ID is recorded, or it is not visible to this account. The two are deliberately indistinguishable." headers: Debuglet-API-Version: description: "Contract version this dispatcher implements." @@ -4378,7 +4378,7 @@ paths: put: operationId: "createPaymentIntent" summary: "Create a payment intent for a batch of debuglets." - description: "Prices the batch and, once every order has been validated and priced, stores one outstanding order per debuglet and returns the transaction ID and auth key used by the following submission. The HTTP API admits the methods TEST and USDC; TEST is the local alpha's bookkeeping-only method. The encoded request body must not exceed 33554432 bytes. Explicit retries require API 1.12 and preserve one parent/request identity across intent and submission; reusing that identity with the same frozen request recovers the existing attempt." + description: "Prices the batch and, once every order has been validated and priced, stores one outstanding order per debuglet and returns the transaction ID and auth key used by the following submission. The HTTP API admits request methods TEST and USDC; TEST is bookkeeping only. API 1.23 optionally selects community_project_id, reserves that shared budget and returns method COMMUNITY without a new external-wallet payment. Community experiment ID/revision may link the run to saved source. The encoded request body must not exceed 33554432 bytes. Explicit retries require API 1.12 and preserve one parent/request identity across intent and submission; reusing that identity with the same frozen request recovers the existing attempt." requestBody: required: true content: @@ -4449,7 +4449,7 @@ paths: schema: $ref: "#/components/schemas/Error" "503": - description: "A chain payment method was requested while blockchain payments are disabled (payments_disabled), or an operator has stopped the admission of new submissions on this dispatcher (service_unavailable). No intent is created and nothing is priced in either case." + description: "A chain payment method was requested while blockchain payments are disabled (payments_disabled), the community uses TEST while usage allowances are disabled, or an operator has stopped the admission of new submissions on this dispatcher (service_unavailable). No intent is created and nothing is priced in either case." headers: Debuglet-API-Version: description: "Contract version this dispatcher implements." @@ -4475,7 +4475,7 @@ paths: schema: $ref: "#/components/schemas/Error" "404": - description: "Retry parent missing or not owned (not_found)." + description: "Retry parent missing or not owned, or the community project is not visible to this account (not_found)." headers: Debuglet-API-Version: description: "Contract version this dispatcher implements." @@ -4486,7 +4486,7 @@ paths: schema: $ref: "#/components/schemas/Error" "409": - description: "Request identity is bound to a different parent or frozen payload (intent_mismatch), or, while usage allowances are enabled, the account's allowance history cannot be represented in signed 64-bit integers (allowance_out_of_range); nothing of the intent is written." + description: "The community budget cannot cover the reservation (invalid_request), request identity is bound to a different parent or frozen payload (intent_mismatch), or, while usage allowances are enabled, the account's allowance history cannot be represented in signed 64-bit integers (allowance_out_of_range); nothing of the intent is written." headers: Debuglet-API-Version: description: "Contract version this dispatcher implements." @@ -4500,7 +4500,7 @@ paths: post: operationId: "quotePayment" summary: "Price a batch of debuglets without creating an intent." - description: "Added in API 1.15. Takes the body of PUT /payment/intent and prices it with the same rule, without writing anything: no transaction, no auth key, no order and no run. A batch the intent would refuse (an empty batch, an unknown executor, an invalid policy, a start_time outside its range, a policy the executor cannot currently serve such as require_icmp without ICMP, a repeated order_id or a price that does not fit a signed 64-bit integer) is answered 200 with every problem in the quote's errors and total empty. retry is not evaluated. Each quote counts against the account request quota like an intent. The intent prices the batch again when it is created and returns its own quote. See docs/api.md for the pricing rule." + description: "Added in API 1.15. Takes the body of PUT /payment/intent and prices it with the same rule, without writing anything: no transaction, no auth key, no order and no run. A batch the intent would refuse (an empty batch, an unknown executor, an invalid policy, a start_time outside its range, a policy the executor cannot currently serve such as require_icmp without ICMP, a repeated order_id or a price that does not fit a signed 64-bit integer) is answered 200 with every problem in the quote's errors and total empty. retry is not evaluated. Each quote counts against the account request quota like an intent. The intent prices the batch again when it is created and returns its own quote. API 1.23 community_project_id also checks current membership and experiment field pairing without reserving funds. Exact saved revision and program consistency are checked during intent creation. See docs/api.md for the pricing rule." requestBody: required: true content: @@ -4548,6 +4548,17 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + "404": + description: "The selected community project is not visible to this account." + headers: + Debuglet-API-Version: + description: "Contract version this dispatcher implements." + schema: + type: "string" + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "413": description: "The body exceeds 33554432 bytes (payload_too_large). A declared length above the limit is refused without reading the body; a body of unknown length is cut at the limit, so a decode that needs more fails with this answer. No handler sees a byte beyond the limit. Also returned for more than 128 orders, a decoded module over 24 MiB, or a logical stored run over 32 MiB." headers: @@ -5791,7 +5802,7 @@ paths: "get": "operationId": "getMeasurement" "summary": "Page measurement children" - "description": "Page the owner’s batch using its durable transaction ID. The response contains lightweight child references in stable order, without arguments, policies, provenance or output. Read each child’s configuration through its detail endpoint. Limit defaults to 25 (1–100); offset is 0–1000000." + "description": "Page an owned or explicitly shared batch using its durable transaction ID. Current community membership grants read access only; can_manage reports mutation authority. The response contains lightweight child references in stable order, without arguments, policies, provenance or output. Read each child’s configuration through its detail endpoint. Limit defaults to 25 (1–100); offset is 0–1000000. API 1.23 also permits current members of a community with which this measurement was explicitly shared; it grants no mutation or private payment access." "parameters": - "name": "Debuglet-API-Version" "in": "header" @@ -5861,7 +5872,7 @@ paths: "schema": "$ref": "#/components/schemas/Error" "404": - "description": "Measurement absent or belongs to another account." + "description": "Measurement absent or is not visible to this account." "headers": "Debuglet-API-Version": "schema": @@ -5897,7 +5908,7 @@ paths: get: operationId: "getRunDetail" summary: "Read retained run configuration" - description: "Read retained configuration without loading output, bounded to 32 MiB independently of profile limits. Requested arguments, policy and start are preserved before normalization. Historical configuration may be null. Actual execution timestamps remain null when unobserved. Cost strings use exact native base units; charged is null until credited or refunded." + description: "Read retained configuration without loading output, bounded to 32 MiB independently of profile limits. Requested arguments, policy and start are preserved before normalization. Historical configuration may be null. Actual execution timestamps remain null when unobserved. Cost strings use exact native base units; charged is null until credited or refunded. API 1.23 also permits current members of a community with which this measurement was explicitly shared; it grants no mutation or private payment access." parameters: - name: "Debuglet-API-Version" in: "header" @@ -5955,7 +5966,7 @@ paths: schema: $ref: "#/components/schemas/Error" "404": - description: "Measurement absent or belongs to another account." + description: "Measurement absent or is not visible to this account." headers: Debuglet-API-Version: schema: @@ -6086,27 +6097,2331 @@ paths: schema: $ref: "#/components/schemas/Error" "413": - description: "Request body exceeds 33554432 bytes (payload_too_large)." + description: "Request body exceeds 33554432 bytes (payload_too_large)." + headers: + Debuglet-API-Version: + description: "Contract version this dispatcher implements." + schema: + type: "string" + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: "Storage/retention could not be checked or committed (internal_error)." + headers: + Debuglet-API-Version: + description: "Contract version this dispatcher implements." + schema: + type: "string" + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "/communities": + "get": + "operationId": "listCommunities" + "summary": "List communities you belong to." + "description": "Added in API 1.23. Requires measurements:read for a scoped credential. Each result reports the caller's role; it grants no dispatcher-wide operator authority." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + "responses": + "200": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityList" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "post": + "operationId": "createCommunity" + "summary": "Create a community owned by your account." + "description": "Added in API 1.23. Requires a browser session. Creates the owner membership. At most 16 communities may be owned by one account. Creating a community creates no funds or TEST grants." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + "requestBody": + "required": true + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityNameRequest" + "responses": + "201": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Community" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "409": + "description": "The current revision, membership or resource quota conflicts with the request." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "/communities/{id}": + "get": + "operationId": "getCommunity" + "summary": "Read a community you belong to." + "description": "Added in API 1.23. Requires measurements:read and current membership." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "responses": + "200": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Community" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "patch": + "operationId": "updateCommunity" + "summary": "Change a community's name and description." + "description": "Added in API 1.23. Requires a browser session and the community owner. Sends the complete name and description; an empty description clears it." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "requestBody": + "required": true + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityNameRequest" + "responses": + "200": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Community" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "409": + "description": "The current revision, membership or resource quota conflicts with the request." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "/communities/{id}/members": + "get": + "operationId": "listCommunityMembers" + "summary": "List community members." + "description": "Added in API 1.23. Requires a browser session and current membership. At most 128 members belong to a community." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "responses": + "200": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityMemberList" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "/communities/{id}/invitations": + "post": + "operationId": "createCommunityInvitation" + "summary": "Create a one-use membership invitation." + "description": "Added in API 1.23. Requires a browser session and the community owner. The token is shown once, expires after seven days and can be accepted by one authenticated account. At most eight unexpired invitations may be outstanding. Treat the token as a credential and send it privately." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "responses": + "201": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityInvitation" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "409": + "description": "The current revision, membership or resource quota conflicts with the request." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "/community-invitations/accept": + "post": + "operationId": "acceptCommunityInvitation" + "summary": "Join a community using an invitation." + "description": "Added in API 1.23. Requires a browser session. Accepts the token for the signed-in account; the token cannot be used again. An invalid, expired or consumed invitation returns 404. Joining is also refused at 128 members per community or 128 memberships per account." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + "requestBody": + "required": true + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityInvitationAcceptance" + "responses": + "200": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Community" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "409": + "description": "The current revision, membership or resource quota conflicts with the request." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "/communities/{id}/members/{user_id}": + "delete": + "operationId": "removeCommunityMember" + "summary": "Remove a member from a community." + "description": "Added in API 1.23. Requires a browser session and the community owner. The creator cannot be removed. Revocation applies to subsequent reads and new spending; it cannot retract already downloaded results." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + - "name": "user_id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "responses": + "204": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "409": + "description": "The current revision, membership or resource quota conflicts with the request." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "/communities/{id}/projects": + "get": + "operationId": "listCommunityProjects" + "summary": "List projects in a community." + "description": "Added in API 1.23. Requires measurements:read and current membership. Returns at most 64 projects." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "responses": + "200": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityProjectList" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "post": + "operationId": "createCommunityProject" + "summary": "Create a shared project." + "description": "Added in API 1.23. Requires measurements:write and current membership. At most 64 projects may belong to one community." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "requestBody": + "required": true + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityNameRequest" + "responses": + "201": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityProject" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "409": + "description": "The current revision, membership or resource quota conflicts with the request." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "/community-projects/{id}": + "get": + "operationId": "getCommunityProject" + "summary": "Read a shared project." + "description": "Added in API 1.23. Requires measurements:read and current community membership." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "responses": + "200": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityProject" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "patch": + "operationId": "updateCommunityProject" + "summary": "Change a shared project's name and description." + "description": "Added in API 1.23. Requires measurements:write and current community membership. Sends the complete name and description; an empty description clears it." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "requestBody": + "required": true + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityNameRequest" + "responses": + "200": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityProject" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "409": + "description": "The current revision, membership or resource quota conflicts with the request." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "/community-projects/{id}/experiments": + "get": + "operationId": "listCommunityExperiments" + "summary": "List the latest saved experiment revisions." + "description": "Added in API 1.23. Requires measurements:read and current community membership. Returns metadata for at most 128 experiments, without document or bundle; fetch an experiment to open its source." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "responses": + "200": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityExperimentList" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "post": + "operationId": "createCommunityExperiment" + "summary": "Save a workflow or coordinated experiment." + "description": "Added in API 1.23. Requires measurements:write and current community membership. Stores revision 1. Retained revision source and bundles are limited to 256 MiB per community. The whole request is limited to 24 MiB, workflow source to 1 MiB and coordinated experiment source to 4 MiB. The optional bundle must match the source and its recorded hashes. Consistency checks do not certify that arbitrary uploaded WASM implements its claimed source." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "requestBody": + "required": true + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CreateCommunityExperiment" + "responses": + "201": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityExperiment" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "409": + "description": "The current revision, membership or resource quota conflicts with the request." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "/community-experiments/{id}": + "get": + "operationId": "getCommunityExperiment" + "summary": "Open a saved experiment revision." + "description": "Added in API 1.23. Requires measurements:read and current community membership. Without revision, returns the latest saved source and optional bundle. A specific historical revision stays immutable." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + - "name": "revision" + "in": "query" + "required": false + "schema": + "type": "integer" + "format": "int64" + "minimum": 1 + "responses": + "200": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityExperiment" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "put": + "operationId": "updateCommunityExperiment" + "summary": "Save the next experiment revision." + "description": "Added in API 1.23. Requires measurements:write and current community membership. The revision in the body must equal the current revision; otherwise 409, without overwriting another edit. Stores a new immutable revision, up to 32 revisions per experiment and 256 MiB of retained revision bytes per community. Omitted or null bundle removes the compiled artifact from the new revision; a replacement must match the supplied source. The same size and validation rules as creation apply." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "requestBody": + "required": true + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/UpdateCommunityExperiment" + "responses": + "200": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityExperiment" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "409": + "description": "The current revision, membership or resource quota conflicts with the request." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "delete": + "operationId": "deleteCommunityExperiment" + "summary": "Delete an unused saved experiment and its revisions." + "description": "Added in API 1.23. Requires measurements:write, current community membership and either authorship of the experiment or ownership of its community. Refused with 409 while any measurement reference names a revision; those retained sources are preserved. Otherwise deletes the experiment and all its revisions, releasing their community storage usage. It does not delete measurements or their output." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "responses": + "204": + "description": "The experiment and its unused revisions were deleted." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The caller lacks measurements:write or is a member who is neither the experiment's creator nor the community owner." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The experiment is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "409": + "description": "A measurement reference names this experiment; retained experiment revisions cannot be deleted." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request body exceeds the allowed limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "/community-projects/{id}/measurements": + "get": + "operationId": "listCommunityMeasurements" + "summary": "List measurements shared with a project." + "description": "Added in API 1.23. Requires measurements:read and current community membership. Returns at most 1024 references. Read a reference through the existing measurement and run detail/output APIs; sharing does not copy or extend retained output." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "responses": + "200": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityMeasurementList" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "post": + "operationId": "shareCommunityMeasurement" + "summary": "Share one of your measurements with a project." + "description": "Added in API 1.23. Requires measurements:write, current community membership and ownership of the measurement. Sharing exposes retained configuration, output, provenance, private executor addressing, host platform observations and per-run cost to every current member. Optional experiment_id and experiment_revision must identify a saved revision in this project." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "requestBody": + "required": true + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/ShareCommunityMeasurement" + "responses": + "201": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityMeasurement" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "409": + "description": "The current revision, membership or resource quota conflicts with the request." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "/community-projects/{id}/measurements/{measurement_id}": + "delete": + "operationId": "unshareCommunityMeasurement" + "summary": "Remove a project's measurement reference." + "description": "Added in API 1.23. Requires measurements:write and current membership, then either the account that shared it or the community owner. Personally funded measurements can be unshared. A community-funded measurement stays in its funding project's history and returns 409 instead; expired reservations that admitted no run are cleaned up automatically. Does not delete the underlying measurement or retract copies; another community reference may still authorize reads." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + - "name": "measurement_id" + "in": "path" + "required": true + "schema": + "type": "string" + "responses": + "204": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "409": + "description": "The current revision, membership or resource quota conflicts with the request." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "/communities/{id}/wallet": + "get": + "operationId": "getCommunityWallet" + "summary": "Read the community's shared measurement budget." + "description": "Added in API 1.23. Requires account:read for a scoped credential and current membership. USDC is prepaid money held by the dispatcher after an owner-signed deposit. TEST is bookkeeping against the creator's existing allowance, not money or a new grant. Amounts are exact decimal strings of base units. No private wallet key is returned or stored for the community." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "responses": + "200": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityWallet" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "503": + "description": "The configured payment or allowance mode is unavailable." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "/communities/{id}/wallet/top-ups": + "post": + "operationId": "createCommunityTopUp" + "summary": "Create an owner-approved USDC deposit intent." + "description": "Added in API 1.23. Requires a browser session and the community owner. Available only while chain payments are enabled. The owner must sign the returned normal Sui payment intent with their external wallet; creation alone adds no funds. A confirmed receipt credits the community once. The returned intent is for a deposit and cannot authorize a run. TEST mode cannot be topped up. The supported chain profile is Sui Testnet; unused deposits have no withdrawal endpoint." + "parameters": + - "name": "Debuglet-API-Version" + "in": "header" + "required": false + "schema": + "type": "string" + - "name": "id" + "in": "path" + "required": true + "schema": + "type": "string" + "format": "uuid" + "requestBody": + "required": true + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/CommunityTopUpRequest" + "responses": + "201": + "description": "Request completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/IntentResponse" + "400": + "description": "The request or required API version is invalid." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "401": + "description": "An authenticated account is required, including in the local development profile." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "403": + "description": "The credential scope or community role does not authorize this operation." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "404": + "description": "The resource is absent or is not visible to this account." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "409": + "description": "The current revision, membership or resource quota conflicts with the request." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "413": + "description": "The request exceeds its size limit." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "429": + description: "The account request quota is reached. Retry-After gives seconds to wait." headers: Debuglet-API-Version: description: "Contract version this dispatcher implements." schema: type: "string" - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "500": - description: "Storage/retention could not be checked or committed (internal_error)." - headers: - Debuglet-API-Version: - description: "Contract version this dispatcher implements." + Retry-After: + description: "Integer seconds before another request." schema: type: "string" content: application/json: schema: $ref: "#/components/schemas/Error" + "500": + "description": "The request could not be completed." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" + "503": + "description": "The configured payment or allowance mode is unavailable." + "headers": + "Debuglet-API-Version": + "description": "Contract version this dispatcher implements." + "schema": + "type": "string" + "content": + "application/json": + "schema": + "$ref": "#/components/schemas/Error" components: securitySchemes: bearerAuth: @@ -7872,7 +10187,7 @@ components: nullable: true address_observations: $ref: "#/components/schemas/AddressObservations" - description: "The listing's RIPE Atlas-style addressing fields (see ExecutorResponse) as known at admission. The addresses are recorded for a private executor too, since a result is visible only to the run's owner and operators." + description: "The listing's RIPE Atlas-style addressing fields (see ExecutorResponse) as known at admission. Private executor addresses are retained too. Owners, operators and current members of communities with which the measurement was explicitly shared can read this snapshot." ExecutorList: type: array description: "An empty array when no executor is registered." @@ -8398,7 +10713,20 @@ components: description: "TEST for the local alpha's bookkeeping-only method, or USDC." refund_address: type: "string" - description: "Required when creating a USDC intent: a Sui address, 0x followed by 1 to 64 hexadecimal digits (short addresses are accepted). Missing or invalid values answer 400 invalid_request with field_errors naming refund_address and code invalid_address, before intent or order writes. Disabled chain payments still answer 503 payments_disabled first. TEST does not validate this field and it may be omitted or empty. The quote endpoint does not evaluate this field." + description: "Required when creating a personal USDC intent: a Sui address, 0x followed by 1 to 64 hexadecimal digits (short addresses are accepted). Missing or invalid values answer 400 invalid_request with field_errors naming refund_address and code invalid_address, before intent or order writes. Disabled chain payments still answer 503 payments_disabled first. TEST does not validate this field and it may be omitted or empty. The quote endpoint does not evaluate this field. Community-funded intents ignore it; funds return to the shared budget." + community_project_id: + type: "string" + format: "uuid" + description: "API 1.23. Select a project whose community funds this measurement. Requires current membership. A quote checks membership without reserving funds; an intent atomically reserves the community budget and returns method COMMUNITY. TEST uses the creator allowance and USDC uses confirmed community deposits. The submitting account still owns the run." + community_experiment_id: + type: "string" + format: "uuid" + description: "API 1.23. Optional saved experiment associated with the community-funded measurement; requires community_project_id and community_experiment_revision." + community_experiment_revision: + type: "integer" + format: "int64" + minimum: 1 + description: "The immutable saved revision in the selected community project." retry: $ref: "#/components/schemas/RetryLink" IntentResponse: @@ -8410,7 +10738,10 @@ components: properties: method: type: "string" - description: "The payment method the intent was created for." + description: "TEST, USDC or COMMUNITY. COMMUNITY is already funded by a shared budget and requires no new external-wallet payment." + currency: + type: "string" + description: "Present for COMMUNITY intents; the budget currency (TEST or USDC)." intent: description: "Method-specific intent." oneOf: @@ -8422,7 +10753,7 @@ components: $ref: "#/components/schemas/Quote" DummyIntent: type: object - description: "Intent of the TEST method. It records bookkeeping only and moves no funds." + description: "Submission authorization for TEST or a funded COMMUNITY intent. TEST is bookkeeping only; COMMUNITY has already reserved its declared currency." additionalProperties: false required: - transaction_id @@ -8433,7 +10764,7 @@ components: description: "16 random bytes, hex-encoded." auth_key: type: string - description: "Empty for TEST transactions." + description: "Authorization returned for subsequent submission. It may be empty for an already funded intent; do not log a non-empty value." SuiIntent: type: object additionalProperties: false @@ -9169,7 +11500,7 @@ components: type: "boolean" nullable: true description: "True when the report had outlived its 90 s lifetime at admission." - description: "Added within vantage_point schema 1 (API 1.9). Operator-only host platform detail, recorded here because a result is readable only by the run's owner and operators; GET /executors never lists it. A file written before it omits the field, which reads as null. All fields are null together." + description: "Added within vantage_point schema 1 (API 1.9). Host platform detail retained in admission provenance. The run owner and operators can read it, and explicitly sharing the measurement also exposes it to current community members; GET /executors never lists it. A file written before it omits the field, which reads as null. All fields are null together." HostPlatform: type: "object" nullable: true @@ -9632,6 +11963,9 @@ components: - "cost" - "execution" properties: + can_manage: + type: "boolean" + description: "API 1.23. Whether this caller may perform owner-authorized run mutations. False for a community reader. Older servers omit the field." run_id: type: "string" format: "uuid" @@ -9735,6 +12069,9 @@ components: - "limit" - "offset" "properties": + can_manage: + type: "boolean" + description: "API 1.23. Whether this caller may perform owner-authorized run mutations. False for a community reader. Older servers omit the field." "id": "type": "string" "runs": @@ -9954,3 +12291,361 @@ components: listener_ready: type: "boolean" description: "Dispatcher receipt times of authenticated, bound executor reports. These are not exact executor execution times. Reported exit code is the executor report. TCP endpoint is stored only after actual listener installation and checked against the admission public host/policy; listener_ready requires the latest retained started state before the reserved end, and does not prove external reachability. Retention deletion removes the endpoint. Historical runs without reports have null execution." + + "CommunityNameRequest": + "type": "object" + "additionalProperties": false + "required": + - "name" + "properties": + "name": + "type": "string" + "description": "Trimmed name of 1 to 120 UTF-8 bytes." + "description": + "type": "string" + "description": "At most 2000 UTF-8 bytes; an empty string clears the description." + "Community": + "type": "object" + "additionalProperties": false + "required": + - "id" + - "name" + - "description" + - "role" + - "created_by" + - "created_at" + "properties": + "id": + "type": "string" + "format": "uuid" + "name": + "type": "string" + "description": + "type": "string" + "role": + "type": "string" + "enum": + - "owner" + - "member" + "created_by": + "type": "string" + "format": "uuid" + "created_at": + "type": "string" + "format": "date-time" + "description": "UTC RFC3339 timestamp." + "CommunityList": + "type": "array" + "description": "Communities the caller currently belongs to." + "items": + "$ref": "#/components/schemas/Community" + "CommunityMember": + "type": "object" + "additionalProperties": false + "required": + - "user_id" + - "name" + - "role" + - "joined_at" + "properties": + "user_id": + "type": "string" + "format": "uuid" + "name": + "type": "string" + "role": + "type": "string" + "enum": + - "owner" + - "member" + "joined_at": + "type": "string" + "format": "date-time" + "description": "UTC RFC3339 timestamp." + "CommunityMemberList": + "type": "array" + "description": "At most 128 members." + "items": + "$ref": "#/components/schemas/CommunityMember" + "CommunityInvitation": + "type": "object" + "additionalProperties": false + "required": + - "token" + - "expires_at" + "properties": + "token": + "type": "string" + "description": "Secret one-use token; never log it or place it in a public URL." + "expires_at": + "type": "string" + "format": "date-time" + "description": "UTC RFC3339 timestamp." + "CommunityInvitationAcceptance": + "type": "object" + "additionalProperties": false + "required": + - "token" + "properties": + "token": + "type": "string" + "CommunityProject": + "type": "object" + "additionalProperties": false + "required": + - "id" + - "community_id" + - "name" + - "description" + - "created_by" + - "created_at" + "properties": + "id": + "type": "string" + "format": "uuid" + "community_id": + "type": "string" + "format": "uuid" + "name": + "type": "string" + "description": + "type": "string" + "created_by": + "type": "string" + "format": "uuid" + "created_at": + "type": "string" + "format": "date-time" + "description": "UTC RFC3339 timestamp." + "CommunityProjectList": + "type": "array" + "description": "At most 64 projects." + "items": + "$ref": "#/components/schemas/CommunityProject" + "CreateCommunityExperiment": + "type": "object" + "additionalProperties": false + "required": + - "name" + - "kind" + - "document" + "properties": + description: + type: "string" + description: "Optional experiment notes, at most 4000 UTF-8 bytes." + collection: + type: "string" + description: "Optional trimmed grouping label within the project, at most 120 UTF-8 bytes." + "name": + "type": "string" + "description": "Trimmed name of 1 to 120 UTF-8 bytes." + "kind": + "type": "string" + "enum": + - "workflow" + - "experiment" + "document": + "type": "object" + "additionalProperties": true + "description": "Editable version 1 source in the existing workflow or experiment format, selected by kind. The dispatcher validates its blocks, ports, links and participant contract; this schema does not duplicate the authoring format." + "bundle": + "type": "object" + "additionalProperties": true + "nullable": true + "description": "Optional matching compiled Studio bundle. Includes generated source, WASM and digests; omitted or null means no compiled artifact. A saved bundle avoids requiring a compiler to reuse unchanged source." + "UpdateCommunityExperiment": + "type": "object" + "additionalProperties": false + "required": + - "name" + - "kind" + - "document" + - "revision" + "properties": + description: + type: "string" + description: "Optional experiment notes, at most 4000 UTF-8 bytes." + collection: + type: "string" + description: "Optional trimmed grouping label within the project, at most 120 UTF-8 bytes." + "name": + "type": "string" + "description": "Trimmed name of 1 to 120 UTF-8 bytes." + "kind": + "type": "string" + "enum": + - "workflow" + - "experiment" + "document": + "type": "object" + "additionalProperties": true + "description": "Editable version 1 source in the existing workflow or experiment format, selected by kind. The dispatcher validates its blocks, ports, links and participant contract; this schema does not duplicate the authoring format." + "bundle": + "type": "object" + "additionalProperties": true + "nullable": true + "description": "Optional matching compiled Studio bundle. Includes generated source, WASM and digests; omitted or null means no compiled artifact. A saved bundle avoids requiring a compiler to reuse unchanged source." + "revision": + "type": "integer" + "format": "int64" + "minimum": 1 + "description": "The current saved revision, for an atomic edit conflict check." + "CommunityExperiment": + "type": "object" + "additionalProperties": false + "required": + - "id" + - "project_id" + - "name" + - "kind" + - "revision" + - "created_by" + - "updated_at" + "description": "Metadata in list responses; GET, POST and PUT also return document and an optional bundle." + "properties": + description: + type: "string" + description: "Optional experiment notes, at most 4000 UTF-8 bytes." + collection: + type: "string" + description: "Optional trimmed grouping label within the project, at most 120 UTF-8 bytes." + "id": + "type": "string" + "format": "uuid" + "project_id": + "type": "string" + "format": "uuid" + "name": + "type": "string" + "kind": + "type": "string" + "enum": + - "workflow" + - "experiment" + "revision": + "type": "integer" + "format": "int64" + "minimum": 1 + "created_by": + "type": "string" + "format": "uuid" + "updated_at": + "type": "string" + "format": "date-time" + "description": "UTC RFC3339 timestamp." + "document": + "type": "object" + "additionalProperties": true + "description": "Editable version 1 source in the existing workflow or experiment format, selected by kind. The dispatcher validates its blocks, ports, links and participant contract; this schema does not duplicate the authoring format." + "bundle": + "type": "object" + "additionalProperties": true + "nullable": true + "description": "Optional matching compiled Studio bundle. Includes generated source, WASM and digests; omitted or null means no compiled artifact. A saved bundle avoids requiring a compiler to reuse unchanged source." + "CommunityExperimentList": + "type": "array" + "description": "At most 128 latest revisions; document and bundle are omitted." + "items": + "$ref": "#/components/schemas/CommunityExperiment" + "ShareCommunityMeasurement": + "type": "object" + "additionalProperties": false + "required": + - "measurement_id" + "properties": + "measurement_id": + "type": "string" + "description": "Existing measurement transaction ID." + "experiment_id": + "type": "string" + "format": "uuid" + "experiment_revision": + "type": "integer" + "format": "int64" + "minimum": 1 + "CommunityMeasurement": + "type": "object" + "additionalProperties": false + "required": + - "measurement_id" + - "project_id" + - "shared_by" + - "shared_at" + "properties": + "measurement_id": + "type": "string" + "project_id": + "type": "string" + "format": "uuid" + "shared_by": + "type": "string" + "format": "uuid" + "shared_at": + "type": "string" + "format": "date-time" + "description": "UTC RFC3339 timestamp." + "experiment_id": + "type": "string" + "format": "uuid" + "experiment_revision": + "type": "integer" + "format": "int64" + "minimum": 1 + "CommunityMeasurementList": + "type": "array" + "description": "At most 1024 shared references." + "items": + "$ref": "#/components/schemas/CommunityMeasurement" + "CommunityWallet": + "type": "object" + "additionalProperties": false + "required": + - "enabled" + - "mode" + - "currency" + - "unit" + - "network" + - "funded" + - "reserved" + - "spent" + - "available" + "properties": + "enabled": + "type": "boolean" + "mode": + "type": "string" + "enum": + - "USDC" + - "TEST" + "currency": + "type": "string" + "unit": + "type": "string" + "network": + "type": "string" + "description": "Configured chain network; empty for TEST." + "funded": + "type": "string" + "description": "Exact decimal string of integer base units." + "reserved": + "type": "string" + "description": "Exact decimal string of integer base units." + "spent": + "type": "string" + "description": "Exact decimal string of integer base units." + "available": + "type": "string" + "description": "Exact decimal string of integer base units." + "CommunityTopUpRequest": + "type": "object" + "additionalProperties": false + "required": + - "amount" + - "refund_address" + "properties": + "amount": + "type": "string" + "description": "Positive decimal string of micro-USDC, from 1 to 9007199254740991." + "refund_address": + "type": "string" + "description": "Sui address controlled by the owner; 0x followed by 1 to 64 hexadecimal digits." diff --git a/api/spec.go b/api/spec.go index 9e0cefe7..9c8bd1e8 100644 --- a/api/spec.go +++ b/api/spec.go @@ -15,7 +15,7 @@ var OpenAPI []byte const ( // Version is the HTTP API contract version as major.minor. It equals the // info.version field of OpenAPI. - Version = "1.22" + Version = "1.23" // Major is the compatibility number of Version. Clients that require a // different major version are answered with an explicit incompatibility // error instead of a best-effort response. @@ -48,7 +48,8 @@ const ( // 1.20 added admission and unavailable drain status to account-owned executors. // 1.21 added authenticated historical attribution exports. // 1.22 added owner-managed executor presentation labels. - Minor = 22 + // 1.23 added communities, shared experiments and funded community measurements. + Minor = 23 // VersionHeader carries the contract version a client requires on requests // and the version the dispatcher implements on responses. VersionHeader = "Debuglet-API-Version" diff --git a/docs/README.md b/docs/README.md index 4712039d..aa29e709 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ This directory is the versioned source for the [Debuglet documentation site](htt - [CLI reference](cli.md) — commands, local demo, and scriptable workflows. - [Go client library](client.md) — submit debuglets and read results from an application. - [Reusable and coordinated measurements](measurements.md) — saved profiles, retries and experiments across executors. +- [Communities](communities.md) — shared projects, editable experiments, results and group funding. - [Portable results](results.md) — export retained output and admission facts for offline analysis. - [Controlled latency experiment](research/latency-evaluation.md) — compare native and WASM probes under known local network faults. - [Write a debuglet](debuglets.md) — execution model and Go authoring interface. diff --git a/docs/api.md b/docs/api.md index 9508aa50..2ceb21ec 100644 --- a/docs/api.md +++ b/docs/api.md @@ -34,7 +34,7 @@ A `TEST` intent has no payment backend: its transaction row is the intent, writt Every intent records the name of the rule that priced it in the `pricing_rule` column of its transaction; the rule above is `bw-s-ceil-ms-v1`, and a transaction written before the column existed carries an empty name and was priced by the rule of the release that wrote it. A `USDC` intent is a local record of the price, the auth key and the addresses the payer uses, created without any chain call, so an intent that fails leaves nothing external to reconcile. -Creating a `USDC` intent requires `refund_address` to be a Sui address: `0x` followed by one to 64 hexadecimal digits; short addresses such as `0x1` are accepted. An omitted, empty or malformed address returns HTTP 400 `invalid_request`, with `field_errors` naming `refund_address` and code `invalid_address`, before the intent or any order is written. While blockchain payments are disabled, the method still returns HTTP 503 `payments_disabled` first. `TEST` does not validate this field and accepts an omitted, empty or arbitrary value. `POST /payment/quote` does not evaluate `refund_address`. +Creating a personal `USDC` intent requires `refund_address` to be a Sui address: `0x` followed by one to 64 hexadecimal digits; short addresses such as `0x1` are accepted. An omitted, empty or malformed address returns HTTP 400 `invalid_request`, with `field_errors` naming `refund_address` and code `invalid_address`, before the intent or any order is written. While blockchain payments are disabled, the method still returns HTTP 503 `payments_disabled` first. `TEST` does not validate this field and accepts an omitted, empty or arbitrary value. `POST /payment/quote` does not evaluate `refund_address`. A community-funded intent ignores it: refunds return to that community's prepaid budget. ## Quotes and economics (API 1.15) @@ -171,6 +171,90 @@ with a new schedule and payment intent. Template references name an immutable version and matching program digest. Existing runs are independent of later profile edits or deletion. See OpenAPI for field and account limits. +## Communities (API 1.23) + +Communities group individually authenticated accounts. They own projects, +versioned workflow or experiment source, and references to shared measurements. +They do not create a shared login or give members dispatcher operator authority. +See the [community guide](communities.md) for the console flow, funding model and +disclosure before sharing. + +| Routes | Purpose | +| --- | --- | +| `GET/POST /communities`, `GET/PATCH /communities/{id}` | List, create, read or rename a community | +| `GET /communities/{id}/members` | List current members | +| `POST /communities/{id}/invitations`, `POST /community-invitations/accept` | Create and accept a one-use, seven-day invitation | +| `DELETE /communities/{id}/members/{user_id}` | Owner removes a member; the creator cannot be removed | +| `GET/POST /communities/{id}/projects`, `GET/PATCH /community-projects/{id}` | Read, create or rename shared projects | +| `GET/POST /community-projects/{id}/experiments` | List experiment metadata or save source and an optional matching bundle | +| `GET/PUT/DELETE /community-experiments/{id}` | Read source, save an immutable next revision, or delete an unused experiment | +| `GET/POST /community-projects/{id}/measurements` | List references or explicitly share an owned measurement | +| `DELETE /community-projects/{id}/measurements/{measurement_id}` | Sharer or community owner removes the reference | +| `GET /communities/{id}/wallet`, `POST /communities/{id}/wallet/top-ups` | Read the shared budget or create an owner-signed USDC deposit intent | + +All routes require an authenticated account, even in the local development +profile. Membership is checked on each request; hidden resources return 404. +Community creation and renaming, membership management and top-ups require a +browser session. Scoped API credentials can read community work with +`measurements:read`, read the wallet with `account:read`, and change projects, +experiments and measurement references with `measurements:write`. Cookie +mutations require the normal CSRF proof. + +Lists are JSON arrays bounded by the resource quotas in the community guide. +Community IDs are UUIDs; measurement IDs retain their existing transaction ID +format. Community timestamps are UTC RFC3339 strings. Names use 1–120 trimmed +UTF-8 bytes and descriptions at most 2000 bytes. + +Experiment creation takes `{name, kind, document, bundle?, description?, collection?}`, where `kind` is +`workflow` or `experiment` and the document uses the existing authoring format. +Responses include the saved `revision`. Updates additionally supply the current +revision; a stale value returns 409 without changing the record. +`GET /community-experiments/{id}?revision=N` reads an immutable historical +revision. Lists omit the document and bundle. Omitting the bundle on an update +removes it from the new revision. The entire save request is limited to 24 MiB; +workflow source remains limited to 1 MiB and experiment source to 4 MiB. +Experiment descriptions use at most 4000 UTF-8 bytes and trimmed collection +labels at most 120 bytes. +`DELETE /community-experiments/{id}` requires current membership and either the +experiment's creator or the community owner. It returns 204 after deleting the +experiment and all its revisions, freeing their storage usage. A measurement +reference to any revision prevents deletion with 409; another member receives +403 and a non-member receives 404. Measurement records and output are unchanged. + +Sharing takes `{measurement_id, experiment_id?, experiment_revision?}`. The +optional experiment fields must name a revision in that project. It grants +current members read access through `GET /measurements/{id}` and +`GET /debuglet/{id}/detail`, `/state`, `/logs`, `/result`, `/recovery` and +`/cancellation`. Reads include the original configuration, retained output, +provenance and per-run costs, including private executor addressing and host +platform observations when recorded. They do not expose payment authorization +keys, refund wallets or private payment history. Sharing never grants run +mutation authority: `can_manage` on measurement and run details is false for a +community reader. Existing limits, output finality and payload tombstones apply. + +For a new community-funded run, send `community_project_id` in the normal +payment quote and intent request. The quote checks membership and prices the +request without reserving funds. Creating an intent atomically reserves the +community's budget and returns `method: "COMMUNITY"`, its `currency`, a +submission `intent` and the confirmed `quote`. An already-recorded retry may +omit the quote, as in the personal retry flow. Submit with the returned +authorization using the normal debuglet API; no additional external-wallet +payment is required for that run. The submitting member remains the run owner. +Insufficient funds returns 409, without creating the intent. Personal payment +requests that omit the project retain their existing behavior. +Optional `community_experiment_id` and `community_experiment_revision` associate +the new measurement with a saved revision in that project. + +USDC top-ups take `{amount, refund_address}`, where `amount` is an exact positive +decimal string of micro-USDC, at most 9007199254740991. Only the community owner creates and signs the +returned Sui payment intent. A deposit intent cannot authorize a measurement; +only a confirmed receipt adds the deposit once. Deposits are funds held by the +dispatcher, not permission to debit the owner's external wallet. In TEST mode, +the community uses the creator's existing allowance; there is no top-up or +automatic grant, and disabled allowances disable community spending. +The supported chain profile is Sui Testnet; there is no endpoint to withdraw +unused community deposits. + ## Measurement history API 1.12 adds account-owned `GET /measurements` and `GET /measurements/{id}`. diff --git a/docs/communities.md b/docs/communities.md new file mode 100644 index 00000000..0bcfc8b0 --- /dev/null +++ b/docs/communities.md @@ -0,0 +1,166 @@ +# Work together in a community + +A community gives a research group a shared workspace for projects, editable +experiments and measurement results. Everyone signs in with their own account. +The person who creates the community owns it, invites members and funds its +shared measurement budget. + +Projects organize related work. A saved experiment contains either one workflow +or a coordinated experiment with several participants. Measurements are actual +runs, with their original executor choices, configuration, output and provenance. +Saving an experiment does not execute it. + +## Set up a group + +Open **Communities** in the console and choose **Create community**, then create +a project for an investigation or study. The **Projects**, **People** and +**Funding** tabs keep shared work, membership and the budget together. Under +**People**, the owner can choose **Invite someone** and send the link privately. +The recipient signs in with their own account, then chooses **Join community**. + +An invitation expires after seven days and can be used once. It is a credential +that lets its recipient join the group, so do not publish it in a public issue or +repository. Owners can remove members. A removed member loses subsequent access +to shared work and cannot reserve new community spending; previously downloaded +copies cannot be recalled. + +| Action | Member | Community owner | +| --- | --- | --- | +| Read shared projects, experiments and results | Yes | Yes | +| Create or edit projects and experiments | Yes | Yes | +| Delete an unused saved experiment | Their own experiment | Any unused experiment | +| Share a personally owned measurement | Yes | Yes | +| Submit using the available community budget | Yes | Yes | +| Remove a personally funded measurement reference | Their own share | Any personal share | +| Rename the community, invite or remove members | No | Yes | +| Deposit money into the community budget | No | Yes | + +Community ownership does not make someone a dispatcher operator or give them +control over another member's runs. The creator remains the owner; ownership +transfer is not part of this interface. + +## Save and reuse experiments + +Choose **Save to community** in a workflow or experiment builder, select the +community project and save the editable source. **Include compiled program** +also saves its matching bundle. Members can then inspect the blocks, choose +their own executors and review a new measurement. +Optional collection labels group experiments within a project; descriptions +record the question or procedure being studied. Open an experiment to download +its source or bundle and see the measurements associated with its revisions. + +A source-only experiment is editable but still needs compilation before it can +run. A saved compiled bundle can be reused without a compiler while its source +remains unchanged. Editing the source invalidates that compiled artifact; supply +a matching rebuilt bundle to run the edited version. The hosted console does +not compile source. See [workflow authoring](development/workflow-authoring.md) +for local compilation and bundle import. + +**Save new revision** updates an existing shared experiment; **Save a copy** +creates another experiment. Each revision stays immutable. If two members edit the same revision, +the second save is refused as a conflict rather than overwriting the first. +Reload the latest revision, review the difference and save again. A measurement +reference can name the exact experiment revision used. + +The experiment's creator or community owner can delete an unused saved experiment +to free the storage occupied by all its revisions. Deletion is refused while a +measurement reference names any revision, preserving the source linked to that +result. Deleting an experiment does not delete measurements or their output. + +Bundles contain executable programs. The dispatcher checks source structure, +generated-source consistency and hashes, but those checks cannot prove that +arbitrary uploaded WASM was built from its claimed source. Review the publisher +and rebuild unfamiliar source before using it. + +## Share measurements deliberately + +Personal measurement history stays personal. Choose **Share with community** +on an existing measurement, or select a community project when submitting a new run. +The project collects references to those measurements; it does not create a +second copy of their output or change their original submitting account. + +Sharing exposes the complete retained measurement to current community members: +program arguments, destination policy, output, timing, per-run cost and admission +provenance. That provenance can include private executor addresses and host +platform observations. Check what the measurement contains before sharing it. +Session credentials, payment authorization keys and private payment history are +not shared. + +Members can inspect a shared group's participants, read output and export +results. They cannot cancel another person's run, delete its payload or reuse +its payment authorization. A personally funded measurement can be unshared by +its sharer or the community owner. Removing that reference removes its sharing +grant; another project reference can still grant access. + +Community-funded measurements remain in the funding project's history and cannot +be unshared; attempting to remove their references returns 409. Expired +reservations that admitted no run are cleaned up automatically. Retention limits +and payload deletion continue to apply to the original measurement, so a +reference does not guarantee that output remains available forever. + +## Pay from the community budget + +The owner funds a community so members can submit under its umbrella without +asking the owner to sign each measurement. The **Funding** tab shows funded, +reserved, spent and available amounts. A member selects the project before +reviewing the measurement's quote, then chooses **Run with community funds**. + +In a deployment with chain payments enabled, the owner creates a USDC top-up +and approves its payment in their external Sui wallet. Creating a top-up or +knowing a wallet address is not permission to spend from that wallet. Only a +confirmed payment credits the community budget, and the same receipt is credited +once. + +The supported chain profile is Sui Testnet. This interface does not yet withdraw +unused deposits. Use test assets for this flow; it is not a production custody +service for real funds. + +Deposited funds are held by the dispatcher's payment account. Debuglet does not +store the owner's wallet private key. Members spend the deposited budget; the +dispatcher reserves the full quoted amount atomically before returning an intent, +refuses insufficient funds, and settles or returns the reservation through the +normal run lifecycle. The submitting member remains recorded. This is a shared +budget without individual member spending caps, so invite only members allowed +to use it. See [chain payments](operations/payments.md) for network configuration +and the supported Testnet profile. + +In a TEST deployment, the budget draws on the creator's existing TEST allowance. +TEST units are bookkeeping credits, not money. Creating a community grants no +credits, and TEST top-ups are unavailable. If allowances are disabled, community +funding is unavailable. The same allowance covers the creator's personal and +community spending, so separate communities are not separate TEST grants. A run +that executes consumes TEST allowance even if it fails, following the existing +allowance rules; unadmitted expired reservations are released. Personal +measurements keep their existing payment flow. + +## Limits and deployment + +| Resource | Limit | +| --- | --- | +| Communities owned by one account | 16 | +| Communities joined by one account | 128 | +| Members in one community | 128 | +| Outstanding invitations in one community | 8 | +| Projects in one community | 64 | +| Experiments in one project | 128 | +| Revisions of one experiment | 32 | +| Measurement references in one project | 1024 | +| Source and optional bundle in one save request | 24 MiB | +| Retained experiment revision bytes in one community | 256 MiB | + +Workflow source retains its 1 MiB limit and coordinated experiment source its +4 MiB limit. Community and project names are trimmed and use 1–120 UTF-8 bytes; +descriptions use at most 2000 bytes. +Experiment descriptions use at most 4000 bytes and collection labels at most +120 bytes. + +The collaboration API requires HTTP contract 1.23 and the matching console. +Upgrade the dispatcher database explicitly before starting the new dispatcher, +using the deployment's [upgrade and backup procedure](operations/configuration.md#stored-state). +Community records and funding records belong to dispatcher state; no executor +schema or control-protocol change is needed for collaboration itself. +Installing updated source or a console build does not publish a package or update +an existing deployment. + +The [HTTP guide](api.md#communities-api-123) describes the routes and access +rules. The [OpenAPI document](../api/openapi.yaml) is the wire contract. diff --git a/docs/development/workflow-authoring.md b/docs/development/workflow-authoring.md index a50a198b..89d12345 100644 --- a/docs/development/workflow-authoring.md +++ b/docs/development/workflow-authoring.md @@ -5,6 +5,9 @@ Choose **Workflow builder** to write one executor's program. Choose workflow. The same graph can be edited visually, authored by an MCP client or compiled from the command line with `dbl-studio`. +Use [communities](../communities.md) to save editable source and compiled bundles +in a shared project, retain revisions and collect the resulting measurements. + Conditions, calculations and repeats execute inside the compiled WASI program. The dispatcher coordinates experiment readiness and stores results; it does not compile source or orchestrate individual blocks. See the diff --git a/docs/operations/configuration.md b/docs/operations/configuration.md index 4352d58a..2262b5a4 100644 --- a/docs/operations/configuration.md +++ b/docs/operations/configuration.md @@ -212,10 +212,11 @@ OAuth, external TLS and SCION state need the deployment's complete backup plan; a database snapshot alone does not include every required credential or config. Never start original and restored copies with the same identity simultaneously. -Dispatcher schema 31 and executor schema 9 are the current schema boundaries. +Dispatcher schema 33 and executor schema 9 are the current schema boundaries. Dispatcher schema 29 preserves authenticated schedule history; schema 30 and executor schema 9 add durable aggregate egress grants. Dispatcher schema 31 adds owner-managed executor presentation labels. +Dispatcher schemas 32 and 33 add community collaboration and shared funding. Recognized older databases require the explicit upgrade below. Dispatcher schemas below 3 and executor schemas below 2 lose recorded `debuglets` and `debuglet_logs` on upgrade and require explicit acceptance. Preserved paid rows diff --git a/docs/operations/executor-discovery.md b/docs/operations/executor-discovery.md index ad78372e..5dac0fd0 100644 --- a/docs/operations/executor-discovery.md +++ b/docs/operations/executor-discovery.md @@ -495,6 +495,8 @@ its own opt-out, `location_opt_out`, independent of this one; the Restart the executor to publish a changed preference. Executors that predate the setting are public. The admission snapshot records the same fields, including a private executor's addresses, as `provenance.vantage_point.addressing`. +Owners who explicitly share a measurement with a community also share this +snapshot with its current members. `dbl nodes` shows the addresses in its `ADDRESS_V4` and `ADDRESS_V6` columns (`private` when withheld, `-` when unknown); `--output json` carries every @@ -607,10 +609,11 @@ changes host state or contacts a time source. of readiness are reported and logged but do not refuse admission. `dbl doctor` runs the same check locally. - Host platform: OS, architecture, kernel release, logical CPUs, total memory - and build version. This is operator-only data and never appears in + and build version. This data never appears in public `GET /executors`. There is no live operator view of it yet; it is recorded in - the admission snapshot `provenance.vantage_point.platform`, which only the - run's owner and operators can read. + the admission snapshot `provenance.vantage_point.platform`, readable by the + run's owner, operators and current members of communities with which the + measurement was explicitly shared. A malformed clock, platform, ICMP state or reason leaves only that field unknown; the executor stays listed with the rest of its report. Results record @@ -680,6 +683,7 @@ explicit `stale` flag. Public views omit raw addresses, endpoints and the SCION host. Established operators can see these details; result owners receive them in the immutable admission snapshot. Address disagreements are reported without silently changing the advertised address. Names are not resolved for comparison. +Explicit community sharing includes this snapshot. For a configured check, admission requires a fresh successful observation when a policy asks for the corresponding literal address family or TCP/UDP listener. diff --git a/docs/operations/payments.md b/docs/operations/payments.md index 3c08a04e..c82194d0 100644 --- a/docs/operations/payments.md +++ b/docs/operations/payments.md @@ -4,6 +4,15 @@ This page describes how a dispatcher with chain payments enabled sends money: executor payouts and buyer refunds. TEST payments move no funds and write no receipt or transfer records. While `[sui] disabled = true`, nothing here runs. +For [community funding](../communities.md#pay-from-the-community-budget), the +owner first signs a USDC deposit. Members then reserve that prepaid balance: +their intents return `method: "COMMUNITY"` and require no individual wallet +payment. Failed USDC orders return funds to that community balance instead of +sending a buyer refund transfer. TEST communities share their creator's existing +allowance. The tables below describe personal payment intents unless stated +otherwise. Community deposits currently support Sui Testnet and have no +withdrawal interface. + ## Modes and what each records Chain payments are switched by `[sui] disabled`. Every shipped configuration diff --git a/docs/results.md b/docs/results.md index d18b6596..91395066 100644 --- a/docs/results.md +++ b/docs/results.md @@ -20,7 +20,9 @@ result, err = client.ReadResult(file) ``` `GET /debuglet/{id}/result` is available from HTTP API 1.8. Access follows the -same ownership rules as logs. The standalone file identifies itself with +same read rules as logs: the owner, an operator, or a current community member +when the measurement was explicitly [shared](communities.md#share-measurements-deliberately). +The standalone file identifies itself with `"format":"debuglet-result"` and `"version":"1.1"`; this file version is independent of the HTTP API version. The reader accepts versions 1.0, 1.1 and 1.2 and rejects other versions, malformed records and inconsistent run, node or attempt @@ -76,8 +78,8 @@ claim it. Older readers reject 1.2 rather than discarding the relationship. `error_bound_ns`, `readiness` and `reason`; and `platform`, the host's `os`, `arch`, `kernel_release`, `cpus`, `memory_bytes` and `build_version`. `clock` and `platform` carry `source`, `observed_at` and `stale` like `scion_isd_as`. - Platform detail is operator-only elsewhere and appears here because a result - is readable only by its owner and operators. Earlier files omit these fields, + Platform detail is withheld from public discovery. Explicitly sharing a + measurement exposes this retained detail to community members too. Earlier files omit these fields, which reads as `null` (or an empty reason). - Also within schema 1, `addressing`: the [probe addresses](operations/executor-discovery.md#probe-addresses) of the listing at admission, `is_public`, the dispatcher-observed diff --git a/docs/security.md b/docs/security.md index 3a97d555..1c88b83a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -32,7 +32,10 @@ plaintext state on their hosts, including credentials and workload data. Managed deployments use server-issued sessions, including the configured GitHub OAuth login. Protected routes require a valid session and enforce object -ownership or an operator role. Cookie-authenticated changes require CSRF proof. +ownership or an operator role. Explicitly sharing a measurement grants current +community members read access to its configuration, output and full admission +provenance, including private executor addresses and host observations. Sharing +does not grant run mutation or private payment access. Cookie-authenticated changes require CSRF proof. A user UUID alone is not a credential. The [API reference](api.md) and [OpenAPI contract](../api/openapi.yaml) define the route-level decisions. diff --git a/docs/versions.md b/docs/versions.md index 2ac8b084..d3430b77 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -24,7 +24,7 @@ establish compatibility for later changes. | --- | --- | --- | | Platform | Linux amd64 on the `ubuntu-24.04` CI runner and the pinned CI tools image | [CI definition](../.github/workflows/ci.yml), [CI images](development/ci-images.md). Other host/architecture combinations have no native package support. | | Packages | Same-candidate full bundle and separate CLI, dispatcher and executor packages | Package lane installs twice, verifies each role inventory and runs `--help`; full-bundle runtime checks run in the demo/local lanes. Split packages exist in source/CI; published v0.2.0 contains only the full bundle. | -| CLI and Go client | Same-source clients and dispatcher; HTTP contract 1.22 | Test, demo and local lanes. Use `dbl version --server` to inspect the selected server's contract; a displayed binary version does not negotiate API features. | +| CLI and Go client | Same-source clients and dispatcher; HTTP contract 1.23 | Test, demo and local lanes. Use `dbl version --server` to inspect the selected server's contract; a displayed binary version does not negotiate API features. | | Dispatcher and executor | Same-package peers; control protocol 3 with binding and lease enforcement. Published v0.2.0 executor against the candidate dispatcher in the loopback TEST fixture | Local and compatibility lanes. The released-peer fixture checks registration, work and reconnect; it does not establish mixed-release TLS/enrollment or new optional features. | | Guests | `debuglet-go-wasi-imports-v1`, frozen compatibility guest and packaged Go samples | [Guest compatibility gate](debuglets.md#compatibility), compatibility lane. C/Rust guest packages remain experimental. | | State | State created by the same package; recognized historical SQLite schemas upgraded explicitly | [State reference](operations/configuration.md#stored-state), storagecheck tests. This is not a cross-package foreground-state migration promise. | @@ -111,6 +111,15 @@ Dispatcher schema 24 stores the executor status history in `probe_status` and `p API 1.19 adds recorded destination policies: `PATCH /destination` accepts `denied`, `reason` and `expires_at`, and `GET /destinations` lists the current policy of each destination. Dispatcher schema 26 stores their append-only history; upgrade explicitly before starting this build. The control protocol gains the optional `DestinationLimit.denied` field without a version change; an executor that does not know it still applies the zero limit sent with it, and an executor that knows it closes the active sockets to the destination. +API 1.23 adds [communities](communities.md), shared projects and experiment +revisions, explicit measurement sharing and a community-funded submission option. +Current membership grants read access to shared measurement details and output; +mutation ownership and private payment access are unchanged. `can_manage` on +measurement and run details lets clients hide owner-only actions. Older servers +omit it. Dispatcher schemas 32 and 33 store collaboration and shared funding; +upgrade explicitly before starting this build. Executor schema and the control +protocol are unchanged by this feature. + API 1.20 adds `admission` and `drain_status` to account-owned executor inventory. Admission reports the current control readiness and dispatcher maintenance. Drain status is explicitly unknown because the existing control protocol does diff --git a/internal/dispatcher/database/allowances.sql.go b/internal/dispatcher/database/allowances.sql.go index eaa5b169..79686534 100644 --- a/internal/dispatcher/database/allowances.sql.go +++ b/internal/dispatcher/database/allowances.sql.go @@ -18,13 +18,15 @@ SELECT CAST(COALESCE((SELECT SUM(o.price) FROM debuglet_order o JOIN transaction_users tu ON tu.transaction_id = o.transaction_id JOIN transactions t ON t.id = o.transaction_id - WHERE tu.user_id = u.id AND t.method = 'TEST' + LEFT JOIN community_funding f ON f.transaction_id = o.transaction_id + WHERE COALESCE(f.sponsor_user_id, tu.user_id) = u.id AND t.method = 'TEST' AND EXISTS (SELECT 1 FROM order_settlements s WHERE s.transaction_id = o.transaction_id AND s.order_id = o.order_id)), 0) AS INTEGER) AS consumed, CAST(COALESCE((SELECT SUM(o.price) FROM debuglet_order o JOIN transaction_users tu ON tu.transaction_id = o.transaction_id JOIN transactions t ON t.id = o.transaction_id - WHERE tu.user_id = u.id AND t.method = 'TEST' AND t.status = ?1 + LEFT JOIN community_funding f ON f.transaction_id = o.transaction_id + WHERE COALESCE(f.sponsor_user_id, tu.user_id) = u.id AND t.method = 'TEST' AND t.status = ?1 AND NOT EXISTS (SELECT 1 FROM order_settlements s WHERE s.transaction_id = o.transaction_id AND s.order_id = o.order_id)), 0) AS INTEGER) AS reserved FROM users u @@ -186,7 +188,8 @@ UPDATE transactions SET status = ?1 WHERE status = ?2 AND method = 'TEST' AND expires_at < ?3 AND id IN (SELECT tu.transaction_id FROM transaction_users tu - JOIN users u ON u.id = tu.user_id WHERE u.uuid = ?4) + LEFT JOIN community_funding f ON f.transaction_id=tu.transaction_id + JOIN users u ON u.id = COALESCE(f.sponsor_user_id, tu.user_id) WHERE u.uuid = ?4) AND NOT EXISTS ( SELECT 1 FROM debuglet_order WHERE debuglet_order.transaction_id = transactions.id AND debuglet_id IS NOT NULL @@ -225,7 +228,8 @@ WHERE u.uuid = ?6 - COALESCE((SELECT SUM(o.price) FROM debuglet_order o JOIN transaction_users tu ON tu.transaction_id = o.transaction_id JOIN transactions t ON t.id = o.transaction_id - WHERE tu.user_id = u.id AND t.method = 'TEST' + LEFT JOIN community_funding f ON f.transaction_id = o.transaction_id + WHERE COALESCE(f.sponsor_user_id, tu.user_id) = u.id AND t.method = 'TEST' AND (t.status = ?4 OR EXISTS (SELECT 1 FROM order_settlements s WHERE s.transaction_id = o.transaction_id AND s.order_id = o.order_id))), 0) >= ?2 diff --git a/internal/dispatcher/database/communities.sql.go b/internal/dispatcher/database/communities.sql.go new file mode 100644 index 00000000..d54bd42c --- /dev/null +++ b/internal/dispatcher/database/communities.sql.go @@ -0,0 +1,891 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: communities.sql + +package database + +import ( + "context" + "database/sql" + + "github.com/google/uuid" +) + +const addCommunityCreator = `-- name: AddCommunityCreator :exec +INSERT INTO community_members(community_id, user_id, joined_at) +SELECT id, created_by, created_at FROM communities WHERE id = ? +` + +func (q *Queries) AddCommunityCreator(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, addCommunityCreator, id) + return err +} + +const advanceCommunityExperiment = `-- name: AdvanceCommunityExperiment :execrows +UPDATE community_experiments SET revision = revision + 1 +WHERE community_experiments.id = ?1 AND community_experiments.revision = ?2 AND community_experiments.revision < 32 +AND EXISTS (SELECT 1 FROM community_projects p JOIN community_members m ON m.community_id = p.community_id + JOIN users u ON u.id = m.user_id WHERE p.id = community_experiments.project_id AND u.uuid = ?3) +` + +type AdvanceCommunityExperimentParams struct { + ID string + Revision int64 + UserUuid uuid.UUID +} + +func (q *Queries) AdvanceCommunityExperiment(ctx context.Context, arg AdvanceCommunityExperimentParams) (int64, error) { + result, err := q.db.ExecContext(ctx, advanceCommunityExperiment, arg.ID, arg.Revision, arg.UserUuid) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const canManageMeasurement = `-- name: CanManageMeasurement :one +SELECT EXISTS(SELECT 1 FROM debuglets first_run WHERE first_run.transaction_id = ?1) +AND NOT EXISTS(SELECT 1 FROM debuglets d WHERE d.transaction_id = ?1 + AND NOT EXISTS(SELECT 1 FROM debuglet_users du JOIN users u ON u.id = du.user_id + WHERE du.debuglet_id = d.id AND u.uuid = ?2)) +` + +type CanManageMeasurementParams struct { + BatchID string + UserUuid uuid.UUID +} + +func (q *Queries) CanManageMeasurement(ctx context.Context, arg CanManageMeasurementParams) (sql.NullBool, error) { + row := q.db.QueryRowContext(ctx, canManageMeasurement, arg.BatchID, arg.UserUuid) + var column_1 sql.NullBool + err := row.Scan(&column_1) + return column_1, err +} + +const canReadCommunityDebuglet = `-- name: CanReadCommunityDebuglet :one +SELECT EXISTS(SELECT 1 FROM debuglets d JOIN community_measurements cm ON cm.measurement_id = d.transaction_id +JOIN community_projects p ON p.id = cm.project_id JOIN community_members m ON m.community_id = p.community_id +JOIN users u ON u.id = m.user_id WHERE d.uuid = ?1 AND u.uuid = ?2) +` + +type CanReadCommunityDebugletParams struct { + RunUuid uuid.UUID + UserUuid uuid.UUID +} + +func (q *Queries) CanReadCommunityDebuglet(ctx context.Context, arg CanReadCommunityDebugletParams) (bool, error) { + row := q.db.QueryRowContext(ctx, canReadCommunityDebuglet, arg.RunUuid, arg.UserUuid) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const consumeCommunityInvitation = `-- name: ConsumeCommunityInvitation :one +DELETE FROM community_invitations WHERE token_hash = ?1 AND expires_at > ?2 +AND (SELECT count(*) FROM community_members WHERE community_id = community_invitations.community_id) < 128 +AND (SELECT count(*) FROM community_members WHERE user_id = (SELECT id FROM users WHERE uuid = ?3)) < 128 +RETURNING community_id +` + +type ConsumeCommunityInvitationParams struct { + TokenHash []byte + Now int64 + UserUuid uuid.UUID +} + +func (q *Queries) ConsumeCommunityInvitation(ctx context.Context, arg ConsumeCommunityInvitationParams) (string, error) { + row := q.db.QueryRowContext(ctx, consumeCommunityInvitation, arg.TokenHash, arg.Now, arg.UserUuid) + var community_id string + err := row.Scan(&community_id) + return community_id, err +} + +const createCommunity = `-- name: CreateCommunity :execrows +INSERT INTO communities(id, name, description, created_by, created_at) +SELECT ?1, ?2, ?3, u.id, ?4 +FROM users u WHERE u.uuid = ?5 +AND (SELECT count(*) FROM communities WHERE created_by = u.id) < 16 +` + +type CreateCommunityParams struct { + ID string + Name string + Description string + CreatedAt int64 + UserUuid uuid.UUID +} + +func (q *Queries) CreateCommunity(ctx context.Context, arg CreateCommunityParams) (int64, error) { + result, err := q.db.ExecContext(ctx, createCommunity, + arg.ID, + arg.Name, + arg.Description, + arg.CreatedAt, + arg.UserUuid, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const createCommunityExperiment = `-- name: CreateCommunityExperiment :execrows +INSERT INTO community_experiments(id, project_id, revision, created_by) +SELECT ?1, p.id, 1, m.user_id FROM community_projects p +JOIN community_members m ON m.community_id = p.community_id JOIN users u ON u.id = m.user_id +WHERE p.id = ?2 AND u.uuid = ?3 +AND (SELECT count(*) FROM community_experiments WHERE project_id = p.id) < 128 +` + +type CreateCommunityExperimentParams struct { + ID string + ProjectID string + UserUuid uuid.UUID +} + +func (q *Queries) CreateCommunityExperiment(ctx context.Context, arg CreateCommunityExperimentParams) (int64, error) { + result, err := q.db.ExecContext(ctx, createCommunityExperiment, arg.ID, arg.ProjectID, arg.UserUuid) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const createCommunityInvitation = `-- name: CreateCommunityInvitation :execrows +INSERT INTO community_invitations(token_hash, community_id, expires_at) +SELECT ?1, c.id, ?2 FROM communities c JOIN users u ON u.id = c.created_by +WHERE c.id = ?3 AND u.uuid = ?4 +AND (SELECT count(*) FROM community_invitations i WHERE i.community_id = c.id AND i.expires_at > ?5) < 8 +` + +type CreateCommunityInvitationParams struct { + TokenHash []byte + ExpiresAt int64 + ID string + UserUuid uuid.UUID + Now int64 +} + +func (q *Queries) CreateCommunityInvitation(ctx context.Context, arg CreateCommunityInvitationParams) (int64, error) { + result, err := q.db.ExecContext(ctx, createCommunityInvitation, + arg.TokenHash, + arg.ExpiresAt, + arg.ID, + arg.UserUuid, + arg.Now, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const createCommunityProject = `-- name: CreateCommunityProject :execrows +INSERT INTO community_projects(id, community_id, name, description, created_by, created_at) +SELECT ?1, m.community_id, ?2, ?3, m.user_id, ?4 +FROM community_members m JOIN users u ON u.id = m.user_id +WHERE m.community_id = ?5 AND u.uuid = ?6 +AND (SELECT count(*) FROM community_projects WHERE community_id = m.community_id) < 64 +` + +type CreateCommunityProjectParams struct { + ID string + Name string + Description string + CreatedAt int64 + CommunityID string + UserUuid uuid.UUID +} + +func (q *Queries) CreateCommunityProject(ctx context.Context, arg CreateCommunityProjectParams) (int64, error) { + result, err := q.db.ExecContext(ctx, createCommunityProject, + arg.ID, + arg.Name, + arg.Description, + arg.CreatedAt, + arg.CommunityID, + arg.UserUuid, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const deleteCommunityExperiment = `-- name: DeleteCommunityExperiment :execrows +DELETE FROM community_experiments WHERE community_experiments.id = ?1 +AND EXISTS (SELECT 1 FROM community_projects p JOIN communities c ON c.id = p.community_id + JOIN community_members m ON m.community_id = c.id JOIN users u ON u.id = m.user_id + WHERE p.id = community_experiments.project_id AND u.uuid = ?2 + AND (community_experiments.created_by = u.id OR c.created_by = u.id)) +AND NOT EXISTS (SELECT 1 FROM community_measurements cm WHERE cm.experiment_id = community_experiments.id) +` + +type DeleteCommunityExperimentParams struct { + ID string + UserUuid uuid.UUID +} + +func (q *Queries) DeleteCommunityExperiment(ctx context.Context, arg DeleteCommunityExperimentParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteCommunityExperiment, arg.ID, arg.UserUuid) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getCommunity = `-- name: GetCommunity :one +SELECT c.id, c.name, c.description, c.created_by, c.created_at, creator.uuid AS creator_uuid, CAST(CASE WHEN c.created_by = m.user_id THEN 'owner' ELSE 'member' END AS TEXT) AS role +FROM communities c JOIN community_members m ON m.community_id = c.id +JOIN users u ON u.id = m.user_id JOIN users creator ON creator.id = c.created_by +WHERE c.id = ?1 AND u.uuid = ?2 +` + +type GetCommunityParams struct { + ID string + UserUuid uuid.UUID +} + +type GetCommunityRow struct { + ID string + Name string + Description string + CreatedBy int64 + CreatedAt int64 + CreatorUuid uuid.UUID + Role string +} + +func (q *Queries) GetCommunity(ctx context.Context, arg GetCommunityParams) (GetCommunityRow, error) { + row := q.db.QueryRowContext(ctx, getCommunity, arg.ID, arg.UserUuid) + var i GetCommunityRow + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.CreatedBy, + &i.CreatedAt, + &i.CreatorUuid, + &i.Role, + ) + return i, err +} + +const getCommunityExperiment = `-- name: GetCommunityExperiment :one +SELECT e.id, e.project_id, r.revision, r.name, r.description, r.collection, r.kind, r.updated_at, r.document, r.bundle, creator.uuid AS creator_uuid +FROM community_experiments e JOIN community_experiment_revisions r ON r.experiment_id = e.id + AND r.revision = CASE WHEN CAST(?1 AS INTEGER) = 0 THEN e.revision ELSE ?1 END +JOIN users creator ON creator.id = e.created_by JOIN community_projects p ON p.id = e.project_id +JOIN community_members m ON m.community_id = p.community_id JOIN users u ON u.id = m.user_id +WHERE e.id = ?2 AND u.uuid = ?3 +` + +type GetCommunityExperimentParams struct { + Revision int64 + ID string + UserUuid uuid.UUID +} + +type GetCommunityExperimentRow struct { + ID string + ProjectID string + Revision int64 + Name string + Description string + Collection string + Kind string + UpdatedAt int64 + Document string + Bundle sql.NullString + CreatorUuid uuid.UUID +} + +func (q *Queries) GetCommunityExperiment(ctx context.Context, arg GetCommunityExperimentParams) (GetCommunityExperimentRow, error) { + row := q.db.QueryRowContext(ctx, getCommunityExperiment, arg.Revision, arg.ID, arg.UserUuid) + var i GetCommunityExperimentRow + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Revision, + &i.Name, + &i.Description, + &i.Collection, + &i.Kind, + &i.UpdatedAt, + &i.Document, + &i.Bundle, + &i.CreatorUuid, + ) + return i, err +} + +const getCommunityMeasurement = `-- name: GetCommunityMeasurement :one +SELECT cm.project_id, cm.measurement_id, cm.shared_by, cm.shared_at, cm.experiment_id, cm.experiment_revision, sharer.uuid AS sharer_uuid FROM community_measurements cm JOIN users sharer ON sharer.id = cm.shared_by +JOIN community_projects p ON p.id = cm.project_id JOIN community_members m ON m.community_id = p.community_id +JOIN users u ON u.id = m.user_id +WHERE p.id = ?1 AND cm.measurement_id = ?2 AND u.uuid = ?3 +` + +type GetCommunityMeasurementParams struct { + ProjectID string + MeasurementID string + UserUuid uuid.UUID +} + +type GetCommunityMeasurementRow struct { + ProjectID string + MeasurementID string + SharedBy int64 + SharedAt int64 + ExperimentID sql.NullString + ExperimentRevision sql.NullInt64 + SharerUuid uuid.UUID +} + +func (q *Queries) GetCommunityMeasurement(ctx context.Context, arg GetCommunityMeasurementParams) (GetCommunityMeasurementRow, error) { + row := q.db.QueryRowContext(ctx, getCommunityMeasurement, arg.ProjectID, arg.MeasurementID, arg.UserUuid) + var i GetCommunityMeasurementRow + err := row.Scan( + &i.ProjectID, + &i.MeasurementID, + &i.SharedBy, + &i.SharedAt, + &i.ExperimentID, + &i.ExperimentRevision, + &i.SharerUuid, + ) + return i, err +} + +const getCommunityProject = `-- name: GetCommunityProject :one +SELECT p.id, p.community_id, p.name, p.description, p.created_by, p.created_at, creator.uuid AS creator_uuid, c.created_by AS owner_id +FROM community_projects p JOIN users creator ON creator.id = p.created_by JOIN communities c ON c.id = p.community_id +JOIN community_members m ON m.community_id = p.community_id JOIN users u ON u.id = m.user_id +WHERE p.id = ?1 AND u.uuid = ?2 +` + +type GetCommunityProjectParams struct { + ID string + UserUuid uuid.UUID +} + +type GetCommunityProjectRow struct { + ID string + CommunityID string + Name string + Description string + CreatedBy int64 + CreatedAt int64 + CreatorUuid uuid.UUID + OwnerID int64 +} + +func (q *Queries) GetCommunityProject(ctx context.Context, arg GetCommunityProjectParams) (GetCommunityProjectRow, error) { + row := q.db.QueryRowContext(ctx, getCommunityProject, arg.ID, arg.UserUuid) + var i GetCommunityProjectRow + err := row.Scan( + &i.ID, + &i.CommunityID, + &i.Name, + &i.Description, + &i.CreatedBy, + &i.CreatedAt, + &i.CreatorUuid, + &i.OwnerID, + ) + return i, err +} + +const insertCommunityExperimentRevision = `-- name: InsertCommunityExperimentRevision :execrows +INSERT INTO community_experiment_revisions(experiment_id, revision, name, description, collection, kind, document, bundle, updated_at) +SELECT ?1, ?2, ?3, ?4, ?5, + ?6, ?7, ?8, ?9 +FROM community_experiments current JOIN community_projects project ON project.id = current.project_id +WHERE current.id = ?1 AND + COALESCE((SELECT sum(length(CAST(r.document AS BLOB)) + COALESCE(length(CAST(r.bundle AS BLOB)), 0)) + FROM community_experiment_revisions r JOIN community_experiments e ON e.id = r.experiment_id + JOIN community_projects p ON p.id = e.project_id WHERE p.community_id = project.community_id), 0) + + length(CAST(?7 AS BLOB)) + COALESCE(length(CAST(?8 AS BLOB)), 0) <= 268435456 +` + +type InsertCommunityExperimentRevisionParams struct { + ExperimentID string + Revision int64 + Name string + Description string + Collection string + Kind string + Document string + Bundle sql.NullString + UpdatedAt int64 +} + +func (q *Queries) InsertCommunityExperimentRevision(ctx context.Context, arg InsertCommunityExperimentRevisionParams) (int64, error) { + result, err := q.db.ExecContext(ctx, insertCommunityExperimentRevision, + arg.ExperimentID, + arg.Revision, + arg.Name, + arg.Description, + arg.Collection, + arg.Kind, + arg.Document, + arg.Bundle, + arg.UpdatedAt, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const isFundedCommunityMeasurement = `-- name: IsFundedCommunityMeasurement :one +SELECT EXISTS (SELECT 1 FROM community_measurements cm JOIN community_funding f + ON f.project_id = cm.project_id AND f.transaction_id = cm.measurement_id + JOIN community_projects p ON p.id = cm.project_id + JOIN community_members m ON m.community_id = p.community_id JOIN users u ON u.id = m.user_id + WHERE cm.project_id = ?1 AND cm.measurement_id = ?2 + AND u.uuid = ?3) +` + +type IsFundedCommunityMeasurementParams struct { + ProjectID string + MeasurementID string + UserUuid uuid.UUID +} + +func (q *Queries) IsFundedCommunityMeasurement(ctx context.Context, arg IsFundedCommunityMeasurementParams) (bool, error) { + row := q.db.QueryRowContext(ctx, isFundedCommunityMeasurement, arg.ProjectID, arg.MeasurementID, arg.UserUuid) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const joinCommunity = `-- name: JoinCommunity :exec +INSERT INTO community_members(community_id, user_id, joined_at) +SELECT ?1, id, ?2 FROM users WHERE uuid = ?3 +ON CONFLICT(community_id, user_id) DO NOTHING +` + +type JoinCommunityParams struct { + ID string + JoinedAt int64 + UserUuid uuid.UUID +} + +func (q *Queries) JoinCommunity(ctx context.Context, arg JoinCommunityParams) error { + _, err := q.db.ExecContext(ctx, joinCommunity, arg.ID, arg.JoinedAt, arg.UserUuid) + return err +} + +const listCommunities = `-- name: ListCommunities :many +SELECT c.id, c.name, c.description, c.created_by, c.created_at, creator.uuid AS creator_uuid, CAST(CASE WHEN c.created_by = m.user_id THEN 'owner' ELSE 'member' END AS TEXT) AS role +FROM communities c JOIN community_members m ON m.community_id = c.id +JOIN users u ON u.id = m.user_id JOIN users creator ON creator.id = c.created_by +WHERE u.uuid = ?1 ORDER BY c.name, c.id +` + +type ListCommunitiesRow struct { + ID string + Name string + Description string + CreatedBy int64 + CreatedAt int64 + CreatorUuid uuid.UUID + Role string +} + +func (q *Queries) ListCommunities(ctx context.Context, userUuid uuid.UUID) ([]ListCommunitiesRow, error) { + rows, err := q.db.QueryContext(ctx, listCommunities, userUuid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListCommunitiesRow + for rows.Next() { + var i ListCommunitiesRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.CreatedBy, + &i.CreatedAt, + &i.CreatorUuid, + &i.Role, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listCommunityExperiments = `-- name: ListCommunityExperiments :many +SELECT e.id, e.project_id, e.revision, r.name, r.description, r.collection, r.kind, r.updated_at, creator.uuid AS creator_uuid +FROM community_experiments e JOIN community_experiment_revisions r ON r.experiment_id = e.id AND r.revision = e.revision +JOIN users creator ON creator.id = e.created_by JOIN community_projects p ON p.id = e.project_id +JOIN community_members m ON m.community_id = p.community_id JOIN users u ON u.id = m.user_id +WHERE p.id = ?1 AND u.uuid = ?2 ORDER BY r.name, e.id +` + +type ListCommunityExperimentsParams struct { + ProjectID string + UserUuid uuid.UUID +} + +type ListCommunityExperimentsRow struct { + ID string + ProjectID string + Revision int64 + Name string + Description string + Collection string + Kind string + UpdatedAt int64 + CreatorUuid uuid.UUID +} + +func (q *Queries) ListCommunityExperiments(ctx context.Context, arg ListCommunityExperimentsParams) ([]ListCommunityExperimentsRow, error) { + rows, err := q.db.QueryContext(ctx, listCommunityExperiments, arg.ProjectID, arg.UserUuid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListCommunityExperimentsRow + for rows.Next() { + var i ListCommunityExperimentsRow + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Revision, + &i.Name, + &i.Description, + &i.Collection, + &i.Kind, + &i.UpdatedAt, + &i.CreatorUuid, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listCommunityMeasurements = `-- name: ListCommunityMeasurements :many +SELECT cm.project_id, cm.measurement_id, cm.shared_by, cm.shared_at, cm.experiment_id, cm.experiment_revision, sharer.uuid AS sharer_uuid FROM community_measurements cm JOIN users sharer ON sharer.id = cm.shared_by +JOIN community_projects p ON p.id = cm.project_id JOIN community_members m ON m.community_id = p.community_id +JOIN users u ON u.id = m.user_id +WHERE p.id = ?1 AND u.uuid = ?2 +AND EXISTS (SELECT 1 FROM debuglets d WHERE d.transaction_id = cm.measurement_id) +ORDER BY cm.shared_at DESC, cm.measurement_id +` + +type ListCommunityMeasurementsParams struct { + ProjectID string + UserUuid uuid.UUID +} + +type ListCommunityMeasurementsRow struct { + ProjectID string + MeasurementID string + SharedBy int64 + SharedAt int64 + ExperimentID sql.NullString + ExperimentRevision sql.NullInt64 + SharerUuid uuid.UUID +} + +func (q *Queries) ListCommunityMeasurements(ctx context.Context, arg ListCommunityMeasurementsParams) ([]ListCommunityMeasurementsRow, error) { + rows, err := q.db.QueryContext(ctx, listCommunityMeasurements, arg.ProjectID, arg.UserUuid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListCommunityMeasurementsRow + for rows.Next() { + var i ListCommunityMeasurementsRow + if err := rows.Scan( + &i.ProjectID, + &i.MeasurementID, + &i.SharedBy, + &i.SharedAt, + &i.ExperimentID, + &i.ExperimentRevision, + &i.SharerUuid, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listCommunityMembers = `-- name: ListCommunityMembers :many +SELECT u.uuid AS user_uuid, u.name, m.joined_at, +CAST(CASE WHEN c.created_by = m.user_id THEN 'owner' ELSE 'member' END AS TEXT) AS role +FROM community_members m JOIN users u ON u.id = m.user_id JOIN communities c ON c.id = m.community_id +WHERE c.id = ?1 AND EXISTS ( + SELECT 1 FROM community_members viewer JOIN users account ON account.id = viewer.user_id + WHERE viewer.community_id = c.id AND account.uuid = ?2) +ORDER BY m.joined_at, u.id +` + +type ListCommunityMembersParams struct { + ID string + UserUuid uuid.UUID +} + +type ListCommunityMembersRow struct { + UserUuid uuid.UUID + Name string + JoinedAt int64 + Role string +} + +func (q *Queries) ListCommunityMembers(ctx context.Context, arg ListCommunityMembersParams) ([]ListCommunityMembersRow, error) { + rows, err := q.db.QueryContext(ctx, listCommunityMembers, arg.ID, arg.UserUuid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListCommunityMembersRow + for rows.Next() { + var i ListCommunityMembersRow + if err := rows.Scan( + &i.UserUuid, + &i.Name, + &i.JoinedAt, + &i.Role, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listCommunityProjects = `-- name: ListCommunityProjects :many +SELECT p.id, p.community_id, p.name, p.description, p.created_by, p.created_at, creator.uuid AS creator_uuid FROM community_projects p JOIN users creator ON creator.id = p.created_by +JOIN community_members m ON m.community_id = p.community_id JOIN users u ON u.id = m.user_id +WHERE p.community_id = ?1 AND u.uuid = ?2 ORDER BY p.name, p.id +` + +type ListCommunityProjectsParams struct { + CommunityID string + UserUuid uuid.UUID +} + +type ListCommunityProjectsRow struct { + ID string + CommunityID string + Name string + Description string + CreatedBy int64 + CreatedAt int64 + CreatorUuid uuid.UUID +} + +func (q *Queries) ListCommunityProjects(ctx context.Context, arg ListCommunityProjectsParams) ([]ListCommunityProjectsRow, error) { + rows, err := q.db.QueryContext(ctx, listCommunityProjects, arg.CommunityID, arg.UserUuid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListCommunityProjectsRow + for rows.Next() { + var i ListCommunityProjectsRow + if err := rows.Scan( + &i.ID, + &i.CommunityID, + &i.Name, + &i.Description, + &i.CreatedBy, + &i.CreatedAt, + &i.CreatorUuid, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const pruneCommunityInvitations = `-- name: PruneCommunityInvitations :exec +DELETE FROM community_invitations WHERE community_id = ? AND expires_at <= ? +` + +type PruneCommunityInvitationsParams struct { + CommunityID string + ExpiresAt int64 +} + +func (q *Queries) PruneCommunityInvitations(ctx context.Context, arg PruneCommunityInvitationsParams) error { + _, err := q.db.ExecContext(ctx, pruneCommunityInvitations, arg.CommunityID, arg.ExpiresAt) + return err +} + +const removeCommunityMember = `-- name: RemoveCommunityMember :execrows +DELETE FROM community_members +WHERE community_members.community_id = ?1 AND community_members.user_id = (SELECT users.id FROM users WHERE users.uuid = ?2) +AND EXISTS (SELECT 1 FROM communities c JOIN users owner ON owner.id = c.created_by + WHERE c.id = community_members.community_id AND owner.uuid = ?3 AND c.created_by <> community_members.user_id) +` + +type RemoveCommunityMemberParams struct { + ID string + MemberUuid uuid.UUID + UserUuid uuid.UUID +} + +func (q *Queries) RemoveCommunityMember(ctx context.Context, arg RemoveCommunityMemberParams) (int64, error) { + result, err := q.db.ExecContext(ctx, removeCommunityMember, arg.ID, arg.MemberUuid, arg.UserUuid) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const shareCommunityMeasurement = `-- name: ShareCommunityMeasurement :execrows +INSERT INTO community_measurements(project_id, measurement_id, shared_by, shared_at, experiment_id, experiment_revision) +SELECT p.id, ?1, u.id, ?2, ?3, ?4 +FROM community_projects p JOIN community_members m ON m.community_id = p.community_id JOIN users u ON u.id = m.user_id +WHERE p.id = ?5 AND u.uuid = ?6 +AND (SELECT count(*) FROM community_measurements WHERE project_id = p.id) < 1024 +AND EXISTS (SELECT 1 FROM debuglets d WHERE d.transaction_id = ?1) +AND EXISTS (SELECT 1 FROM transaction_users tu WHERE tu.transaction_id = ?1 AND tu.user_id = u.id) +AND NOT EXISTS (SELECT 1 FROM debuglets d WHERE d.transaction_id = ?1 + AND NOT EXISTS (SELECT 1 FROM debuglet_users du WHERE du.debuglet_id = d.id AND du.user_id = u.id)) +AND (?3 IS NULL OR EXISTS (SELECT 1 FROM community_experiments e + JOIN community_experiment_revisions r ON r.experiment_id = e.id + WHERE e.id = ?3 AND e.project_id = p.id AND r.revision = ?4)) +ON CONFLICT(project_id, measurement_id) DO NOTHING +` + +type ShareCommunityMeasurementParams struct { + MeasurementID string + SharedAt int64 + ExperimentID sql.NullString + ExperimentRevision sql.NullInt64 + ProjectID string + UserUuid uuid.UUID +} + +func (q *Queries) ShareCommunityMeasurement(ctx context.Context, arg ShareCommunityMeasurementParams) (int64, error) { + result, err := q.db.ExecContext(ctx, shareCommunityMeasurement, + arg.MeasurementID, + arg.SharedAt, + arg.ExperimentID, + arg.ExperimentRevision, + arg.ProjectID, + arg.UserUuid, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const unshareCommunityMeasurement = `-- name: UnshareCommunityMeasurement :execrows +DELETE FROM community_measurements WHERE community_measurements.project_id = ?1 AND community_measurements.measurement_id = ?2 +AND NOT EXISTS (SELECT 1 FROM community_funding f WHERE f.project_id = community_measurements.project_id + AND f.transaction_id = community_measurements.measurement_id) +AND EXISTS (SELECT 1 FROM community_projects p JOIN communities c ON c.id = p.community_id + JOIN community_members m ON m.community_id = c.id JOIN users u ON u.id = m.user_id + WHERE p.id = community_measurements.project_id AND u.uuid = ?3 + AND (community_measurements.shared_by = u.id OR c.created_by = u.id)) +` + +type UnshareCommunityMeasurementParams struct { + ProjectID string + MeasurementID string + UserUuid uuid.UUID +} + +func (q *Queries) UnshareCommunityMeasurement(ctx context.Context, arg UnshareCommunityMeasurementParams) (int64, error) { + result, err := q.db.ExecContext(ctx, unshareCommunityMeasurement, arg.ProjectID, arg.MeasurementID, arg.UserUuid) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const updateCommunity = `-- name: UpdateCommunity :execrows +UPDATE communities SET name = ?1, description = ?2 +WHERE communities.id = ?3 AND communities.created_by = (SELECT users.id FROM users WHERE users.uuid = ?4) +` + +type UpdateCommunityParams struct { + Name string + Description string + ID string + UserUuid uuid.UUID +} + +func (q *Queries) UpdateCommunity(ctx context.Context, arg UpdateCommunityParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateCommunity, + arg.Name, + arg.Description, + arg.ID, + arg.UserUuid, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const updateCommunityProject = `-- name: UpdateCommunityProject :execrows +UPDATE community_projects SET name = ?1, description = ?2 +WHERE community_projects.id = ?3 AND EXISTS (SELECT 1 FROM community_members m JOIN users u ON u.id = m.user_id + WHERE m.community_id = community_projects.community_id AND u.uuid = ?4) +` + +type UpdateCommunityProjectParams struct { + Name string + Description string + ID string + UserUuid uuid.UUID +} + +func (q *Queries) UpdateCommunityProject(ctx context.Context, arg UpdateCommunityProjectParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateCommunityProject, + arg.Name, + arg.Description, + arg.ID, + arg.UserUuid, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/dispatcher/database/measurements.sql.go b/internal/dispatcher/database/measurements.sql.go index 062df18c..90ae2806 100644 --- a/internal/dispatcher/database/measurements.sql.go +++ b/internal/dispatcher/database/measurements.sql.go @@ -118,9 +118,12 @@ func (q *Queries) MeasurementCounts(ctx context.Context, arg MeasurementCountsPa } const measurementRunCount = `-- name: MeasurementRunCount :one -SELECT COUNT(*) FROM debuglets d JOIN debuglet_users du ON du.debuglet_id = d.id -JOIN users u ON u.id = du.user_id -WHERE d.transaction_id = ?1 AND u.uuid = ?2 +SELECT COUNT(*) FROM debuglets d +WHERE d.transaction_id = ?1 AND (EXISTS (SELECT 1 FROM debuglet_users du JOIN users u ON u.id = du.user_id + WHERE du.debuglet_id = d.id AND u.uuid = ?2) OR EXISTS ( + SELECT 1 FROM community_measurements cm JOIN community_projects p ON p.id = cm.project_id + JOIN community_members member ON member.community_id = p.community_id JOIN users u ON u.id = member.user_id + WHERE cm.measurement_id = d.transaction_id AND u.uuid = ?2)) ` type MeasurementRunCountParams struct { @@ -139,10 +142,13 @@ const measurementRuns = `-- name: MeasurementRuns :many SELECT d.uuid, d.executor_id, d.order_id, d.state, CAST(COALESCE(json_extract(m.document, '$.label'), '') AS TEXT) AS label, CAST(COALESCE(json_extract(m.document, '$.program_name'), '') AS TEXT) AS program_name -FROM debuglets d JOIN debuglet_users du ON du.debuglet_id = d.id -JOIN users u ON u.id = du.user_id +FROM debuglets d LEFT JOIN measurement_requests m ON m.debuglet_id = d.id -WHERE d.transaction_id = ?1 AND u.uuid = ?2 +WHERE d.transaction_id = ?1 AND (EXISTS (SELECT 1 FROM debuglet_users du JOIN users u ON u.id = du.user_id + WHERE du.debuglet_id = d.id AND u.uuid = ?2) OR EXISTS ( + SELECT 1 FROM community_measurements cm JOIN community_projects p ON p.id = cm.project_id + JOIN community_members member ON member.community_id = p.community_id JOIN users u ON u.id = member.user_id + WHERE cm.measurement_id = d.transaction_id AND u.uuid = ?2)) ORDER BY d.order_id, d.id LIMIT ?4 OFFSET ?3 ` diff --git a/internal/dispatcher/database/migrations/00032_communities.sql b/internal/dispatcher/database/migrations/00032_communities.sql new file mode 100644 index 00000000..b24c81eb --- /dev/null +++ b/internal/dispatcher/database/migrations/00032_communities.sql @@ -0,0 +1,79 @@ +-- +goose up +CREATE TABLE communities ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + created_by INTEGER NOT NULL REFERENCES users(id), + created_at INTEGER NOT NULL +); +CREATE INDEX communities_creator ON communities(created_by); +CREATE TABLE community_members ( + community_id TEXT NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at INTEGER NOT NULL, + PRIMARY KEY(community_id, user_id) +); +CREATE INDEX community_members_user ON community_members(user_id); +CREATE TABLE community_invitations ( + token_hash BLOB PRIMARY KEY, + community_id TEXT NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL +); +CREATE INDEX community_invitations_community ON community_invitations(community_id); +CREATE TABLE community_projects ( + id TEXT PRIMARY KEY, + community_id TEXT NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL, + created_by INTEGER NOT NULL REFERENCES users(id), + created_at INTEGER NOT NULL +); +CREATE INDEX community_projects_community ON community_projects(community_id); +CREATE TABLE community_experiments ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES community_projects(id) ON DELETE CASCADE, + revision INTEGER NOT NULL CHECK(revision > 0), + created_by INTEGER NOT NULL REFERENCES users(id) +); +CREATE INDEX community_experiments_project ON community_experiments(project_id); +CREATE TABLE community_experiment_revisions ( + experiment_id TEXT NOT NULL REFERENCES community_experiments(id) ON DELETE CASCADE, + revision INTEGER NOT NULL CHECK(revision > 0), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + collection TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL CHECK(kind IN ('workflow', 'experiment')), + document TEXT NOT NULL CHECK(json_valid(document)), + bundle TEXT CHECK(bundle IS NULL OR json_valid(bundle)), + updated_at INTEGER NOT NULL, + PRIMARY KEY(experiment_id, revision), + CHECK(length(CAST(document AS BLOB)) + COALESCE(length(CAST(bundle AS BLOB)), 0) <= 25165824) +); +-- +goose StatementBegin +CREATE TRIGGER community_experiment_revision_immutable BEFORE UPDATE ON community_experiment_revisions +BEGIN + SELECT RAISE(ABORT, 'saved experiment revisions are immutable'); +END; +-- +goose StatementEnd +CREATE TABLE community_measurements ( + project_id TEXT NOT NULL REFERENCES community_projects(id) ON DELETE CASCADE, + measurement_id TEXT NOT NULL REFERENCES transactions(id), + shared_by INTEGER NOT NULL REFERENCES users(id), + shared_at INTEGER NOT NULL, + experiment_id TEXT, + experiment_revision INTEGER, + PRIMARY KEY(project_id, measurement_id), + FOREIGN KEY(experiment_id, experiment_revision) REFERENCES community_experiment_revisions(experiment_id, revision), + CHECK((experiment_id IS NULL) = (experiment_revision IS NULL)) +); +CREATE INDEX community_measurements_measurement ON community_measurements(measurement_id); + +-- +goose down +DROP TABLE community_measurements; +DROP TRIGGER community_experiment_revision_immutable; +DROP TABLE community_experiment_revisions; +DROP TABLE community_experiments; +DROP TABLE community_projects; +DROP TABLE community_invitations; +DROP TABLE community_members; +DROP TABLE communities; diff --git a/internal/dispatcher/database/migrations/00033_community_funding.sql b/internal/dispatcher/database/migrations/00033_community_funding.sql new file mode 100644 index 00000000..18ce56cc --- /dev/null +++ b/internal/dispatcher/database/migrations/00033_community_funding.sql @@ -0,0 +1,27 @@ +-- +goose up +-- Deposits retain their ordinary chain receipt and transaction. Sponsored +-- measurements retain their actor, while the community pays their orders. +CREATE TABLE community_topups ( + transaction_id TEXT PRIMARY KEY NOT NULL REFERENCES transactions(id), + community_id TEXT NOT NULL REFERENCES communities(id), + created_by INTEGER NOT NULL REFERENCES users(id), + refund_address TEXT NOT NULL, + network TEXT NOT NULL, + coin_type TEXT NOT NULL +); +CREATE INDEX community_topups_community ON community_topups(community_id); +CREATE TABLE community_funding ( + transaction_id TEXT PRIMARY KEY NOT NULL REFERENCES transactions(id), + community_id TEXT NOT NULL REFERENCES communities(id), + project_id TEXT NOT NULL REFERENCES community_projects(id), + sponsor_user_id INTEGER NOT NULL REFERENCES users(id), + actor_user_id INTEGER NOT NULL REFERENCES users(id), + currency TEXT NOT NULL CHECK(currency IN ('USDC', 'TEST')), + network TEXT NOT NULL +); +CREATE INDEX community_funding_community ON community_funding(community_id); +CREATE INDEX community_funding_sponsor ON community_funding(sponsor_user_id); + +-- +goose down +DROP TABLE community_funding; +DROP TABLE community_topups; diff --git a/internal/dispatcher/database/models.go b/internal/dispatcher/database/models.go index a0986e9d..7fb4839a 100644 --- a/internal/dispatcher/database/models.go +++ b/internal/dispatcher/database/models.go @@ -116,6 +116,82 @@ type ChainTransfer struct { UpdatedAt models.UTCTime } +type Community struct { + ID string + Name string + Description string + CreatedBy int64 + CreatedAt int64 +} + +type CommunityExperiment struct { + ID string + ProjectID string + Revision int64 + CreatedBy int64 +} + +type CommunityExperimentRevision struct { + ExperimentID string + Revision int64 + Name string + Description string + Collection string + Kind string + Document string + Bundle sql.NullString + UpdatedAt int64 +} + +type CommunityFunding struct { + TransactionID string + CommunityID string + ProjectID string + SponsorUserID int64 + ActorUserID int64 + Currency string + Network string +} + +type CommunityInvitation struct { + TokenHash []byte + CommunityID string + ExpiresAt int64 +} + +type CommunityMeasurement struct { + ProjectID string + MeasurementID string + SharedBy int64 + SharedAt int64 + ExperimentID sql.NullString + ExperimentRevision sql.NullInt64 +} + +type CommunityMember struct { + CommunityID string + UserID int64 + JoinedAt int64 +} + +type CommunityProject struct { + ID string + CommunityID string + Name string + Description string + CreatedBy int64 + CreatedAt int64 +} + +type CommunityTopup struct { + TransactionID string + CommunityID string + CreatedBy int64 + RefundAddress string + Network string + CoinType string +} + type Debuglet struct { ID int64 Uuid uuid.UUID diff --git a/internal/dispatcher/database/queries/allowances.sql b/internal/dispatcher/database/queries/allowances.sql index 9ebc3b9f..6750cee7 100644 --- a/internal/dispatcher/database/queries/allowances.sql +++ b/internal/dispatcher/database/queries/allowances.sql @@ -7,13 +7,15 @@ SELECT CAST(COALESCE((SELECT SUM(o.price) FROM debuglet_order o JOIN transaction_users tu ON tu.transaction_id = o.transaction_id JOIN transactions t ON t.id = o.transaction_id - WHERE tu.user_id = u.id AND t.method = 'TEST' + LEFT JOIN community_funding f ON f.transaction_id = o.transaction_id + WHERE COALESCE(f.sponsor_user_id, tu.user_id) = u.id AND t.method = 'TEST' AND EXISTS (SELECT 1 FROM order_settlements s WHERE s.transaction_id = o.transaction_id AND s.order_id = o.order_id)), 0) AS INTEGER) AS consumed, CAST(COALESCE((SELECT SUM(o.price) FROM debuglet_order o JOIN transaction_users tu ON tu.transaction_id = o.transaction_id JOIN transactions t ON t.id = o.transaction_id - WHERE tu.user_id = u.id AND t.method = 'TEST' AND t.status = sqlc.arg(paid_status) + LEFT JOIN community_funding f ON f.transaction_id = o.transaction_id + WHERE COALESCE(f.sponsor_user_id, tu.user_id) = u.id AND t.method = 'TEST' AND t.status = sqlc.arg(paid_status) AND NOT EXISTS (SELECT 1 FROM order_settlements s WHERE s.transaction_id = o.transaction_id AND s.order_id = o.order_id)), 0) AS INTEGER) AS reserved FROM users u @@ -31,7 +33,8 @@ WHERE u.uuid = sqlc.arg(user_uuid) - COALESCE((SELECT SUM(o.price) FROM debuglet_order o JOIN transaction_users tu ON tu.transaction_id = o.transaction_id JOIN transactions t ON t.id = o.transaction_id - WHERE tu.user_id = u.id AND t.method = 'TEST' + LEFT JOIN community_funding f ON f.transaction_id = o.transaction_id + WHERE COALESCE(f.sponsor_user_id, tu.user_id) = u.id AND t.method = 'TEST' AND (t.status = sqlc.arg(paid_status) OR EXISTS (SELECT 1 FROM order_settlements s WHERE s.transaction_id = o.transaction_id AND s.order_id = o.order_id))), 0) >= sqlc.arg(price); @@ -44,7 +47,8 @@ UPDATE transactions SET status = sqlc.arg(expired_status) WHERE status = sqlc.arg(paid_status) AND method = 'TEST' AND expires_at < sqlc.arg(now) AND id IN (SELECT tu.transaction_id FROM transaction_users tu - JOIN users u ON u.id = tu.user_id WHERE u.uuid = sqlc.arg(user_uuid)) + LEFT JOIN community_funding f ON f.transaction_id=tu.transaction_id + JOIN users u ON u.id = COALESCE(f.sponsor_user_id, tu.user_id) WHERE u.uuid = sqlc.arg(user_uuid)) AND NOT EXISTS ( SELECT 1 FROM debuglet_order WHERE debuglet_order.transaction_id = transactions.id AND debuglet_id IS NOT NULL diff --git a/internal/dispatcher/database/queries/communities.sql b/internal/dispatcher/database/queries/communities.sql new file mode 100644 index 00000000..38e6addb --- /dev/null +++ b/internal/dispatcher/database/queries/communities.sql @@ -0,0 +1,187 @@ +-- name: CreateCommunity :execrows +INSERT INTO communities(id, name, description, created_by, created_at) +SELECT sqlc.arg(id), sqlc.arg(name), sqlc.arg(description), u.id, sqlc.arg(created_at) +FROM users u WHERE u.uuid = sqlc.arg(user_uuid) +AND (SELECT count(*) FROM communities WHERE created_by = u.id) < 16; + +-- name: AddCommunityCreator :exec +INSERT INTO community_members(community_id, user_id, joined_at) +SELECT id, created_by, created_at FROM communities WHERE id = ?; + +-- name: GetCommunity :one +SELECT c.*, creator.uuid AS creator_uuid, CAST(CASE WHEN c.created_by = m.user_id THEN 'owner' ELSE 'member' END AS TEXT) AS role +FROM communities c JOIN community_members m ON m.community_id = c.id +JOIN users u ON u.id = m.user_id JOIN users creator ON creator.id = c.created_by +WHERE c.id = sqlc.arg(id) AND u.uuid = sqlc.arg(user_uuid); + +-- name: ListCommunities :many +SELECT c.*, creator.uuid AS creator_uuid, CAST(CASE WHEN c.created_by = m.user_id THEN 'owner' ELSE 'member' END AS TEXT) AS role +FROM communities c JOIN community_members m ON m.community_id = c.id +JOIN users u ON u.id = m.user_id JOIN users creator ON creator.id = c.created_by +WHERE u.uuid = sqlc.arg(user_uuid) ORDER BY c.name, c.id; + +-- name: UpdateCommunity :execrows +UPDATE communities SET name = sqlc.arg(name), description = sqlc.arg(description) +WHERE communities.id = sqlc.arg(id) AND communities.created_by = (SELECT users.id FROM users WHERE users.uuid = sqlc.arg(user_uuid)); + +-- name: ListCommunityMembers :many +SELECT u.uuid AS user_uuid, u.name, m.joined_at, +CAST(CASE WHEN c.created_by = m.user_id THEN 'owner' ELSE 'member' END AS TEXT) AS role +FROM community_members m JOIN users u ON u.id = m.user_id JOIN communities c ON c.id = m.community_id +WHERE c.id = sqlc.arg(id) AND EXISTS ( + SELECT 1 FROM community_members viewer JOIN users account ON account.id = viewer.user_id + WHERE viewer.community_id = c.id AND account.uuid = sqlc.arg(user_uuid)) +ORDER BY m.joined_at, u.id; + +-- name: RemoveCommunityMember :execrows +DELETE FROM community_members +WHERE community_members.community_id = sqlc.arg(id) AND community_members.user_id = (SELECT users.id FROM users WHERE users.uuid = sqlc.arg(member_uuid)) +AND EXISTS (SELECT 1 FROM communities c JOIN users owner ON owner.id = c.created_by + WHERE c.id = community_members.community_id AND owner.uuid = sqlc.arg(user_uuid) AND c.created_by <> community_members.user_id); + +-- name: CreateCommunityInvitation :execrows +INSERT INTO community_invitations(token_hash, community_id, expires_at) +SELECT sqlc.arg(token_hash), c.id, sqlc.arg(expires_at) FROM communities c JOIN users u ON u.id = c.created_by +WHERE c.id = sqlc.arg(id) AND u.uuid = sqlc.arg(user_uuid) +AND (SELECT count(*) FROM community_invitations i WHERE i.community_id = c.id AND i.expires_at > sqlc.arg(now)) < 8; + +-- name: PruneCommunityInvitations :exec +DELETE FROM community_invitations WHERE community_id = ? AND expires_at <= ?; + +-- name: ConsumeCommunityInvitation :one +DELETE FROM community_invitations WHERE token_hash = sqlc.arg(token_hash) AND expires_at > sqlc.arg(now) +AND (SELECT count(*) FROM community_members WHERE community_id = community_invitations.community_id) < 128 +AND (SELECT count(*) FROM community_members WHERE user_id = (SELECT id FROM users WHERE uuid = sqlc.arg(user_uuid))) < 128 +RETURNING community_id; + +-- name: JoinCommunity :exec +INSERT INTO community_members(community_id, user_id, joined_at) +SELECT sqlc.arg(id), id, sqlc.arg(joined_at) FROM users WHERE uuid = sqlc.arg(user_uuid) +ON CONFLICT(community_id, user_id) DO NOTHING; + +-- name: GetCommunityProject :one +SELECT p.*, creator.uuid AS creator_uuid, c.created_by AS owner_id +FROM community_projects p JOIN users creator ON creator.id = p.created_by JOIN communities c ON c.id = p.community_id +JOIN community_members m ON m.community_id = p.community_id JOIN users u ON u.id = m.user_id +WHERE p.id = sqlc.arg(id) AND u.uuid = sqlc.arg(user_uuid); + +-- name: ListCommunityProjects :many +SELECT p.*, creator.uuid AS creator_uuid FROM community_projects p JOIN users creator ON creator.id = p.created_by +JOIN community_members m ON m.community_id = p.community_id JOIN users u ON u.id = m.user_id +WHERE p.community_id = sqlc.arg(community_id) AND u.uuid = sqlc.arg(user_uuid) ORDER BY p.name, p.id; + +-- name: CreateCommunityProject :execrows +INSERT INTO community_projects(id, community_id, name, description, created_by, created_at) +SELECT sqlc.arg(id), m.community_id, sqlc.arg(name), sqlc.arg(description), m.user_id, sqlc.arg(created_at) +FROM community_members m JOIN users u ON u.id = m.user_id +WHERE m.community_id = sqlc.arg(community_id) AND u.uuid = sqlc.arg(user_uuid) +AND (SELECT count(*) FROM community_projects WHERE community_id = m.community_id) < 64; + +-- name: UpdateCommunityProject :execrows +UPDATE community_projects SET name = sqlc.arg(name), description = sqlc.arg(description) +WHERE community_projects.id = sqlc.arg(id) AND EXISTS (SELECT 1 FROM community_members m JOIN users u ON u.id = m.user_id + WHERE m.community_id = community_projects.community_id AND u.uuid = sqlc.arg(user_uuid)); + +-- name: ListCommunityExperiments :many +SELECT e.id, e.project_id, e.revision, r.name, r.description, r.collection, r.kind, r.updated_at, creator.uuid AS creator_uuid +FROM community_experiments e JOIN community_experiment_revisions r ON r.experiment_id = e.id AND r.revision = e.revision +JOIN users creator ON creator.id = e.created_by JOIN community_projects p ON p.id = e.project_id +JOIN community_members m ON m.community_id = p.community_id JOIN users u ON u.id = m.user_id +WHERE p.id = sqlc.arg(project_id) AND u.uuid = sqlc.arg(user_uuid) ORDER BY r.name, e.id; + +-- name: GetCommunityExperiment :one +SELECT e.id, e.project_id, r.revision, r.name, r.description, r.collection, r.kind, r.updated_at, r.document, r.bundle, creator.uuid AS creator_uuid +FROM community_experiments e JOIN community_experiment_revisions r ON r.experiment_id = e.id + AND r.revision = CASE WHEN CAST(sqlc.arg(revision) AS INTEGER) = 0 THEN e.revision ELSE sqlc.arg(revision) END +JOIN users creator ON creator.id = e.created_by JOIN community_projects p ON p.id = e.project_id +JOIN community_members m ON m.community_id = p.community_id JOIN users u ON u.id = m.user_id +WHERE e.id = sqlc.arg(id) AND u.uuid = sqlc.arg(user_uuid); + +-- name: CreateCommunityExperiment :execrows +INSERT INTO community_experiments(id, project_id, revision, created_by) +SELECT sqlc.arg(id), p.id, 1, m.user_id FROM community_projects p +JOIN community_members m ON m.community_id = p.community_id JOIN users u ON u.id = m.user_id +WHERE p.id = sqlc.arg(project_id) AND u.uuid = sqlc.arg(user_uuid) +AND (SELECT count(*) FROM community_experiments WHERE project_id = p.id) < 128; + +-- name: AdvanceCommunityExperiment :execrows +UPDATE community_experiments SET revision = revision + 1 +WHERE community_experiments.id = sqlc.arg(id) AND community_experiments.revision = sqlc.arg(revision) AND community_experiments.revision < 32 +AND EXISTS (SELECT 1 FROM community_projects p JOIN community_members m ON m.community_id = p.community_id + JOIN users u ON u.id = m.user_id WHERE p.id = community_experiments.project_id AND u.uuid = sqlc.arg(user_uuid)); + +-- name: InsertCommunityExperimentRevision :execrows +INSERT INTO community_experiment_revisions(experiment_id, revision, name, description, collection, kind, document, bundle, updated_at) +SELECT sqlc.arg(experiment_id), sqlc.arg(revision), sqlc.arg(name), sqlc.arg(description), sqlc.arg(collection), + sqlc.arg(kind), sqlc.arg(document), sqlc.narg(bundle), sqlc.arg(updated_at) +FROM community_experiments current JOIN community_projects project ON project.id = current.project_id +WHERE current.id = sqlc.arg(experiment_id) AND + COALESCE((SELECT sum(length(CAST(r.document AS BLOB)) + COALESCE(length(CAST(r.bundle AS BLOB)), 0)) + FROM community_experiment_revisions r JOIN community_experiments e ON e.id = r.experiment_id + JOIN community_projects p ON p.id = e.project_id WHERE p.community_id = project.community_id), 0) + + length(CAST(sqlc.arg(document) AS BLOB)) + COALESCE(length(CAST(sqlc.narg(bundle) AS BLOB)), 0) <= 268435456; + +-- name: ListCommunityMeasurements :many +SELECT cm.*, sharer.uuid AS sharer_uuid FROM community_measurements cm JOIN users sharer ON sharer.id = cm.shared_by +JOIN community_projects p ON p.id = cm.project_id JOIN community_members m ON m.community_id = p.community_id +JOIN users u ON u.id = m.user_id +WHERE p.id = sqlc.arg(project_id) AND u.uuid = sqlc.arg(user_uuid) +AND EXISTS (SELECT 1 FROM debuglets d WHERE d.transaction_id = cm.measurement_id) +ORDER BY cm.shared_at DESC, cm.measurement_id; + +-- name: ShareCommunityMeasurement :execrows +INSERT INTO community_measurements(project_id, measurement_id, shared_by, shared_at, experiment_id, experiment_revision) +SELECT p.id, sqlc.arg(measurement_id), u.id, sqlc.arg(shared_at), sqlc.narg(experiment_id), sqlc.narg(experiment_revision) +FROM community_projects p JOIN community_members m ON m.community_id = p.community_id JOIN users u ON u.id = m.user_id +WHERE p.id = sqlc.arg(project_id) AND u.uuid = sqlc.arg(user_uuid) +AND (SELECT count(*) FROM community_measurements WHERE project_id = p.id) < 1024 +AND EXISTS (SELECT 1 FROM debuglets d WHERE d.transaction_id = sqlc.arg(measurement_id)) +AND EXISTS (SELECT 1 FROM transaction_users tu WHERE tu.transaction_id = sqlc.arg(measurement_id) AND tu.user_id = u.id) +AND NOT EXISTS (SELECT 1 FROM debuglets d WHERE d.transaction_id = sqlc.arg(measurement_id) + AND NOT EXISTS (SELECT 1 FROM debuglet_users du WHERE du.debuglet_id = d.id AND du.user_id = u.id)) +AND (sqlc.narg(experiment_id) IS NULL OR EXISTS (SELECT 1 FROM community_experiments e + JOIN community_experiment_revisions r ON r.experiment_id = e.id + WHERE e.id = sqlc.narg(experiment_id) AND e.project_id = p.id AND r.revision = sqlc.narg(experiment_revision))) +ON CONFLICT(project_id, measurement_id) DO NOTHING; + +-- name: GetCommunityMeasurement :one +SELECT cm.*, sharer.uuid AS sharer_uuid FROM community_measurements cm JOIN users sharer ON sharer.id = cm.shared_by +JOIN community_projects p ON p.id = cm.project_id JOIN community_members m ON m.community_id = p.community_id +JOIN users u ON u.id = m.user_id +WHERE p.id = sqlc.arg(project_id) AND cm.measurement_id = sqlc.arg(measurement_id) AND u.uuid = sqlc.arg(user_uuid); + +-- name: UnshareCommunityMeasurement :execrows +DELETE FROM community_measurements WHERE community_measurements.project_id = sqlc.arg(project_id) AND community_measurements.measurement_id = sqlc.arg(measurement_id) +AND NOT EXISTS (SELECT 1 FROM community_funding f WHERE f.project_id = community_measurements.project_id + AND f.transaction_id = community_measurements.measurement_id) +AND EXISTS (SELECT 1 FROM community_projects p JOIN communities c ON c.id = p.community_id + JOIN community_members m ON m.community_id = c.id JOIN users u ON u.id = m.user_id + WHERE p.id = community_measurements.project_id AND u.uuid = sqlc.arg(user_uuid) + AND (community_measurements.shared_by = u.id OR c.created_by = u.id)); + +-- name: CanReadCommunityDebuglet :one +SELECT EXISTS(SELECT 1 FROM debuglets d JOIN community_measurements cm ON cm.measurement_id = d.transaction_id +JOIN community_projects p ON p.id = cm.project_id JOIN community_members m ON m.community_id = p.community_id +JOIN users u ON u.id = m.user_id WHERE d.uuid = sqlc.arg(run_uuid) AND u.uuid = sqlc.arg(user_uuid)); + +-- name: CanManageMeasurement :one +SELECT EXISTS(SELECT 1 FROM debuglets first_run WHERE first_run.transaction_id = sqlc.arg(batch_id)) +AND NOT EXISTS(SELECT 1 FROM debuglets d WHERE d.transaction_id = sqlc.arg(batch_id) + AND NOT EXISTS(SELECT 1 FROM debuglet_users du JOIN users u ON u.id = du.user_id + WHERE du.debuglet_id = d.id AND u.uuid = sqlc.arg(user_uuid))); + +-- name: DeleteCommunityExperiment :execrows +DELETE FROM community_experiments WHERE community_experiments.id = sqlc.arg(id) +AND EXISTS (SELECT 1 FROM community_projects p JOIN communities c ON c.id = p.community_id + JOIN community_members m ON m.community_id = c.id JOIN users u ON u.id = m.user_id + WHERE p.id = community_experiments.project_id AND u.uuid = sqlc.arg(user_uuid) + AND (community_experiments.created_by = u.id OR c.created_by = u.id)) +AND NOT EXISTS (SELECT 1 FROM community_measurements cm WHERE cm.experiment_id = community_experiments.id); + +-- name: IsFundedCommunityMeasurement :one +SELECT EXISTS (SELECT 1 FROM community_measurements cm JOIN community_funding f + ON f.project_id = cm.project_id AND f.transaction_id = cm.measurement_id + JOIN community_projects p ON p.id = cm.project_id + JOIN community_members m ON m.community_id = p.community_id JOIN users u ON u.id = m.user_id + WHERE cm.project_id = sqlc.arg(project_id) AND cm.measurement_id = sqlc.arg(measurement_id) + AND u.uuid = sqlc.arg(user_uuid)); diff --git a/internal/dispatcher/database/queries/measurements.sql b/internal/dispatcher/database/queries/measurements.sql index bd27d834..106849f8 100644 --- a/internal/dispatcher/database/queries/measurements.sql +++ b/internal/dispatcher/database/queries/measurements.sql @@ -21,13 +21,19 @@ GROUP BY s.state; SELECT d.uuid, d.executor_id, d.order_id, d.state, CAST(COALESCE(json_extract(m.document, '$.label'), '') AS TEXT) AS label, CAST(COALESCE(json_extract(m.document, '$.program_name'), '') AS TEXT) AS program_name -FROM debuglets d JOIN debuglet_users du ON du.debuglet_id = d.id -JOIN users u ON u.id = du.user_id +FROM debuglets d LEFT JOIN measurement_requests m ON m.debuglet_id = d.id -WHERE d.transaction_id = sqlc.arg(batch_id) AND u.uuid = sqlc.arg(user_uuid) +WHERE d.transaction_id = sqlc.arg(batch_id) AND (EXISTS (SELECT 1 FROM debuglet_users du JOIN users u ON u.id = du.user_id + WHERE du.debuglet_id = d.id AND u.uuid = sqlc.arg(user_uuid)) OR EXISTS ( + SELECT 1 FROM community_measurements cm JOIN community_projects p ON p.id = cm.project_id + JOIN community_members member ON member.community_id = p.community_id JOIN users u ON u.id = member.user_id + WHERE cm.measurement_id = d.transaction_id AND u.uuid = sqlc.arg(user_uuid))) ORDER BY d.order_id, d.id LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset); -- name: MeasurementRunCount :one -SELECT COUNT(*) FROM debuglets d JOIN debuglet_users du ON du.debuglet_id = d.id -JOIN users u ON u.id = du.user_id -WHERE d.transaction_id = sqlc.arg(batch_id) AND u.uuid = sqlc.arg(user_uuid); +SELECT COUNT(*) FROM debuglets d +WHERE d.transaction_id = sqlc.arg(batch_id) AND (EXISTS (SELECT 1 FROM debuglet_users du JOIN users u ON u.id = du.user_id + WHERE du.debuglet_id = d.id AND u.uuid = sqlc.arg(user_uuid)) OR EXISTS ( + SELECT 1 FROM community_measurements cm JOIN community_projects p ON p.id = cm.project_id + JOIN community_members member ON member.community_id = p.community_id JOIN users u ON u.id = member.user_id + WHERE cm.measurement_id = d.transaction_id AND u.uuid = sqlc.arg(user_uuid))); diff --git a/internal/dispatcher/database/queries/transactions.sql b/internal/dispatcher/database/queries/transactions.sql index ac83ace5..dcc91128 100644 --- a/internal/dispatcher/database/queries/transactions.sql +++ b/internal/dispatcher/database/queries/transactions.sql @@ -96,12 +96,18 @@ LIMIT sqlc.arg(row_limit); -- name: ClaimDebugletOrder :execrows UPDATE debuglet_order SET debuglet_id = sqlc.narg(debuglet_id) -WHERE transaction_id = sqlc.arg(transaction_id) AND order_id = sqlc.arg(order_id) - AND debuglet_id IS NULL AND state = sqlc.arg(outstanding_state) +WHERE debuglet_order.transaction_id = sqlc.arg(transaction_id) AND debuglet_order.order_id = sqlc.arg(order_id) + AND debuglet_order.debuglet_id IS NULL AND debuglet_order.state = sqlc.arg(outstanding_state) AND EXISTS ( SELECT 1 FROM transactions WHERE transactions.id = debuglet_order.transaction_id AND transactions.status = sqlc.arg(paid_status) + ) + AND NOT EXISTS(SELECT 1 FROM community_topups d WHERE d.transaction_id=debuglet_order.transaction_id) + AND NOT EXISTS( + SELECT 1 FROM community_funding f WHERE f.transaction_id=debuglet_order.transaction_id + AND NOT EXISTS(SELECT 1 FROM community_members m + WHERE m.community_id=f.community_id AND m.user_id=f.actor_user_id) ); -- name: GetAdmittedRuns :many diff --git a/internal/dispatcher/database/transactions.sql.go b/internal/dispatcher/database/transactions.sql.go index a25c5939..22d3ffac 100644 --- a/internal/dispatcher/database/transactions.sql.go +++ b/internal/dispatcher/database/transactions.sql.go @@ -34,13 +34,19 @@ func (q *Queries) AddEarnings(ctx context.Context, arg AddEarningsParams) error const claimDebugletOrder = `-- name: ClaimDebugletOrder :execrows UPDATE debuglet_order SET debuglet_id = ?1 -WHERE transaction_id = ?2 AND order_id = ?3 - AND debuglet_id IS NULL AND state = ?4 +WHERE debuglet_order.transaction_id = ?2 AND debuglet_order.order_id = ?3 + AND debuglet_order.debuglet_id IS NULL AND debuglet_order.state = ?4 AND EXISTS ( SELECT 1 FROM transactions WHERE transactions.id = debuglet_order.transaction_id AND transactions.status = ?5 ) + AND NOT EXISTS(SELECT 1 FROM community_topups d WHERE d.transaction_id=debuglet_order.transaction_id) + AND NOT EXISTS( + SELECT 1 FROM community_funding f WHERE f.transaction_id=debuglet_order.transaction_id + AND NOT EXISTS(SELECT 1 FROM community_members m + WHERE m.community_id=f.community_id AND m.user_id=f.actor_user_id) + ) ` type ClaimDebugletOrderParams struct { diff --git a/internal/dispatcher/payments/community.go b/internal/dispatcher/payments/community.go new file mode 100644 index 00000000..0b756559 --- /dev/null +++ b/internal/dispatcher/payments/community.go @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package payments + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" + "time" + + "github.com/google/uuid" + "github.com/netsec-ethz/debuglet/internal/dispatcher/database" + "github.com/netsec-ethz/debuglet/internal/dispatcher/models" + "github.com/netsec-ethz/debuglet/internal/dispatcher/payments/sui" +) + +var ( + ErrCommunityNotFound = errors.New("community or project not found") + ErrCommunityOwner = errors.New("only the community owner may add funds") + ErrCommunityAllowanceDisabled = errors.New("community TEST spending requires the creator's configured usage allowance") + ErrCommunityDeposit = errors.New("a community deposit cannot be submitted as a measurement") + ErrCommunityNetwork = errors.New("community funds belong to a different Sui network") +) + +type CommunityFundsError struct{ Available, Required int64 } + +func (e *CommunityFundsError) Error() string { + return fmt.Sprintf("community has %d units available; this measurement requires %d", e.Available, e.Required) +} + +// CommunityBudget derives funds from receipts and existing order decisions; +// there is no second balance that can drift away from the payment records. +type CommunityBudget struct{ Funded, Reserved, Spent, Available int64 } + +func (p *PaymentHandler) CommunityCurrency() (string, string) { + if p.chainDisabled() { + return "TEST", "" + } + return "USDC", p.cfg.Sui.Network +} + +// CommunityBalance includes only the configured chain and asset. TEST reports +// the creator's allowance, including their individual and sponsored spending. +func (p *PaymentHandler) CommunityBalance(ctx context.Context, db database.DBTX, communityID, currency string) (CommunityBudget, error) { + if currency == "TEST" { + var owner uuid.UUID + err := db.QueryRowContext(ctx, `SELECT u.uuid FROM communities c JOIN users u ON u.id=c.created_by WHERE c.id=?`, communityID).Scan(&owner) + if errors.Is(err, sql.ErrNoRows) { + return CommunityBudget{}, ErrCommunityNotFound + } + if err != nil { + return CommunityBudget{}, err + } + if !p.AllowancesEnabled() { + return CommunityBudget{}, ErrCommunityAllowanceDisabled + } + a, err := readAllowance(ctx, database.New(db), owner) + return CommunityBudget{a.Granted, a.Reserved, a.Consumed, a.Remaining()}, err + } + if currency != "USDC" { + return CommunityBudget{}, ErrUnsupportedPaymentMethod + } + var b CommunityBudget + err := db.QueryRowContext(ctx, `SELECT + COALESCE((SELECT SUM(t.price) FROM community_topups d JOIN transactions t ON t.id=d.transaction_id + WHERE d.community_id=c.id AND d.network=? AND d.coin_type=? AND t.status=?),0), + COALESCE((SELECT SUM(o.price) FROM community_funding f JOIN transactions t ON t.id=f.transaction_id + JOIN debuglet_order o ON o.transaction_id=t.id + WHERE f.community_id=c.id AND f.currency='USDC' AND f.network=? AND t.status=? AND o.state=?),0), + COALESCE((SELECT SUM(o.price) FROM community_funding f JOIN debuglet_order o ON o.transaction_id=f.transaction_id + WHERE f.community_id=c.id AND f.currency='USDC' AND f.network=? AND o.state=?),0) + FROM communities c WHERE c.id=?`, + p.cfg.Sui.Network, sui.GetCoinType("USDC", p.cfg.Sui.Network), models.Paid, + p.cfg.Sui.Network, models.Paid, models.Outstanding, + p.cfg.Sui.Network, models.Credited, communityID).Scan(&b.Funded, &b.Reserved, &b.Spent) + if errors.Is(err, sql.ErrNoRows) { + return b, ErrCommunityNotFound + } + if err != nil { + return b, err + } + if b.Funded < 0 || b.Reserved < 0 || b.Spent < 0 || b.Reserved > math.MaxInt64-b.Spent { + return b, errors.New("community payment history exceeds the supported range") + } + b.Available = b.Funded - b.Reserved - b.Spent + return b, nil +} + +// CreateCommunityTopUpIn creates the ordinary owner-signed USDC intent and +// classifies it as a deposit in the same caller-owned SQL transaction. +func (p *PaymentHandler) CreateCommunityTopUpIn(db database.DBTX, actor uuid.UUID, communityID, transactionID string, amount int64, refundAddress string, ctx context.Context) (PaymentIntent, error) { + if err := p.requireChain("USDC", "fund community"); err != nil { + return PaymentIntent{}, err + } + if amount <= 0 || amount > 1<<53-1 { + return PaymentIntent{}, errors.New("top-up must be between 1 and 9007199254740991 micro-USDC") + } + if err := sui.ValidAddress(refundAddress); err != nil { + return PaymentIntent{}, err + } + var ownerID int64 + var owner uuid.UUID + err := db.QueryRowContext(ctx, `SELECT c.created_by,u.uuid FROM communities c JOIN users u ON u.id=c.created_by + WHERE c.id=? AND EXISTS(SELECT 1 FROM community_members m JOIN users a ON a.id=m.user_id WHERE m.community_id=c.id AND a.uuid=?)`, + communityID, actor).Scan(&ownerID, &owner) + if errors.Is(err, sql.ErrNoRows) { + return PaymentIntent{}, ErrCommunityNotFound + } + if err != nil { + return PaymentIntent{}, err + } + if actor != owner { + return PaymentIntent{}, ErrCommunityOwner + } + intent, err := p.CreatePaymentIntentIn(db, transactionID, amount, "USDC", "community-deposit:"+communityID, ctx) + if err != nil { + return PaymentIntent{}, err + } + if _, err := db.ExecContext(ctx, `INSERT INTO community_topups(transaction_id,community_id,created_by,refund_address,network,coin_type) VALUES(?,?,?,?,?,?)`, + transactionID, communityID, ownerID, refundAddress, p.cfg.Sui.Network, sui.GetCoinType("USDC", p.cfg.Sui.Network)); err != nil { + return PaymentIntent{}, err + } + if err := database.New(db).SetTransactionOwner(ctx, database.SetTransactionOwnerParams{TransactionID: transactionID, Uuid: actor}); err != nil { + return PaymentIntent{}, err + } + return intent, nil +} + +// CreateCommunityIntentIn reserves the whole quoted batch before its caller +// writes the order rows. The caller must hold one SQL transaction for all of +// these writes. The first UPDATE acquires SQLite's write lock before the +// balance and current membership are read. +func (p *PaymentHandler) CreateCommunityIntentIn(db database.DBTX, actor uuid.UUID, projectID, transactionID string, price int64, currency, hash string, ctx context.Context) (PaymentIntent, error) { + if price < 0 { + return PaymentIntent{}, errors.New("community price must not be negative") + } + if err := p.CheckPaymentMethod(currency); err != nil { + return PaymentIntent{}, err + } + if currency != "TEST" && currency != "USDC" { + return PaymentIntent{}, ErrUnsupportedPaymentMethod + } + if _, err := db.ExecContext(ctx, `UPDATE communities SET name=name WHERE id=(SELECT community_id FROM community_projects WHERE id=?)`, projectID); err != nil { + return PaymentIntent{}, err + } + var communityID string + var ownerID, actorID int64 + var owner uuid.UUID + err := db.QueryRowContext(ctx, `SELECT c.id,c.created_by,a.id,o.uuid FROM community_projects p + JOIN communities c ON c.id=p.community_id JOIN users o ON o.id=c.created_by + JOIN community_members m ON m.community_id=c.id JOIN users a ON a.id=m.user_id + WHERE p.id=? AND a.uuid=?`, projectID, actor).Scan(&communityID, &ownerID, &actorID, &owner) + if errors.Is(err, sql.ErrNoRows) { + return PaymentIntent{}, ErrCommunityNotFound + } + if err != nil { + return PaymentIntent{}, err + } + if _, err := db.ExecContext(ctx, `UPDATE transactions SET status=? + WHERE status=? AND expires_at studio.MaxBundleBytes { + return invalid("compiled bundle exceeds the 24 MiB limit") + } + switch kind { + case "workflow": + wf, err := studio.DecodeWorkflow(bytes.NewReader(document)) + if err != nil { + return invalid("document must be a workflow JSON object of at most 1 MiB") + } + program, err := workflow.Generate(wf) + if err != nil { + return invalid("document contains an invalid workflow") + } + canonical, _ := json.Marshal(wf) + if !hasBundle { + return canonical, nil, nil + } + var compiled studio.Bundle + if err := decodeCommunityBundle(bundle, &compiled); err != nil { + return invalid("bundle must be a workflow bundle JSON object") + } + if err := validateCommunityWorkflowBundle(compiled, canonical, program); err != nil { + return invalid(err.Error()) + } + encoded, _ := json.Marshal(compiled) + return canonical, encoded, nil + case "experiment": + experiment, err := studio.DecodeExperiment(bytes.NewReader(document)) + if err != nil { + return invalid("document must be an experiment JSON object of at most 4 MiB") + } + if len(workflow.ValidateExperiment(experiment)) != 0 { + return invalid("document contains an invalid experiment") + } + canonical, _ := json.Marshal(experiment) + if !hasBundle { + return canonical, nil, nil + } + var compiled studio.ExperimentBundle + if err := decodeCommunityBundle(bundle, &compiled); err != nil { + return invalid("bundle must be an experiment bundle JSON object") + } + if err := validateCommunityExperimentBundle(compiled, experiment, canonical); err != nil { + return invalid(err.Error()) + } + encoded, _ := json.Marshal(compiled) + return canonical, encoded, nil + default: + return invalid("kind must be workflow or experiment") + } +} + +func decodeCommunityBundle(raw []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if decoder.Decode(new(any)) != io.EOF { + return errors.New("expected one bundle JSON object") + } + return nil +} + +func validateCommunityWorkflowBundle(bundle studio.Bundle, canonical []byte, program workflow.Program) error { + embedded, err := json.Marshal(bundle.Workflow) + if err != nil || bundle.Version != 1 || + !bytes.Equal(embedded, canonical) || bundle.WorkflowJSON != string(canonical) || + bundle.WorkflowSHA256 != program.WorkflowSHA256 || + !slices.Equal(bundle.Destinations, program.Destinations) || bundle.RequireICMP != program.RequireICMP || + len(bundle.Wasm) < 8 || len(bundle.Wasm) > 16<<20 || + !bytes.Equal(bundle.Wasm[:8], []byte{0, 'a', 's', 'm', 1, 0, 0, 0}) || + bundle.WasmSHA256 != communityDocumentDigest(bundle.Wasm) { + return errors.New("compiled bundle does not match the saved workflow") + } + if bundle.Source != program.Source { + return errors.New("compiled bundle has different generated source; rebuild it with the current Studio and SDK") + } + return nil +} + +func validateCommunityExperimentBundle(bundle studio.ExperimentBundle, experiment workflow.Experiment, canonical []byte) error { + mismatch := errors.New("compiled bundle does not match the saved experiment") + embedded, err := json.Marshal(bundle.Experiment) + if err != nil || bundle.Version != 1 || bundle.Kind != "experiment_bundle" || + !bytes.Equal(embedded, canonical) || bundle.ExperimentJSON != string(canonical) || + bundle.ExperimentSHA256 != communityDocumentDigest(canonical) || + !bundle.RequireTCPListener || len(bundle.Participants) != len(experiment.Participants) { + return mismatch + } + programBytes, encodedProgramBytes := 0, 0 + for i, participant := range experiment.Participants { + compiled := bundle.Participants[i] + if compiled.ID != participant.ID || compiled.Label != participant.Label || compiled.Bundle == nil { + return mismatch + } + wf, program, err := workflow.GenerateParticipant(experiment, participant.ID) + if err != nil { + return mismatch + } + source, _ := json.Marshal(wf) + if err := validateCommunityWorkflowBundle(*compiled.Bundle, source, program); err != nil { + return err + } + programBytes += len(compiled.Bundle.Wasm) + encodedProgramBytes += base64.StdEncoding.EncodedLen(len(compiled.Bundle.Wasm)) + } + if bundle.ProgramBytes != programBytes || bundle.EncodedProgramBytes != encodedProgramBytes { + return mismatch + } + return nil +} + +func communityDocumentDigest(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/dispatcher/transport/api/community_document_test.go b/internal/dispatcher/transport/api/community_document_test.go new file mode 100644 index 00000000..1fc4e028 --- /dev/null +++ b/internal/dispatcher/transport/api/community_document_test.go @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package api + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/netsec-ethz/debuglet/internal/studio" + "github.com/netsec-ethz/debuglet/pkg/workflow" +) + +func communityTestWorkflow() workflow.Workflow { + return workflow.Workflow{ + Version: 1, Name: "Shared measurement", Entry: "request", + Nodes: []workflow.Node{{ID: "request", Block: "http", Inputs: map[string]any{"url": "https://example.test/"}}}, + Edges: []workflow.Edge{}, + } +} + +func communityTestJSON(t *testing.T, value any) json.RawMessage { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return raw +} + +func communityTestBundle(t *testing.T, wf workflow.Workflow, program workflow.Program) studio.Bundle { + t.Helper() + wasm := []byte{0, 'a', 's', 'm', 1, 0, 0, 0} + return studio.Bundle{ + Version: 1, Workflow: wf, WorkflowJSON: string(communityTestJSON(t, wf)), + Source: program.Source, WorkflowSHA256: program.WorkflowSHA256, + Wasm: wasm, WasmSHA256: communityDocumentDigest(wasm), + Destinations: program.Destinations, RequireICMP: program.RequireICMP, + } +} + +func TestCommunityDocumentCanonicalSourceAndOptionalBundle(t *testing.T) { + wf := communityTestWorkflow() + program, err := workflow.Generate(wf) + if err != nil { + t.Fatal(err) + } + canonical := communityTestJSON(t, wf) + indented, err := json.MarshalIndent(wf, "", " ") + if err != nil { + t.Fatal(err) + } + bundle := communityTestJSON(t, communityTestBundle(t, wf, program)) + for _, inputBundle := range []json.RawMessage{nil, []byte("null"), bundle} { + saved, compiled, err := validateCommunityDocument("workflow", indented, inputBundle) + if err != nil || !bytes.Equal(saved, canonical) { + t.Fatalf("saved source: %s, %v", saved, err) + } + if bytes.Equal(inputBundle, bundle) { + if !bytes.Equal(compiled, bundle) { + t.Fatal("changed the compiled bundle") + } + } else if compiled != nil { + t.Fatal("retained an absent bundle") + } + } +} + +func TestCommunityDocumentRejectsMalformedOrInvalidSource(t *testing.T) { + wf := communityTestJSON(t, communityTestWorkflow()) + for name, test := range map[string]struct { + kind string + raw []byte + }{ + "unknown kind": {"other", wf}, + "unknown field": {"workflow", bytes.Replace(wf, []byte(`"version":1`), []byte(`"version":1,"extra":true`), 1)}, + "unknown node field": {"workflow", bytes.Replace(wf, []byte(`"block":"http"`), []byte(`"block":"http","extra":true`), 1)}, + "trailing object": {"workflow", append(append([]byte{}, wf...), []byte(" {}")...)}, + "null source": {"workflow", []byte("null")}, + "invalid graph": {"workflow", bytes.Replace(wf, []byte(`"block":"http"`), []byte(`"block":"missing"`), 1)}, + "oversize workflow": {"workflow", []byte(strings.Repeat(" ", studio.MaxWorkflowBytes+1))}, + "oversize experiment": {"experiment", []byte(strings.Repeat(" ", studio.MaxExperimentBytes+1))}, + } { + t.Run(name, func(t *testing.T) { + assertCommunityDocumentRejected(t, test.kind, test.raw, nil) + }) + } +} + +func TestCommunityWorkflowBundleRejectsMismatchedFields(t *testing.T) { + for name, change := range map[string]func(*studio.Bundle){ + "version": func(b *studio.Bundle) { b.Version = 2 }, + "source": func(b *studio.Bundle) { b.Source += "\n" }, + "source hash": func(b *studio.Bundle) { b.WorkflowSHA256 = strings.Repeat("0", 64) }, + "embedded graph": func(b *studio.Bundle) { + b.Workflow.Name = "Another workflow" + }, + "raw graph": func(b *studio.Bundle) { b.WorkflowJSON += "\n" }, + "destination": func(b *studio.Bundle) { b.Destinations = []string{"other.test"} }, + "icmp": func(b *studio.Bundle) { b.RequireICMP = true }, + "wasm hash": func(b *studio.Bundle) { b.WasmSHA256 = strings.Repeat("0", 64) }, + "wasm magic": func(b *studio.Bundle) { b.Wasm[0] = 1; b.WasmSHA256 = communityDocumentDigest(b.Wasm) }, + "wasm version": func(b *studio.Bundle) { b.Wasm[4] = 2; b.WasmSHA256 = communityDocumentDigest(b.Wasm) }, + "short wasm": func(b *studio.Bundle) { b.Wasm = b.Wasm[:4]; b.WasmSHA256 = communityDocumentDigest(b.Wasm) }, + } { + t.Run(name, func(t *testing.T) { + wf := communityTestWorkflow() + program, err := workflow.Generate(wf) + if err != nil { + t.Fatal(err) + } + bundle := communityTestBundle(t, wf, program) + change(&bundle) + assertCommunityDocumentRejected(t, "workflow", communityTestJSON(t, wf), communityTestJSON(t, bundle)) + }) + } + wf := communityTestWorkflow() + program, err := workflow.Generate(wf) + if err != nil { + t.Fatal(err) + } + bundle := communityTestJSON(t, communityTestBundle(t, wf, program)) + for _, malformed := range []json.RawMessage{ + bytes.Replace(bundle, []byte(`"version":1`), []byte(`"version":1,"extra":true`), 1), + append(append([]byte{}, bundle...), []byte(" {}")...), + []byte(`[]`), + } { + assertCommunityDocumentRejected(t, "workflow", communityTestJSON(t, wf), malformed) + } +} + +func communityTestExperiment(t *testing.T) (workflow.Experiment, studio.ExperimentBundle) { + t.Helper() + experiment := workflow.Experiment{ + Version: 1, Kind: "experiment", Name: "Shared experiment", + Participants: []workflow.Participant{ + {ID: "first", Label: "First", Workflow: communityTestWorkflow()}, + {ID: "second", Label: "Second", Workflow: communityTestWorkflow()}, + }, + Channels: []workflow.Channel{{ID: "reports", From: "first", To: "second", Type: "number"}}, + } + raw := communityTestJSON(t, experiment) + bundle := studio.ExperimentBundle{ + Version: 1, Kind: "experiment_bundle", Experiment: experiment, + ExperimentJSON: string(raw), ExperimentSHA256: communityDocumentDigest(raw), + RequireTCPListener: true, + } + for _, participant := range experiment.Participants { + wf, program, err := workflow.GenerateParticipant(experiment, participant.ID) + if err != nil { + t.Fatal(err) + } + compiled := communityTestBundle(t, wf, program) + bundle.Participants = append(bundle.Participants, studio.ParticipantBundle{ID: participant.ID, Label: participant.Label, Bundle: &compiled}) + bundle.ProgramBytes += len(compiled.Wasm) + bundle.EncodedProgramBytes += base64.StdEncoding.EncodedLen(len(compiled.Wasm)) + } + return experiment, bundle +} + +func TestCommunityExperimentBundlePreservesGeneratedParticipantContext(t *testing.T) { + experiment, bundle := communityTestExperiment(t) + raw, encoded := communityTestJSON(t, experiment), communityTestJSON(t, bundle) + saved, compiled, err := validateCommunityDocument("experiment", raw, encoded) + if err != nil || !bytes.Equal(raw, saved) || !bytes.Equal(encoded, compiled) { + t.Fatalf("valid experiment: %v", err) + } + for name, change := range map[string]func(*studio.ExperimentBundle){ + "kind": func(b *studio.ExperimentBundle) { b.Kind = "other" }, + "source": func(b *studio.ExperimentBundle) { b.Experiment.Name = "Other experiment" }, + "source hash": func(b *studio.ExperimentBundle) { b.ExperimentSHA256 = strings.Repeat("0", 64) }, + "raw source": func(b *studio.ExperimentBundle) { b.ExperimentJSON += "\n" }, + "listener": func(b *studio.ExperimentBundle) { b.RequireTCPListener = false }, + "program bytes": func(b *studio.ExperimentBundle) { b.ProgramBytes++ }, + "encoded bytes": func(b *studio.ExperimentBundle) { b.EncodedProgramBytes++ }, + "missing member": func(b *studio.ExperimentBundle) { b.Participants = b.Participants[:1] }, + "duplicate member": func(b *studio.ExperimentBundle) { + b.Participants[1] = b.Participants[0] + }, + "missing bundle": func(b *studio.ExperimentBundle) { b.Participants[0].Bundle = nil }, + "wrong label": func(b *studio.ExperimentBundle) { b.Participants[0].Label = "Other" }, + "crossed programs": func(b *studio.ExperimentBundle) { + b.Participants[0].Bundle, b.Participants[1].Bundle = b.Participants[1].Bundle, b.Participants[0].Bundle + }, + "context fingerprint": func(b *studio.ExperimentBundle) { + b.Participants[0].Bundle.Workflow.Experiment.Fingerprint = strings.Repeat("0", 64) + }, + } { + t.Run(name, func(t *testing.T) { + experiment, bundle := communityTestExperiment(t) + change(&bundle) + assertCommunityDocumentRejected(t, "experiment", communityTestJSON(t, experiment), communityTestJSON(t, bundle)) + }) + } +} + +func TestCommunityBundleSourceMismatchExplainsHowToRebuild(t *testing.T) { + wf := communityTestWorkflow() + program, err := workflow.Generate(wf) + if err != nil { + t.Fatal(err) + } + bundle := communityTestBundle(t, wf, program) + bundle.Source += "\n" + experiment, group := communityTestExperiment(t) + group.Participants[1].Bundle.Source += "\n" + for _, test := range []struct { + kind string + document, bundle any + }{ + {"workflow", wf, bundle}, + {"experiment", experiment, group}, + } { + t.Run(test.kind, func(t *testing.T) { + document, compiled, err := validateCommunityDocument(test.kind, communityTestJSON(t, test.document), communityTestJSON(t, test.bundle)) + if err == nil || document != nil || compiled != nil { + t.Fatal("accepted changed generated source") + } + status, body, _ := errorEnvelope(err) + if status != http.StatusBadRequest || body.Code != CodeInvalidRequest || + !strings.Contains(body.Message, "rebuild it with the current Studio and SDK") { + t.Fatalf("missing actionable rebuild error: %d %+v", status, body) + } + }) + } +} + +func assertCommunityDocumentRejected(t *testing.T, kind string, document, bundle json.RawMessage) { + t.Helper() + saved, compiled, err := validateCommunityDocument(kind, document, bundle) + if err == nil || saved != nil || compiled != nil { + t.Fatal("accepted invalid saved source or compiled bundle") + } + status, body, _ := errorEnvelope(err) + if status != http.StatusBadRequest || body.Code != CodeInvalidRequest { + t.Fatalf("error envelope = %d %s", status, body.Code) + } +} diff --git a/internal/dispatcher/transport/api/community_funding_test.go b/internal/dispatcher/transport/api/community_funding_test.go new file mode 100644 index 00000000..ffde4c9a --- /dev/null +++ b/internal/dispatcher/transport/api/community_funding_test.go @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package api + +import ( + "database/sql" + "net/http" + "reflect" + "testing" + "time" + + "github.com/google/uuid" + "github.com/netsec-ethz/debuglet/internal/dispatcher/database" + "github.com/netsec-ethz/debuglet/internal/dispatcher/models" + "github.com/netsec-ethz/debuglet/pkg/wire" + "github.com/netsec-ethz/debuglet/pkg/workflow" +) + +type communityFundingFixture struct { + f *ccFixture + ownerID, ownerToken, memberID, memberToken, outsiderToken string + community wire.Community + project wire.CommunityProject +} + +func newCommunityFundingFixture(t *testing.T, allowances bool) communityFundingFixture { + t.Helper() + f := ccNewFixtureWith + if allowances { + f = alNewFixture + } + s := communityFundingFixture{f: f(t)} + owner, ownerToken, _ := authAccount(t, s.f, "Community creator") + member, memberToken, _ := authAccount(t, s.f, "Member") + _, outsiderToken, _ := authAccount(t, s.f, "Outsider") + s.ownerID, s.ownerToken = owner.ID, ownerToken + s.memberID, s.memberToken, s.outsiderToken = member.ID, memberToken, outsiderToken + s.community = communityCreate(t, s.f, s.ownerToken) + communityJoin(t, s.f, s.ownerToken, s.memberToken, s.community.ID) + s.project = communityProject(t, s.f, s.ownerToken, s.community.ID) + if allowances { + authGrantOperator(t, s.f, owner.ID) + for _, grant := range []struct{ account, body string }{ + {owner.ID, `{"amount":"5000","reason":"shared research","idempotency_key":"owner"}`}, + {member.ID, `{"amount":"3000","reason":"personal research","idempotency_key":"member"}`}, + } { + if status, _, _ := alGrant(t, s.f, s.ownerToken, grant.account, grant.body); status != http.StatusCreated { + t.Fatalf("grant: %d", status) + } + } + } + return s +} + +func (s communityFundingFixture) request() PaymentIntentRequest { + return PaymentIntentRequest{CommunityProjectID: s.project.ID, PaymentMethod: "TEST", Debuglets: []DebugletRequest{iaDebuglet(1, 1000, 2000)}} +} + +type communityFundingIntent struct { + Method string `json:"method"` + Currency string `json:"currency"` + Intent DummyIntent `json:"intent"` +} + +func (s communityFundingFixture) intent(t *testing.T, request PaymentIntentRequest) communityFundingIntent { + t.Helper() + var response communityFundingIntent + communityRequest(t, s.f, s.memberToken, http.MethodPut, "/payment/intent", request, http.StatusOK, &response) + if response.Method != "COMMUNITY" || response.Currency != "TEST" || response.Intent.TransactionID == "" { + t.Fatalf("funding response: %+v", response) + } + return response +} + +func (s communityFundingFixture) assertAllowances(t *testing.T, owner, member wire.Allowance) { + t.Helper() + for _, want := range []struct { + token string + allowance wire.Allowance + }{{s.ownerToken, owner}, {s.memberToken, member}} { + if status, _, got := alAllowance(t, s.f, want.token); status != http.StatusOK || got != want.allowance { + t.Fatalf("allowance %d: %+v, want %+v", status, got, want.allowance) + } + } +} + +func TestCommunityFundingIntentKeepsActorAndSponsorSeparate(t *testing.T) { + s := newCommunityFundingFixture(t, true) + request := s.request() + response := s.intent(t, request) + s.assertAllowances(t, alWant("5000", "2000", "0", "3000"), alWant("3000", "0", "0", "3000")) + actor, err := s.f.queries.GetTransactionOwnerUUID(t.Context(), response.Intent.TransactionID) + if err != nil || actor.String() != s.memberID { + t.Fatalf("intent owner: %s, %v", actor, err) + } + var wallet communityWalletResponse + communityRequest(t, s.f, s.memberToken, http.MethodGet, "/communities/"+s.community.ID+"/wallet", nil, 200, &wallet) + if !wallet.Enabled || wallet.Currency != "TEST" || wallet.Funded != "5000" || wallet.Reserved != "2000" || wallet.Available != "3000" { + t.Fatalf("wallet: %+v", wallet) + } + var project, sponsor, recordedActor string + if err := s.f.db.QueryRow(`SELECT f.project_id,s.uuid,a.uuid FROM community_funding f +JOIN users s ON s.id=f.sponsor_user_id JOIN users a ON a.id=f.actor_user_id WHERE f.transaction_id=?`, + response.Intent.TransactionID).Scan(&project, &sponsor, &recordedActor); err != nil { + t.Fatal(err) + } + if project != s.project.ID || sponsor != s.ownerID || recordedActor != s.memberID { + t.Fatalf("funding attribution: %s %s %s", project, sponsor, recordedActor) + } + var ids []string + communityRequest(t, s.f, s.memberToken, http.MethodPut, "/debuglet", SubmitDebugletsRequest{ + Debuglets: request.Debuglets, TransactionId: response.Intent.TransactionID, AuthKey: response.Intent.AuthKey, + }, 200, &ids) + if len(ids) != 1 || iaCount(t, s.f.db, "community_measurements") != 1 { + t.Fatal("sponsored run was not admitted and shared") + } +} + +func TestCommunityFundingRejectsOutsidersAndRemovedMembers(t *testing.T) { + s := newCommunityFundingFixture(t, true) + request := s.request() + before := ecRowCounts(t, s.f) + communityRequest(t, s.f, s.outsiderToken, http.MethodPut, "/payment/intent", request, 404, nil) + communityRequest(t, s.f, s.outsiderToken, http.MethodGet, "/communities/"+s.community.ID+"/wallet", nil, 404, nil) + if after := ecRowCounts(t, s.f); !reflect.DeepEqual(before, after) { + t.Fatalf("outsider wrote payment rows: %v -> %v", before, after) + } + response := s.intent(t, request) + submission := SubmitDebugletsRequest{Debuglets: request.Debuglets, TransactionId: response.Intent.TransactionID, AuthKey: response.Intent.AuthKey} + communityRequest(t, s.f, s.outsiderToken, http.MethodPut, "/debuglet", submission, 401, nil) + communityRequest(t, s.f, s.ownerToken, http.MethodDelete, "/communities/"+s.community.ID+"/members/"+s.memberID, nil, 204, nil) + before = ecRowCounts(t, s.f) + communityRequest(t, s.f, s.memberToken, http.MethodPut, "/debuglet", submission, 404, nil) + communityRequest(t, s.f, s.memberToken, http.MethodPut, "/payment/intent", request, 404, nil) + if after := ecRowCounts(t, s.f); !reflect.DeepEqual(before, after) { + t.Fatalf("removed member wrote payment rows: %v -> %v", before, after) + } + changed, err := s.f.queries.ClaimDebugletOrder(t.Context(), database.ClaimDebugletOrderParams{ + TransactionID: response.Intent.TransactionID, OrderID: request.Debuglets[0].OrderID, + DebugletID: sql.NullInt64{}, PaidStatus: int64(models.Paid), OutstandingState: int64(models.Outstanding), + }) + if err != nil || changed != 0 { + t.Fatalf("SQL admission ignored membership removal: %d, %v", changed, err) + } +} + +func TestCommunityFundingSavedProgramMismatchRollsBack(t *testing.T) { + s := newCommunityFundingFixture(t, true) + wf := communityTestWorkflow() + program, err := workflow.Generate(wf) + if err != nil { + t.Fatal(err) + } + var saved wire.CommunityExperiment + communityRequest(t, s.f, s.ownerToken, http.MethodPost, "/community-projects/"+s.project.ID+"/experiments", + communityExperimentRequest{Name: "Shared HTTP", Kind: "workflow", Document: communityTestJSON(t, wf), + Bundle: communityTestJSON(t, communityTestBundle(t, wf, program))}, 201, &saved) + request := s.request() + request.CommunityExperimentID, request.CommunityExperimentRevision = saved.ID, &saved.Revision + before := ecRowCounts(t, s.f) + var envelope ErrorResponse + communityRequest(t, s.f, s.memberToken, http.MethodPut, "/payment/intent", request, 400, &envelope) + if envelope.Code != CodeIntentMismatch { + t.Fatalf("mismatch code: %s", envelope.Code) + } + if after := ecRowCounts(t, s.f); !reflect.DeepEqual(before, after) || iaCount(t, s.f.db, "community_funding") != 0 || iaCount(t, s.f.db, "community_measurements") != 0 { + t.Fatalf("mismatch retained payment rows: %v -> %v", before, after) + } + s.assertAllowances(t, alWant("5000", "0", "0", "5000"), alWant("3000", "0", "0", "3000")) +} + +func TestCommunityFundingDisabledAllowanceCreatesNoCredits(t *testing.T) { + s := newCommunityFundingFixture(t, false) + var wallet communityWalletResponse + communityRequest(t, s.f, s.memberToken, http.MethodGet, "/communities/"+s.community.ID+"/wallet", nil, 200, &wallet) + if wallet.Enabled || wallet.Funded != "0" || wallet.Available != "0" { + t.Fatalf("disabled allowance shown as usable money: %+v", wallet) + } + before := ecRowCounts(t, s.f) + var envelope ErrorResponse + communityRequest(t, s.f, s.memberToken, http.MethodPut, "/payment/intent", s.request(), 503, &envelope) + if envelope.Code != CodeUnavailable { + t.Fatalf("disabled allowance code: %s", envelope.Code) + } + if after := ecRowCounts(t, s.f); !reflect.DeepEqual(before, after) || iaCount(t, s.f.db, "allowance_grants") != 0 { + t.Fatalf("disabled allowance created credit: %v -> %v", before, after) + } +} + +func TestCommunityFundingDepositCannotBeSubmittedOrDiscovered(t *testing.T) { + s := newCommunityFundingFixture(t, true) + id := uuid.NewString() + if _, err := s.f.queries.CreateTransaction(t.Context(), database.CreateTransactionParams{ + ID: id, AuthKey: "deposit-key", Price: 7, Currency: "USDC", Method: "SUI", + ExpiresAt: models.NewUTCTime(time.Now().Add(time.Hour)), Status: int64(models.Paid), Hash: "community-deposit:" + s.community.ID, + }); err != nil { + t.Fatal(err) + } + if err := s.f.queries.SetTransactionOwner(t.Context(), database.SetTransactionOwnerParams{TransactionID: id, Uuid: uuid.MustParse(s.ownerID)}); err != nil { + t.Fatal(err) + } + if _, err := s.f.db.Exec(`INSERT INTO community_topups(transaction_id,community_id,created_by,refund_address,network,coin_type) +SELECT ?,?,id,'0x1','testnet','test-coin' FROM users WHERE uuid=?`, id, s.community.ID, s.ownerID); err != nil { + t.Fatal(err) + } + body := SubmitDebugletsRequest{Debuglets: s.request().Debuglets, TransactionId: id, AuthKey: "deposit-key"} + var refusal ErrorResponse + communityRequest(t, s.f, s.ownerToken, http.MethodPut, "/debuglet", body, 400, &refusal) + if refusal.Code != CodeIntentMismatch { + t.Fatalf("deposit refusal: %+v", refusal) + } + var known, unknown ErrorResponse + communityRequest(t, s.f, s.outsiderToken, http.MethodPut, "/debuglet", body, 401, &known) + body.TransactionId = uuid.NewString() + communityRequest(t, s.f, s.outsiderToken, http.MethodPut, "/debuglet", body, 401, &unknown) + if !reflect.DeepEqual(known, unknown) { + t.Fatalf("foreign deposit differs from unknown: %+v / %+v", known, unknown) + } + if iaCount(t, s.f.db, "debuglets") != 0 { + t.Fatal("deposit admitted a run") + } +} + +func TestCommunityFundingUSDCSubmissionHonorsDisabledChain(t *testing.T) { + s := newCommunityFundingFixture(t, true) + request := s.request() + response := s.intent(t, request) + if _, err := s.f.db.Exec("UPDATE transactions SET method='COMMUNITY',currency='USDC' WHERE id=?", response.Intent.TransactionID); err != nil { + t.Fatal(err) + } + if _, err := s.f.db.Exec("UPDATE community_funding SET currency='USDC' WHERE transaction_id=?", response.Intent.TransactionID); err != nil { + t.Fatal(err) + } + if _, err := s.f.db.Exec("UPDATE debuglet_order SET currency='USDC' WHERE transaction_id=?", response.Intent.TransactionID); err != nil { + t.Fatal(err) + } + var refusal ErrorResponse + communityRequest(t, s.f, s.memberToken, http.MethodPut, "/debuglet", SubmitDebugletsRequest{ + Debuglets: request.Debuglets, TransactionId: response.Intent.TransactionID, AuthKey: response.Intent.AuthKey, + }, 503, &refusal) + if refusal.Code != CodePaymentsDisabled { + t.Fatalf("sponsored USDC classified incorrectly: %+v", refusal) + } + if iaCount(t, s.f.db, "debuglets") != 0 { + t.Fatal("disabled USDC admitted a run") + } +} + +func TestCommunityFundingHistoryLimitRollsBackAndExpiredIntentReleasesSlot(t *testing.T) { + s := newCommunityFundingFixture(t, true) + request := s.request() + first := s.intent(t, request) + tx, err := s.f.db.BeginTx(t.Context(), nil) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + // Fill the remaining history with zero-price pending intents, without + // spending the sponsor's grant or relying on admission-rate limits. + if _, err := tx.Exec(`WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1023) +INSERT INTO transactions(id,auth_key,price,currency,method,expires_at,status,hash) +SELECT 'history-'||x,'',0,'TEST','TEST',?,?,'history' FROM n`, + models.NewUTCTime(time.Now().Add(time.Hour)), models.Paid); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(`INSERT INTO transaction_users(transaction_id,user_id) +SELECT t.id,u.id FROM transactions t JOIN users u ON u.uuid=? WHERE t.id LIKE 'history-%'`, s.memberID); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(`INSERT INTO community_funding(transaction_id,community_id,project_id,sponsor_user_id,actor_user_id,currency,network) +SELECT t.id,?,?,o.id,a.id,'TEST','' FROM transactions t JOIN users o ON o.uuid=? JOIN users a ON a.uuid=? +WHERE t.id LIKE 'history-%'`, s.community.ID, s.project.ID, s.ownerID, s.memberID); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(`INSERT INTO debuglet_order(transaction_id,order_id,executor_id,price,currency,state,refund_address) +SELECT id,1,?,0,'TEST',?,'' FROM transactions WHERE id LIKE 'history-%'`, ccExecutorID, models.Outstanding); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(`INSERT INTO community_measurements(project_id,measurement_id,shared_by,shared_at) +SELECT ?,t.id,u.id,unixepoch() FROM transactions t JOIN users u ON u.uuid=? WHERE t.id LIKE 'history-%'`, s.project.ID, s.memberID); err != nil { + t.Fatal(err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + before := ecRowCounts(t, s.f) + communityRequest(t, s.f, s.memberToken, http.MethodPut, "/payment/intent", request, 409, nil) + if after := ecRowCounts(t, s.f); !reflect.DeepEqual(before, after) || iaCount(t, s.f.db, "community_funding") != 1024 || iaCount(t, s.f.db, "community_measurements") != 1024 { + t.Fatalf("full history retained refused reservation: %v -> %v", before, after) + } + s.assertAllowances(t, alWant("5000", "2000", "0", "3000"), alWant("3000", "0", "0", "3000")) + if _, err := s.f.db.Exec("UPDATE transactions SET expires_at=? WHERE id=?", + models.NewUTCTime(time.Now().Add(-time.Hour)), first.Intent.TransactionID); err != nil { + t.Fatal(err) + } + s.intent(t, request) + if iaCount(t, s.f.db, "community_measurements") != 1024 { + t.Fatal("expired intent failed to release its history slot") + } + var retained int + if err := s.f.db.QueryRow("SELECT COUNT(*) FROM community_measurements WHERE measurement_id=?", first.Intent.TransactionID).Scan(&retained); err != nil || retained != 0 { + t.Fatalf("expired unadmitted measurement retained: %d, %v", retained, err) + } + var expired ErrorResponse + communityRequest(t, s.f, s.memberToken, http.MethodPut, "/debuglet", SubmitDebugletsRequest{ + Debuglets: request.Debuglets, TransactionId: first.Intent.TransactionID, AuthKey: first.Intent.AuthKey, + }, 400, &expired) + if expired.Code != CodePaymentIncomplete { + t.Fatalf("expired intent remains uploadable: %+v", expired) + } + s.assertAllowances(t, alWant("5000", "2000", "0", "3000"), alWant("3000", "0", "0", "3000")) +} diff --git a/internal/dispatcher/transport/api/community_read.go b/internal/dispatcher/transport/api/community_read.go new file mode 100644 index 00000000..61a4b354 --- /dev/null +++ b/internal/dispatcher/transport/api/community_read.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package api + +import ( + "database/sql" + "errors" + "net/http" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/netsec-ethz/debuglet/internal/dispatcher/database" +) + +// authorizeDebugletRead admits an owner or a current member of a community +// where the measurement was shared. Its result reports owner-only management +// authority; sharing never grants cancellation, deletion or payment access. +func (h *Handler) authorizeDebugletRead(c echo.Context, id uuid.UUID) (bool, error) { + established, err := requireCaller(c) + if err != nil { + return false, err + } + if established.unrestricted() { + return true, nil + } + q := database.New(h.db) + ctx := c.Request().Context() + owner, err := q.GetDebugletOwnerUUID(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return false, debugletNotFound() + } + if err != nil { + return false, apiErrorFrom(http.StatusInternalServerError, CodeInternal, "failed to read the run owner", err) + } + if owner == established.UserUUID { + return !established.API || credentialAllows(established.Scopes, http.MethodDelete, "/debuglet"), nil + } + allowed, err := q.CanReadCommunityDebuglet(ctx, database.CanReadCommunityDebugletParams{ + RunUuid: id, UserUuid: established.UserUUID, + }) + if err != nil { + return false, apiErrorFrom(http.StatusInternalServerError, CodeInternal, "failed to read measurement access", err) + } + if !allowed { + return false, debugletNotFound() + } + return false, nil +} diff --git a/internal/dispatcher/transport/api/community_read_test.go b/internal/dispatcher/transport/api/community_read_test.go new file mode 100644 index 00000000..8d20dba5 --- /dev/null +++ b/internal/dispatcher/transport/api/community_read_test.go @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package api + +import ( + "bytes" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/netsec-ethz/debuglet/pkg/client" +) + +type sharedReadFixture struct { + f *ccFixture + owner, member *client.Client + memberID, memberToken, creatorToken string + outsiderToken, projectID, secondProjectID string + communityID string + submission client.Submission +} + +func newSharedReadFixture(t *testing.T) sharedReadFixture { + t.Helper() + f := ccNewFixtureWith(t) + alice, _, owner := authAccount(t, f, "run owner") + bob, memberToken, member := authAccount(t, f, "community member") + carol, creatorToken, _ := authAccount(t, f, "community creator") + _, outsiderToken, _ := authAccount(t, f, "outsider") + requests := []client.Request{ccRequest([]string{"first"}), ccRequest([]string{"second"})} + requests[1].OrderID++ + batch, err := client.Prepare(requests) + if err != nil { + t.Fatal(err) + } + submission, err := owner.SubmitTEST(f.ctx, batch) + if err != nil { + t.Fatal(err) + } + s := sharedReadFixture{f: f, owner: owner, member: member, memberID: bob.ID, + memberToken: memberToken, creatorToken: creatorToken, outsiderToken: outsiderToken, + communityID: uuid.NewString(), projectID: uuid.NewString(), secondProjectID: uuid.NewString(), + submission: submission} + exec := func(query string, args ...any) { + t.Helper() + if _, err := f.db.ExecContext(f.ctx, query, args...); err != nil { + t.Fatal(err) + } + } + exec("INSERT INTO communities(id,name,description,created_by,created_at) SELECT ?, 'Shared measurements', '', id, unixepoch() FROM users WHERE uuid=?", s.communityID, carol.ID) + for _, account := range []string{alice.ID, bob.ID, carol.ID} { + exec("INSERT INTO community_members(community_id,user_id,joined_at) SELECT ?,id,unixepoch() FROM users WHERE uuid=?", s.communityID, account) + } + // The same measurement may appear in several projects, but every run + // remains one row when the shared measurement is paginated. + for _, project := range []string{s.projectID, s.secondProjectID} { + exec("INSERT INTO community_projects(id,community_id,name,description,created_by,created_at) SELECT ?,?,'Project','',id,unixepoch() FROM users WHERE uuid=?", project, s.communityID, carol.ID) + exec("INSERT INTO community_measurements(project_id,measurement_id,shared_by,shared_at) SELECT ?,?,id,unixepoch() FROM users WHERE uuid=?", project, submission.TransactionID, alice.ID) + } + f.seedLogs(submission.IDs[0], [][]byte{[]byte("shared output")}) + return s +} + +func TestCommunityMeasurementReadAccessDoesNotGrantManagement(t *testing.T) { + s := newSharedReadFixture(t) + f := s.f + id := s.submission.IDs[0] + for offset := int64(0); offset < 3; offset++ { + page, err := s.member.MeasurementRuns(f.ctx, s.submission.TransactionID, 1, offset) + if err != nil || page.Total != 2 || page.CanManage || page.Offset != offset { + t.Fatalf("shared page %d: %+v, %v", offset, page, err) + } + if offset < 2 && (len(page.Runs) != 1 || page.Runs[0].RunID != s.submission.IDs[offset]) { + t.Fatalf("shared pagination duplicated or lost a run: %+v", page.Runs) + } + if offset == 2 && len(page.Runs) != 0 { + t.Fatal("expected empty last page") + } + owned, err := s.owner.MeasurementRuns(f.ctx, s.submission.TransactionID, 1, offset) + if err != nil || !owned.CanManage { + t.Fatalf("original owner lost management at offset %d: %+v, %v", offset, owned, err) + } + } + original, err := s.owner.RunDetail(f.ctx, id) + if err != nil || !original.CanManage { + t.Fatalf("owner detail: %+v, %v", original, err) + } + shared, err := s.member.RunDetail(f.ctx, id) + if err != nil || shared.CanManage || shared.Cost == nil { + t.Fatalf("shared detail: %+v, %v", shared, err) + } + shared.CanManage = true + shared.Timing.ObservedAt = original.Timing.ObservedAt + if !reflect.DeepEqual(original, shared) { + t.Fatal("sharing changed retained configuration, admission or per-run cost") + } + originalResult, err := s.owner.Export(f.ctx, id) + if err != nil { + t.Fatal(err) + } + sharedResult, err := s.member.Export(f.ctx, id) + if err != nil { + t.Fatal(err) + } + sharedResult.Timing.ObservedAt = originalResult.Timing.ObservedAt + if !reflect.DeepEqual(originalResult, sharedResult) { + t.Fatal("sharing changed the export or its provenance") + } + for _, token := range []string{s.memberToken, s.creatorToken} { + for _, suffix := range []string{"detail", "logs", "state", "result", "recovery"} { + status, _ := authAs(t, f, token, http.MethodGet, "/debuglet/"+id+"/"+suffix, nil) + if status != http.StatusOK { + t.Fatalf("shared %s: %d", suffix, status) + } + } + for _, route := range []string{"/debuglet/" + id + "/payload", "/payment/" + s.submission.TransactionID + "/status"} { + method := http.MethodGet + if route == "/debuglet/"+id+"/payload" { + method = http.MethodDelete + } + if status, _ := authAs(t, f, token, method, route, nil); status != http.StatusNotFound { + t.Fatalf("shared member gained private mutation/payment access: %s %s = %d", method, route, status) + } + } + } + if err := s.member.Cancel(f.ctx, id, ccExecutorID); !authIsNotFound(err) { + t.Fatalf("member cancellation: %v", err) + } + if _, err := s.member.RetryTEST(f.ctx, id, uuid.NewString(), retryBatch(t)); !authIsNotFound(err) { + t.Fatalf("member retry: %v", err) + } + if f.peer.abortCount() != 0 { + t.Fatal("denied cancellation contacted executor") + } + // Reading a stored cancellation is shared; creating one remains the + // original submitter's action. + if err := s.owner.Cancel(f.ctx, id, ccExecutorID); err != nil { + t.Fatal(err) + } + if _, err := s.member.Cancellation(f.ctx, id); err != nil || f.peer.abortCount() != 1 { + t.Fatalf("shared cancellation observation: %v", err) + } + private := f.submit(s.owner, []string{"private"}) + if _, err := s.member.RunDetail(f.ctx, private.IDs[0]); !authIsNotFound(err) { + t.Fatalf("unshared run disclosed: %v", err) + } + personal, err := s.member.Measurements(f.ctx, client.MeasurementOptions{}) + if err != nil || personal.Total != 0 { + t.Fatalf("sharing changed the personal list: %+v, %v", personal, err) + } + for _, route := range []string{"/measurements/" + s.submission.TransactionID, "/debuglet/" + id + "/detail", "/debuglet/" + id + "/logs", "/debuglet/" + id + "/state", "/debuglet/" + id + "/result", "/debuglet/" + id + "/recovery", "/debuglet/" + id + "/cancellation"} { + if status, _ := authAs(t, f, s.outsiderToken, http.MethodGet, route, nil); status != http.StatusNotFound { + t.Fatalf("outsider %s: %d", route, status) + } + } +} + +func TestCommunityReadsRespectRemovalUnsharingAndPayloadLimits(t *testing.T) { + s := newSharedReadFixture(t) + f := s.f + id := s.submission.IDs[0] + if _, err := f.db.ExecContext(f.ctx, "UPDATE debuglet_logs SET output=zeroblob(?) WHERE debuglet_id=(SELECT id FROM debuglets WHERE uuid=?)", 25<<20, id); err != nil { + t.Fatal(err) + } + if status, code := authAs(t, f, s.memberToken, http.MethodGet, "/debuglet/"+id+"/result", nil); status != http.StatusRequestEntityTooLarge || code != CodePayloadTooLarge { + t.Fatalf("shared oversized export: %d %s", status, code) + } + if _, err := s.member.RunDetail(f.ctx, id); err != nil { + t.Fatalf("shared configuration depends on output size: %v", err) + } + if _, err := f.db.ExecContext(f.ctx, "UPDATE debuglet_logs SET output=? WHERE debuglet_id=(SELECT id FROM debuglets WHERE uuid=?)", []byte("shared output"), id); err != nil { + t.Fatal(err) + } + if _, err := f.db.ExecContext(f.ctx, "INSERT INTO payload_tombstones(debuglet_id,deleted_at,reason,workload_sha256,certificate_sha256) SELECT id,unixepoch(),'owner_requested','',NULL FROM debuglets WHERE uuid=?", id); err != nil { + t.Fatal(err) + } + for _, suffix := range []string{"logs", "detail", "result"} { + if status, code := authAs(t, f, s.memberToken, http.MethodGet, "/debuglet/"+id+"/"+suffix, nil); status != http.StatusGone || code != CodePayloadDeleted { + t.Fatalf("shared tombstone %s: %d %s", suffix, status, code) + } + } + if status, _ := authAs(t, f, s.memberToken, http.MethodGet, "/debuglet/"+id+"/state", nil); status != http.StatusOK { + t.Fatalf("retained shared state: %d", status) + } + if _, err := f.db.ExecContext(f.ctx, "DELETE FROM community_measurements WHERE project_id=?", s.projectID); err != nil { + t.Fatal(err) + } + if _, err := s.member.Measurement(f.ctx, s.submission.TransactionID); err != nil { + t.Fatalf("removing one share removed another project's access: %v", err) + } + if _, err := f.db.ExecContext(f.ctx, "DELETE FROM community_members WHERE community_id=? AND user_id=(SELECT id FROM users WHERE uuid=?)", s.communityID, s.memberID); err != nil { + t.Fatal(err) + } + for _, route := range []string{"/measurements/" + s.submission.TransactionID, "/debuglet/" + id + "/logs", "/debuglet/" + id + "/detail", "/debuglet/" + id + "/result", "/debuglet/" + id + "/state", "/debuglet/" + id + "/recovery", "/debuglet/" + id + "/cancellation"} { + if status, _, body, _ := authRequest(t, f, http.MethodGet, route, nil, authBearer(s.memberToken)); status != http.StatusNotFound || bytes.Contains(body, []byte("payload_deleted")) { + t.Fatalf("removed member can still inspect %s: %d", route, status) + } + } + if _, err := f.db.ExecContext(f.ctx, "DELETE FROM community_measurements WHERE project_id=?", s.secondProjectID); err != nil { + t.Fatal(err) + } + if status, _ := authAs(t, f, s.creatorToken, http.MethodGet, "/measurements/"+s.submission.TransactionID, nil); status != http.StatusNotFound { + t.Fatalf("unsharing left creator read access: %d", status) + } + owned, err := s.owner.Measurement(f.ctx, s.submission.TransactionID) + if err != nil || !owned.CanManage { + t.Fatalf("unsharing revoked original ownership: %+v, %v", owned, err) + } +} + +func TestMeasurementManagementFlagsRespectCredentialScopes(t *testing.T) { + f := credentialFixture(t) + browser := credentialBrowser(t, f) + owner, err := f.client(f.root.URL, false).WithCredential(strings.TrimPrefix(browser["Cookie"], sessionCookieName+"=")) + if err != nil { + t.Fatal(err) + } + submission := f.submit(owner, nil) + for _, scopes := range [][]string{{"measurements:read"}, {"measurements:read", "measurements:write"}} { + issued := createTestCredential(t, f, browser, scopes...) + scoped, err := owner.WithCredential(issued.Token) + if err != nil { + t.Fatal(err) + } + detail, err := scoped.RunDetail(f.ctx, submission.IDs[0]) + if err != nil || detail.CanManage != (len(scopes) == 2) { + t.Fatalf("scoped detail management: %+v, %v", detail, err) + } + group, err := scoped.Measurement(f.ctx, submission.TransactionID) + if err != nil || group.CanManage != (len(scopes) == 2) { + t.Fatalf("scoped group management: %+v, %v", group, err) + } + } +} diff --git a/internal/dispatcher/transport/api/credential_scopes.go b/internal/dispatcher/transport/api/credential_scopes.go index 2aa5f4fd..53e472ef 100644 --- a/internal/dispatcher/transport/api/credential_scopes.go +++ b/internal/dispatcher/transport/api/credential_scopes.go @@ -21,6 +21,21 @@ var credentialScopes = []string{"account:read", "executors:read", "executors:wri // API credentials narrow existing account access; they grant no dispatcher // administration or identity-management authority. var credentialRouteScopes = map[string]string{ + "GET /communities": "measurements:read", + "GET /communities/:id": "measurements:read", + "GET /communities/:id/projects": "measurements:read", + "GET /community-projects/:id": "measurements:read", + "GET /community-projects/:id/experiments": "measurements:read", + "GET /community-experiments/:id": "measurements:read", + "GET /community-projects/:id/measurements": "measurements:read", + "GET /communities/:id/wallet": "account:read", + "POST /communities/:id/projects": "measurements:write", + "PATCH /community-projects/:id": "measurements:write", + "POST /community-projects/:id/experiments": "measurements:write", + "PUT /community-experiments/:id": "measurements:write", + "DELETE /community-experiments/:id": "measurements:write", + "POST /community-projects/:id/measurements": "measurements:write", + "DELETE /community-projects/:id/measurements/:measurement_id": "measurements:write", "GET /me": "account:read", "GET /me/orders": "account:read", "GET /me/allowance": "account:read", diff --git a/internal/dispatcher/transport/api/handler_cancellation.go b/internal/dispatcher/transport/api/handler_cancellation.go index 0e25a11a..37600470 100644 --- a/internal/dispatcher/transport/api/handler_cancellation.go +++ b/internal/dispatcher/transport/api/handler_cancellation.go @@ -17,7 +17,7 @@ func (h *Handler) GetDebugletCancellation(c echo.Context) error { if err != nil { return err } - if err := h.authorizeDebuglet(c, id); err != nil { + if _, err := h.authorizeDebugletRead(c, id); err != nil { return err } document, err := h.dispatcher.Cancellation(c.Request().Context(), id) diff --git a/internal/dispatcher/transport/api/handler_recovery.go b/internal/dispatcher/transport/api/handler_recovery.go index ec22fbc6..3b01074a 100644 --- a/internal/dispatcher/transport/api/handler_recovery.go +++ b/internal/dispatcher/transport/api/handler_recovery.go @@ -17,7 +17,7 @@ func (h *Handler) GetDebugletRecovery(c echo.Context) error { if err != nil { return err } - if err := h.authorizeDebuglet(c, id); err != nil { + if _, err := h.authorizeDebugletRead(c, id); err != nil { return err } document, err := h.dispatcher.Recovery(c.Request().Context(), id) diff --git a/internal/dispatcher/transport/api/handler_result.go b/internal/dispatcher/transport/api/handler_result.go index 0054891b..7041ee4c 100644 --- a/internal/dispatcher/transport/api/handler_result.go +++ b/internal/dispatcher/transport/api/handler_result.go @@ -23,7 +23,7 @@ func (h *Handler) GetDebugletResult(c echo.Context) error { if err != nil { return err } - if err := h.authorizeDebuglet(c, id); err != nil { + if _, err := h.authorizeDebugletRead(c, id); err != nil { return err } ctx := c.Request().Context() diff --git a/internal/dispatcher/transport/api/handlers_communities.go b/internal/dispatcher/transport/api/handlers_communities.go new file mode 100644 index 00000000..3f749f57 --- /dev/null +++ b/internal/dispatcher/transport/api/handlers_communities.go @@ -0,0 +1,652 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package api + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/netsec-ethz/debuglet/internal/dispatcher/database" + "github.com/netsec-ethz/debuglet/pkg/wire" +) + +func (h *Handler) registerCommunityRoutes(e *echo.Echo) { + e.GET("/communities", h.ListCommunities) + e.POST("/communities", h.CreateCommunity) + e.GET("/communities/:id", h.GetCommunity) + e.PATCH("/communities/:id", h.UpdateCommunity) + e.GET("/communities/:id/members", h.ListCommunityMembers) + e.POST("/communities/:id/invitations", h.CreateCommunityInvitation) + e.POST("/community-invitations/accept", h.AcceptCommunityInvitation) + e.DELETE("/communities/:id/members/:user_id", h.RemoveCommunityMember) + e.GET("/communities/:id/projects", h.ListCommunityProjects) + e.POST("/communities/:id/projects", h.CreateCommunityProject) + e.GET("/community-projects/:id", h.GetCommunityProject) + e.PATCH("/community-projects/:id", h.UpdateCommunityProject) + e.GET("/community-projects/:id/experiments", h.ListCommunityExperiments) + e.POST("/community-projects/:id/experiments", h.CreateCommunityExperiment) + e.GET("/community-experiments/:id", h.GetCommunityExperiment) + e.PUT("/community-experiments/:id", h.UpdateCommunityExperiment) + e.DELETE("/community-experiments/:id", h.DeleteCommunityExperiment) + e.GET("/community-projects/:id/measurements", h.ListCommunityMeasurements) + e.POST("/community-projects/:id/measurements", h.ShareCommunityMeasurement) + e.DELETE("/community-projects/:id/measurements/:measurement_id", h.UnshareCommunityMeasurement) +} + +func communityNotFound() error { + return apiError(http.StatusNotFound, CodeNotFound, "community resource not found") +} +func communityFailure(err error) error { + return apiErrorFrom(http.StatusInternalServerError, CodeInternal, "community storage failed", err) +} +func communityConflict(message string) error { + return apiError(http.StatusConflict, CodeInvalidRequest, message) +} + +func (h *Handler) requireCommunity(c echo.Context, id string, ownerOnly bool) (database.GetCommunityRow, error) { + account, err := requireAccount(c) + if err != nil { + return database.GetCommunityRow{}, err + } + row, err := database.New(h.db).GetCommunity(c.Request().Context(), database.GetCommunityParams{ID: id, UserUuid: account.UserUUID}) + if errors.Is(err, sql.ErrNoRows) || (err == nil && ownerOnly && row.Role != "owner") { + return row, communityNotFound() + } + if err != nil { + return row, communityFailure(err) + } + return row, nil +} + +func (h *Handler) requireCommunityProject(c echo.Context, id string) (database.GetCommunityProjectRow, error) { + account, err := requireAccount(c) + if err != nil { + return database.GetCommunityProjectRow{}, err + } + row, err := database.New(h.db).GetCommunityProject(c.Request().Context(), database.GetCommunityProjectParams{ID: id, UserUuid: account.UserUUID}) + if errors.Is(err, sql.ErrNoRows) { + return row, communityNotFound() + } + if err != nil { + return row, communityFailure(err) + } + return row, nil +} + +func communityView(row database.GetCommunityRow) wire.Community { + return wire.Community{ + ID: row.ID, Name: row.Name, Description: row.Description, Role: row.Role, + CreatedBy: row.CreatorUuid.String(), CreatedAt: time.Unix(row.CreatedAt, 0).UTC(), + } +} +func projectView(row database.GetCommunityProjectRow) wire.CommunityProject { + return wire.CommunityProject{ + ID: row.ID, CommunityID: row.CommunityID, Name: row.Name, Description: row.Description, + CreatedBy: row.CreatorUuid.String(), CreatedAt: time.Unix(row.CreatedAt, 0).UTC(), + } +} +func experimentView(row database.GetCommunityExperimentRow) wire.CommunityExperiment { + return wire.CommunityExperiment{ + ID: row.ID, ProjectID: row.ProjectID, Name: row.Name, Description: row.Description, + Collection: row.Collection, Kind: row.Kind, Revision: row.Revision, + CreatedBy: row.CreatorUuid.String(), UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC(), + Document: json.RawMessage(row.Document), Bundle: json.RawMessage(row.Bundle.String), + } +} +func sharedMeasurementView(row database.GetCommunityMeasurementRow) wire.CommunityMeasurement { + item := wire.CommunityMeasurement{ProjectID: row.ProjectID, MeasurementID: row.MeasurementID, SharedBy: row.SharerUuid.String(), SharedAt: time.Unix(row.SharedAt, 0).UTC(), ExperimentID: row.ExperimentID.String} + if row.ExperimentRevision.Valid { + revision := row.ExperimentRevision.Int64 + item.ExperimentRevision = &revision + } + return item +} + +type communityNameRequest struct { + Name string `json:"name"` + Description string `json:"description"` +} + +func readCommunityName(c echo.Context) (communityNameRequest, error) { + var input communityNameRequest + c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, 8<<10) + if err := decodeMeasurementRequest(c, &input); err != nil { + return input, err + } + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 120 || len(input.Description) > 2000 { + return input, apiError(http.StatusBadRequest, CodeInvalidRequest, "name must contain 1 to 120 bytes and description at most 2000 bytes") + } + return input, nil +} + +func (h *Handler) ListCommunities(c echo.Context) error { + account, err := requireAccount(c) + if err != nil { + return err + } + rows, err := database.New(h.db).ListCommunities(c.Request().Context(), account.UserUUID) + if err != nil { + return communityFailure(err) + } + items := make([]wire.Community, 0, len(rows)) + for _, row := range rows { + items = append(items, communityView(database.GetCommunityRow(row))) + } + return c.JSON(http.StatusOK, items) +} +func (h *Handler) GetCommunity(c echo.Context) error { + row, err := h.requireCommunity(c, c.Param("id"), false) + if err != nil { + return err + } + return c.JSON(http.StatusOK, communityView(row)) +} +func (h *Handler) CreateCommunity(c echo.Context) error { + account, err := requireAccount(c) + if err != nil { + return err + } + input, err := readCommunityName(c) + if err != nil { + return err + } + ctx := c.Request().Context() + tx, err := h.db.BeginTx(ctx, nil) + if err != nil { + return communityFailure(err) + } + defer tx.Rollback() + q := database.New(tx) + id := uuid.NewString() + n, err := q.CreateCommunity(ctx, database.CreateCommunityParams{ID: id, Name: input.Name, Description: input.Description, CreatedAt: time.Now().Unix(), UserUuid: account.UserUUID}) + if err != nil { + return communityFailure(err) + } + if n != 1 { + return communityConflict("an account may create at most 16 communities") + } + if err = q.AddCommunityCreator(ctx, id); err != nil { + return communityFailure(err) + } + if err = tx.Commit(); err != nil { + return communityFailure(err) + } + row, err := h.requireCommunity(c, id, false) + if err != nil { + return err + } + return c.JSON(http.StatusCreated, communityView(row)) +} +func (h *Handler) UpdateCommunity(c echo.Context) error { + if _, err := h.requireCommunity(c, c.Param("id"), true); err != nil { + return err + } + input, err := readCommunityName(c) + if err != nil { + return err + } + n, err := database.New(h.db).UpdateCommunity(c.Request().Context(), database.UpdateCommunityParams{ID: c.Param("id"), Name: input.Name, Description: input.Description, UserUuid: requestCaller(c).UserUUID}) + if err != nil { + return communityFailure(err) + } + if n != 1 { + return communityNotFound() + } + return h.GetCommunity(c) +} +func (h *Handler) ListCommunityMembers(c echo.Context) error { + if _, err := h.requireCommunity(c, c.Param("id"), false); err != nil { + return err + } + rows, err := database.New(h.db).ListCommunityMembers(c.Request().Context(), database.ListCommunityMembersParams{ID: c.Param("id"), UserUuid: requestCaller(c).UserUUID}) + if err != nil { + return communityFailure(err) + } + items := make([]wire.CommunityMember, 0, len(rows)) + for _, row := range rows { + items = append(items, wire.CommunityMember{UserID: row.UserUuid.String(), Name: row.Name, Role: row.Role, JoinedAt: time.Unix(row.JoinedAt, 0).UTC()}) + } + return c.JSON(http.StatusOK, items) +} +func (h *Handler) RemoveCommunityMember(c echo.Context) error { + community, err := h.requireCommunity(c, c.Param("id"), true) + if err != nil { + return err + } + member, err := uuid.Parse(c.Param("user_id")) + if err != nil { + return communityNotFound() + } + if member == community.CreatorUuid { + return apiError(http.StatusBadRequest, CodeInvalidRequest, "the community owner cannot be removed") + } + n, err := database.New(h.db).RemoveCommunityMember(c.Request().Context(), database.RemoveCommunityMemberParams{ID: community.ID, MemberUuid: member, UserUuid: requestCaller(c).UserUUID}) + if err != nil { + return communityFailure(err) + } + if n != 1 { + return communityNotFound() + } + return c.NoContent(http.StatusNoContent) +} +func (h *Handler) CreateCommunityInvitation(c echo.Context) error { + community, err := h.requireCommunity(c, c.Param("id"), true) + if err != nil { + return err + } + secret := make([]byte, 32) + if _, err = rand.Read(secret); err != nil { + return communityFailure(err) + } + token := base64.RawURLEncoding.EncodeToString(secret) + hash := sha256.Sum256([]byte(token)) + now := time.Now().UTC() + expires := now.Add(7 * 24 * time.Hour) + q := database.New(h.db) + ctx := c.Request().Context() + if err = q.PruneCommunityInvitations(ctx, database.PruneCommunityInvitationsParams{CommunityID: community.ID, ExpiresAt: now.Unix()}); err != nil { + return communityFailure(err) + } + n, err := q.CreateCommunityInvitation(ctx, database.CreateCommunityInvitationParams{TokenHash: hash[:], ID: community.ID, ExpiresAt: expires.Unix(), Now: now.Unix(), UserUuid: requestCaller(c).UserUUID}) + if err != nil { + return communityFailure(err) + } + if n != 1 { + return communityConflict("a community may have at most 8 active invitations") + } + return c.JSON(http.StatusCreated, struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + }{token, expires}) +} +func (h *Handler) AcceptCommunityInvitation(c echo.Context) error { + account, err := requireAccount(c) + if err != nil { + return err + } + var input struct { + Token string `json:"token"` + } + c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, 1024) + if err = decodeMeasurementRequest(c, &input); err != nil { + return err + } + if len(input.Token) != 43 { + return communityNotFound() + } + hash := sha256.Sum256([]byte(input.Token)) + ctx := c.Request().Context() + tx, err := h.db.BeginTx(ctx, nil) + if err != nil { + return communityFailure(err) + } + defer tx.Rollback() + q := database.New(tx) + id, err := q.ConsumeCommunityInvitation(ctx, database.ConsumeCommunityInvitationParams{TokenHash: hash[:], Now: time.Now().Unix(), UserUuid: account.UserUUID}) + if errors.Is(err, sql.ErrNoRows) { + return communityNotFound() + } + if err != nil { + return communityFailure(err) + } + if err = q.JoinCommunity(ctx, database.JoinCommunityParams{ID: id, JoinedAt: time.Now().Unix(), UserUuid: account.UserUUID}); err != nil { + return communityFailure(err) + } + if err = tx.Commit(); err != nil { + return communityFailure(err) + } + row, err := h.requireCommunity(c, id, false) + if err != nil { + return err + } + return c.JSON(http.StatusOK, communityView(row)) +} + +func (h *Handler) ListCommunityProjects(c echo.Context) error { + if _, err := h.requireCommunity(c, c.Param("id"), false); err != nil { + return err + } + rows, err := database.New(h.db).ListCommunityProjects(c.Request().Context(), database.ListCommunityProjectsParams{CommunityID: c.Param("id"), UserUuid: requestCaller(c).UserUUID}) + if err != nil { + return communityFailure(err) + } + items := make([]wire.CommunityProject, 0, len(rows)) + for _, row := range rows { + items = append(items, wire.CommunityProject{ID: row.ID, CommunityID: row.CommunityID, Name: row.Name, Description: row.Description, CreatedBy: row.CreatorUuid.String(), CreatedAt: time.Unix(row.CreatedAt, 0).UTC()}) + } + return c.JSON(http.StatusOK, items) +} +func (h *Handler) GetCommunityProject(c echo.Context) error { + row, err := h.requireCommunityProject(c, c.Param("id")) + if err != nil { + return err + } + return c.JSON(http.StatusOK, projectView(row)) +} +func (h *Handler) CreateCommunityProject(c echo.Context) error { + if _, err := h.requireCommunity(c, c.Param("id"), false); err != nil { + return err + } + input, err := readCommunityName(c) + if err != nil { + return err + } + id := uuid.NewString() + n, err := database.New(h.db).CreateCommunityProject(c.Request().Context(), database.CreateCommunityProjectParams{ID: id, CommunityID: c.Param("id"), Name: input.Name, Description: input.Description, CreatedAt: time.Now().Unix(), UserUuid: requestCaller(c).UserUUID}) + if err != nil { + return communityFailure(err) + } + if n != 1 { + return communityConflict("a community may have at most 64 projects") + } + row, err := h.requireCommunityProject(c, id) + if err != nil { + return err + } + return c.JSON(http.StatusCreated, projectView(row)) +} +func (h *Handler) UpdateCommunityProject(c echo.Context) error { + if _, err := h.requireCommunityProject(c, c.Param("id")); err != nil { + return err + } + input, err := readCommunityName(c) + if err != nil { + return err + } + n, err := database.New(h.db).UpdateCommunityProject(c.Request().Context(), database.UpdateCommunityProjectParams{ID: c.Param("id"), Name: input.Name, Description: input.Description, UserUuid: requestCaller(c).UserUUID}) + if err != nil { + return communityFailure(err) + } + if n != 1 { + return communityNotFound() + } + return h.GetCommunityProject(c) +} + +type communityExperimentRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Collection string `json:"collection,omitempty"` + Kind string `json:"kind"` + Document json.RawMessage `json:"document"` + Bundle json.RawMessage `json:"bundle,omitempty"` + Revision int64 `json:"revision,omitempty"` +} + +func readCommunityExperiment(c echo.Context) (communityExperimentRequest, error) { + var input communityExperimentRequest + c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, wire.MaxCommunityExperimentBytes) + if err := decodeMeasurementRequest(c, &input); err != nil { + return input, err + } + input.Name = strings.TrimSpace(input.Name) + input.Collection = strings.TrimSpace(input.Collection) + if input.Name == "" || len(input.Name) > 120 || len(input.Description) > 4000 || len(input.Collection) > 120 { + return input, apiError(http.StatusBadRequest, CodeInvalidRequest, "experiment name and collection are limited to 120 bytes and description to 4000 bytes") + } + var err error + input.Document, input.Bundle, err = validateCommunityDocument(input.Kind, input.Document, input.Bundle) + if err != nil { + return input, err + } + encoded, err := json.Marshal(input) + if err != nil { + return input, communityFailure(err) + } + if len(encoded) > wire.MaxCommunityExperimentBytes { + return input, apiError(http.StatusRequestEntityTooLarge, CodePayloadTooLarge, "saved experiment exceeds 24 MiB") + } + return input, nil +} +func (h *Handler) ListCommunityExperiments(c echo.Context) error { + if _, err := h.requireCommunityProject(c, c.Param("id")); err != nil { + return err + } + rows, err := database.New(h.db).ListCommunityExperiments(c.Request().Context(), database.ListCommunityExperimentsParams{ProjectID: c.Param("id"), UserUuid: requestCaller(c).UserUUID}) + if err != nil { + return communityFailure(err) + } + items := make([]wire.CommunityExperiment, 0, len(rows)) + for _, row := range rows { + items = append(items, wire.CommunityExperiment{ID: row.ID, ProjectID: row.ProjectID, Name: row.Name, Description: row.Description, Collection: row.Collection, Kind: row.Kind, Revision: row.Revision, CreatedBy: row.CreatorUuid.String(), UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC()}) + } + return c.JSON(http.StatusOK, items) +} +func (h *Handler) GetCommunityExperiment(c echo.Context) error { + account, err := requireAccount(c) + if err != nil { + return err + } + revision := int64(0) + if value := c.QueryParam("revision"); value != "" { + revision, err = strconv.ParseInt(value, 10, 64) + if err != nil || revision < 1 { + return apiError(http.StatusBadRequest, CodeInvalidRequest, "revision must be a positive integer") + } + } + row, err := database.New(h.db).GetCommunityExperiment(c.Request().Context(), database.GetCommunityExperimentParams{ID: c.Param("id"), Revision: revision, UserUuid: account.UserUUID}) + if errors.Is(err, sql.ErrNoRows) { + return communityNotFound() + } + if err != nil { + return communityFailure(err) + } + return c.JSON(http.StatusOK, experimentView(row)) +} +func (h *Handler) CreateCommunityExperiment(c echo.Context) error { + if _, err := h.requireCommunityProject(c, c.Param("id")); err != nil { + return err + } + input, err := readCommunityExperiment(c) + if err != nil { + return err + } + if input.Revision != 0 { + return apiError(http.StatusBadRequest, CodeInvalidRequest, "new experiments do not specify a revision") + } + return h.saveCommunityExperiment(c, input, uuid.NewString(), true) +} +func (h *Handler) UpdateCommunityExperiment(c echo.Context) error { + account, err := requireAccount(c) + if err != nil { + return err + } + _, err = database.New(h.db).GetCommunityExperiment(c.Request().Context(), database.GetCommunityExperimentParams{ID: c.Param("id"), Revision: 0, UserUuid: account.UserUUID}) + if errors.Is(err, sql.ErrNoRows) { + return communityNotFound() + } + if err != nil { + return communityFailure(err) + } + input, err := readCommunityExperiment(c) + if err != nil { + return err + } + if input.Revision < 1 { + return apiError(http.StatusBadRequest, CodeInvalidRequest, "updating an experiment requires its current revision") + } + return h.saveCommunityExperiment(c, input, c.Param("id"), false) +} +func (h *Handler) DeleteCommunityExperiment(c echo.Context) error { + account, err := requireAccount(c) + if err != nil { + return err + } + ctx := c.Request().Context() + q := database.New(h.db) + experiment, err := q.GetCommunityExperiment(ctx, database.GetCommunityExperimentParams{ID: c.Param("id"), UserUuid: account.UserUUID}) + if errors.Is(err, sql.ErrNoRows) { + return communityNotFound() + } + if err != nil { + return communityFailure(err) + } + if experiment.CreatorUuid != account.UserUUID { + project, err := h.requireCommunityProject(c, experiment.ProjectID) + if err != nil { + return err + } + if _, err := h.requireCommunity(c, project.CommunityID, true); err != nil { + return err + } + } + tx, err := h.db.BeginTx(ctx, nil) + if err != nil { + return communityFailure(err) + } + defer tx.Rollback() + if err := pruneUnadmittedCommunityShares(ctx, tx, experiment.ProjectID); err != nil { + return communityFailure(err) + } + deleted, err := database.New(tx).DeleteCommunityExperiment(ctx, database.DeleteCommunityExperimentParams{ID: experiment.ID, UserUuid: account.UserUUID}) + if err != nil { + return communityFailure(err) + } + if deleted != 1 { + return communityConflict("experiment is referenced by shared measurements or pending runs; remove those references before deleting it") + } + if err := tx.Commit(); err != nil { + return communityFailure(err) + } + return c.NoContent(http.StatusNoContent) +} + +func (h *Handler) saveCommunityExperiment(c echo.Context, input communityExperimentRequest, id string, create bool) error { + ctx := c.Request().Context() + tx, err := h.db.BeginTx(ctx, nil) + if err != nil { + return communityFailure(err) + } + defer tx.Rollback() + q := database.New(tx) + account := requestCaller(c) + revision := input.Revision + 1 + var n int64 + if create { + n, err = q.CreateCommunityExperiment(ctx, database.CreateCommunityExperimentParams{ID: id, ProjectID: c.Param("id"), UserUuid: account.UserUUID}) + } else { + n, err = q.AdvanceCommunityExperiment(ctx, database.AdvanceCommunityExperimentParams{ID: id, Revision: input.Revision, UserUuid: account.UserUUID}) + } + if err != nil { + return communityFailure(err) + } + if n != 1 { + return communityConflict("experiment changed or its limit was reached; reload before editing, or save a new experiment") + } + n, err = q.InsertCommunityExperimentRevision(ctx, database.InsertCommunityExperimentRevisionParams{ExperimentID: id, Revision: revision, Name: input.Name, Description: input.Description, Collection: input.Collection, Kind: input.Kind, Document: string(input.Document), Bundle: sql.NullString{String: string(input.Bundle), Valid: len(input.Bundle) > 0}, UpdatedAt: time.Now().Unix()}) + if err != nil { + return communityFailure(err) + } + if n != 1 { + return communityConflict("community saved experiments exceed 256 MiB; delete unused saved experiments before saving another") + } + row, err := q.GetCommunityExperiment(ctx, database.GetCommunityExperimentParams{ID: id, Revision: revision, UserUuid: account.UserUUID}) + if err != nil { + return communityFailure(err) + } + if err = tx.Commit(); err != nil { + return communityFailure(err) + } + status := http.StatusOK + if create { + status = http.StatusCreated + } + return c.JSON(status, experimentView(row)) +} +func (h *Handler) ListCommunityMeasurements(c echo.Context) error { + if _, err := h.requireCommunityProject(c, c.Param("id")); err != nil { + return err + } + rows, err := database.New(h.db).ListCommunityMeasurements(c.Request().Context(), database.ListCommunityMeasurementsParams{ProjectID: c.Param("id"), UserUuid: requestCaller(c).UserUUID}) + if err != nil { + return communityFailure(err) + } + items := make([]wire.CommunityMeasurement, 0, len(rows)) + for _, row := range rows { + items = append(items, sharedMeasurementView(database.GetCommunityMeasurementRow(row))) + } + return c.JSON(http.StatusOK, items) +} +func (h *Handler) ShareCommunityMeasurement(c echo.Context) error { + if _, err := h.requireCommunityProject(c, c.Param("id")); err != nil { + return err + } + var input struct { + MeasurementID string `json:"measurement_id"` + ExperimentID string `json:"experiment_id,omitempty"` + ExperimentRevision *int64 `json:"experiment_revision,omitempty"` + } + c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, 4096) + if err := decodeMeasurementRequest(c, &input); err != nil { + return err + } + if input.MeasurementID == "" || len(input.MeasurementID) > 256 || (input.ExperimentID == "") != (input.ExperimentRevision == nil) || (input.ExperimentRevision != nil && *input.ExperimentRevision < 1) { + return apiError(http.StatusBadRequest, CodeInvalidRequest, "measurement_id and a complete optional experiment revision reference are required") + } + revision := sql.NullInt64{} + if input.ExperimentRevision != nil { + revision = sql.NullInt64{Int64: *input.ExperimentRevision, Valid: true} + } + ctx := c.Request().Context() + tx, err := h.db.BeginTx(ctx, nil) + if err != nil { + return communityFailure(err) + } + defer tx.Rollback() + if err := pruneUnadmittedCommunityShares(ctx, tx, c.Param("id")); err != nil { + return communityFailure(err) + } + q := database.New(tx) + account := requestCaller(c) + n, err := q.ShareCommunityMeasurement(ctx, database.ShareCommunityMeasurementParams{ProjectID: c.Param("id"), MeasurementID: input.MeasurementID, SharedAt: time.Now().Unix(), UserUuid: account.UserUUID, ExperimentID: sql.NullString{String: input.ExperimentID, Valid: input.ExperimentID != ""}, ExperimentRevision: revision}) + if err != nil { + return communityFailure(err) + } + if n != 1 { + return communityNotFound() + } + row, err := q.GetCommunityMeasurement(ctx, database.GetCommunityMeasurementParams{ProjectID: c.Param("id"), MeasurementID: input.MeasurementID, UserUuid: account.UserUUID}) + if err != nil { + return communityFailure(err) + } + if err := tx.Commit(); err != nil { + return communityFailure(err) + } + return c.JSON(http.StatusCreated, sharedMeasurementView(row)) +} +func (h *Handler) UnshareCommunityMeasurement(c echo.Context) error { + if _, err := h.requireCommunityProject(c, c.Param("id")); err != nil { + return err + } + ctx := c.Request().Context() + q := database.New(h.db) + n, err := q.UnshareCommunityMeasurement(ctx, database.UnshareCommunityMeasurementParams{ProjectID: c.Param("id"), MeasurementID: c.Param("measurement_id"), UserUuid: requestCaller(c).UserUUID}) + if err != nil { + return communityFailure(err) + } + if n != 1 { + funded, err := q.IsFundedCommunityMeasurement(ctx, database.IsFundedCommunityMeasurementParams{ + ProjectID: c.Param("id"), MeasurementID: c.Param("measurement_id"), UserUuid: requestCaller(c).UserUUID, + }) + if err != nil { + return communityFailure(err) + } + if funded { + return communityConflict("Measurements funded by a community remain in its project history.") + } + return communityNotFound() + } + return c.NoContent(http.StatusNoContent) +} diff --git a/internal/dispatcher/transport/api/handlers_communities_test.go b/internal/dispatcher/transport/api/handlers_communities_test.go new file mode 100644 index 00000000..5a128db9 --- /dev/null +++ b/internal/dispatcher/transport/api/handlers_communities_test.go @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package api + +import ( + "encoding/json" + "net/http" + "sync" + "testing" + "time" + + "github.com/netsec-ethz/debuglet/pkg/client" + "github.com/netsec-ethz/debuglet/pkg/wire" + "github.com/netsec-ethz/debuglet/pkg/workflow" +) + +func communityRequest(t *testing.T, f *ccFixture, token, method, path string, body any, status int, out any) { + t.Helper() + var raw []byte + var err error + if body != nil { + raw, err = json.Marshal(body) + if err != nil { + t.Fatal(err) + } + } + got, _, data, _ := authRequest(t, f, method, path, raw, authBearer(token)) + if got != status { + t.Fatalf("%s %s: status %d want %d: %s", method, path, got, status, data) + } + if out != nil { + if err = json.Unmarshal(data, out); err != nil { + t.Fatal(err) + } + } +} +func communityCreate(t *testing.T, f *ccFixture, token string) wire.Community { + t.Helper() + var item wire.Community + communityRequest(t, f, token, http.MethodPost, "/communities", map[string]string{"name": " Netsec ", "description": "Shared network research"}, 201, &item) + return item +} +func communityJoin(t *testing.T, f *ccFixture, owner, member, community string) { + t.Helper() + var invite struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + } + communityRequest(t, f, owner, http.MethodPost, "/communities/"+community+"/invitations", nil, 201, &invite) + if len(invite.Token) != 43 || time.Until(invite.ExpiresAt) < 6*24*time.Hour { + t.Fatal("invalid invitation") + } + communityRequest(t, f, member, http.MethodPost, "/community-invitations/accept", map[string]string{"token": invite.Token}, 200, nil) + communityRequest(t, f, member, http.MethodPost, "/community-invitations/accept", map[string]string{"token": invite.Token}, 404, nil) +} +func communityProject(t *testing.T, f *ccFixture, token, community string) wire.CommunityProject { + t.Helper() + var item wire.CommunityProject + communityRequest(t, f, token, http.MethodPost, "/communities/"+community+"/projects", map[string]string{"name": "DNS study"}, 201, &item) + return item +} + +func TestCommunityMembershipInvitationsAndProjectIsolation(t *testing.T) { + f := ccNewFixtureWith(t) + alice, owner, _ := authAccount(t, f, "Alice") + bob, member, _ := authAccount(t, f, "Bob") + _, outsider, _ := authAccount(t, f, "Outsider") + group := communityCreate(t, f, owner) + if group.Name != "Netsec" || group.Role != "owner" || group.CreatedBy != alice.ID { + t.Fatalf("created community: %+v", group) + } + communityRequest(t, f, member, http.MethodGet, "/communities/"+group.ID, nil, 404, nil) + communityRequest(t, f, "", http.MethodGet, "/communities", nil, 401, nil) + communityJoin(t, f, owner, member, group.ID) + var memberships []wire.Community + communityRequest(t, f, member, http.MethodGet, "/communities", nil, 200, &memberships) + if len(memberships) != 1 || memberships[0].Role != "member" { + t.Fatalf("member list: %+v", memberships) + } + var people []wire.CommunityMember + communityRequest(t, f, member, http.MethodGet, "/communities/"+group.ID+"/members", nil, 200, &people) + if len(people) != 2 { + t.Fatalf("member count %d", len(people)) + } + communityRequest(t, f, member, http.MethodPost, "/communities/"+group.ID+"/invitations", nil, 404, nil) + communityRequest(t, f, member, http.MethodPatch, "/communities/"+group.ID, map[string]string{"name": "Changed"}, 404, nil) + project := communityProject(t, f, member, group.ID) + communityRequest(t, f, owner, http.MethodPatch, "/community-projects/"+project.ID, map[string]string{"name": "Updated study"}, 200, nil) + communityRequest(t, f, outsider, http.MethodGet, "/community-projects/"+project.ID, nil, 404, nil) + communityRequest(t, f, owner, http.MethodDelete, "/communities/"+group.ID+"/members/"+alice.ID, nil, 400, nil) + communityRequest(t, f, owner, http.MethodDelete, "/communities/"+group.ID+"/members/"+bob.ID, nil, 204, nil) + communityRequest(t, f, member, http.MethodGet, "/community-projects/"+project.ID, nil, 404, nil) + communityRequest(t, f, member, http.MethodPost, "/communities/"+group.ID+"/projects", map[string]string{"name": "Forbidden"}, 404, nil) + var invite struct { + Token string `json:"token"` + } + communityRequest(t, f, owner, http.MethodPost, "/communities/"+group.ID+"/invitations", nil, 201, &invite) + if _, err := f.db.Exec("UPDATE community_invitations SET expires_at=0"); err != nil { + t.Fatal(err) + } + communityRequest(t, f, member, http.MethodPost, "/community-invitations/accept", map[string]string{"token": invite.Token}, 404, nil) +} + +func TestCommunityExperimentRevisionConflictRetainsOriginalBundle(t *testing.T) { + f := ccNewFixtureWith(t) + _, owner, _ := authAccount(t, f, "Owner") + _, member, _ := authAccount(t, f, "Member") + _, outsider, _ := authAccount(t, f, "Outsider") + group := communityCreate(t, f, owner) + communityJoin(t, f, owner, member, group.ID) + project := communityProject(t, f, owner, group.ID) + wf := communityTestWorkflow() + program, err := workflow.Generate(wf) + if err != nil { + t.Fatal(err) + } + input := communityExperimentRequest{Name: "Resolver behavior", Kind: "workflow", Description: "Compare resolvers", Collection: "Resolvers", Document: communityTestJSON(t, wf), Bundle: communityTestJSON(t, communityTestBundle(t, wf, program))} + var original wire.CommunityExperiment + communityRequest(t, f, owner, http.MethodPost, "/community-projects/"+project.ID+"/experiments", input, 201, &original) + if original.Revision != 1 || len(original.Bundle) == 0 || original.Collection != "Resolvers" { + t.Fatalf("saved %+v", original) + } + communityRequest(t, f, outsider, http.MethodGet, "/community-experiments/"+original.ID, nil, 404, nil) + var list []wire.CommunityExperiment + communityRequest(t, f, member, http.MethodGet, "/community-projects/"+project.ID+"/experiments", nil, 200, &list) + if len(list) != 1 || len(list[0].Document) > 0 || len(list[0].Bundle) > 0 { + t.Fatal("list leaked large documents or missed metadata") + } + input.Revision = 1 + input.Name = "Edited" + input.Bundle = nil + wf.Name = "Edited source" + input.Document = communityTestJSON(t, wf) + body, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + statuses := make(chan int, 2) + var workers sync.WaitGroup + for _, token := range []string{owner, member} { + workers.Add(1) + go func(token string) { + defer workers.Done() + status, _ := authAs(t, f, token, http.MethodPut, "/community-experiments/"+original.ID, body) + statuses <- status + }(token) + } + workers.Wait() + close(statuses) + counts := map[int]int{} + for status := range statuses { + counts[status]++ + } + if counts[200] != 1 || counts[409] != 1 { + t.Fatalf("concurrent revision results: %v", counts) + } + var current, previous wire.CommunityExperiment + communityRequest(t, f, member, http.MethodGet, "/community-experiments/"+original.ID, nil, 200, ¤t) + communityRequest(t, f, member, http.MethodGet, "/community-experiments/"+original.ID+"?revision=1", nil, 200, &previous) + if current.Revision != 2 || len(current.Bundle) != 0 || previous.Revision != 1 || string(previous.Bundle) != string(original.Bundle) { + t.Fatal("edit replaced historical revision or retained stale bundle") + } + if _, err := f.db.Exec("UPDATE community_experiment_revisions SET name='tampered' WHERE experiment_id=?", original.ID); err == nil { + t.Fatal("historical revision is mutable") + } + communityRequest(t, f, member, http.MethodGet, "/community-experiments/"+original.ID+"?revision=0", nil, 400, nil) + input.Revision = 32 + communityRequest(t, f, member, http.MethodPut, "/community-experiments/"+original.ID, input, 409, nil) +} + +func TestCommunityMeasurementSharingRequiresOwnedBatchAndSameProjectRevision(t *testing.T) { + f := ccNewFixtureWith(t) + ownerAccount, owner, ownerClient := authAccount(t, f, "Owner") + memberAccount, member, _ := authAccount(t, f, "Member") + _, outsider, _ := authAccount(t, f, "Outsider") + group := communityCreate(t, f, owner) + communityJoin(t, f, owner, member, group.ID) + project := communityProject(t, f, owner, group.ID) + other := communityProject(t, f, owner, group.ID) + batch, err := client.Prepare([]client.Request{ccRequest([]string{"shared"})}) + if err != nil { + t.Fatal(err) + } + submission, err := ownerClient.SubmitTEST(f.ctx, batch) + if err != nil { + t.Fatal(err) + } + input := map[string]any{"measurement_id": submission.TransactionID} + route := "/community-projects/" + project.ID + "/measurements" + communityRequest(t, f, member, http.MethodPost, route, input, 404, nil) + communityRequest(t, f, outsider, http.MethodPost, route, input, 404, nil) + var experiment wire.CommunityExperiment + communityRequest(t, f, owner, http.MethodPost, "/community-projects/"+other.ID+"/experiments", communityExperimentRequest{Name: "Other project", Kind: "workflow", Document: communityTestJSON(t, communityTestWorkflow())}, 201, &experiment) + input["experiment_id"] = experiment.ID + input["experiment_revision"] = 1 + communityRequest(t, f, owner, http.MethodPost, route, input, 404, nil) + communityRequest(t, f, owner, http.MethodPost, "/community-projects/"+project.ID+"/experiments", communityExperimentRequest{Name: "Same project", Kind: "workflow", Document: communityTestJSON(t, communityTestWorkflow())}, 201, &experiment) + input["experiment_id"] = experiment.ID + if _, err := f.db.Exec("UPDATE transaction_users SET user_id=(SELECT id FROM users WHERE uuid=?) WHERE transaction_id=?", memberAccount.ID, submission.TransactionID); err != nil { + t.Fatal(err) + } + communityRequest(t, f, owner, http.MethodPost, route, input, 404, nil) + if _, err := f.db.Exec("UPDATE transaction_users SET user_id=(SELECT id FROM users WHERE uuid=?) WHERE transaction_id=?", ownerAccount.ID, submission.TransactionID); err != nil { + t.Fatal(err) + } + var shared wire.CommunityMeasurement + communityRequest(t, f, owner, http.MethodPost, route, input, 201, &shared) + var items []wire.CommunityMeasurement + communityRequest(t, f, member, http.MethodGet, route, nil, 200, &items) + if len(items) != 1 || items[0].MeasurementID != submission.TransactionID || items[0].ExperimentID != experiment.ID || items[0].ExperimentRevision == nil || *items[0].ExperimentRevision != 1 { + t.Fatalf("shared list: %+v", items) + } + communityRequest(t, f, member, http.MethodDelete, route+"/"+submission.TransactionID, nil, 404, nil) + communityRequest(t, f, owner, http.MethodDelete, "/community-experiments/"+experiment.ID, nil, 409, nil) + communityRequest(t, f, owner, http.MethodDelete, route+"/"+submission.TransactionID, nil, 204, nil) + communityRequest(t, f, member, http.MethodGet, "/measurements/"+submission.TransactionID, nil, 404, nil) + communityRequest(t, f, owner, http.MethodGet, "/measurements/"+submission.TransactionID, nil, 200, nil) + communityRequest(t, f, owner, http.MethodPost, route, input, 201, nil) + communityRequest(t, f, member, http.MethodGet, "/debuglet/"+submission.IDs[0]+"/detail", nil, 200, nil) + communityRequest(t, f, owner, http.MethodDelete, "/communities/"+group.ID+"/members/"+memberAccount.ID, nil, 204, nil) + communityRequest(t, f, member, http.MethodGet, "/debuglet/"+submission.IDs[0]+"/detail", nil, 404, nil) + communityRequest(t, f, owner, http.MethodDelete, route+"/"+submission.TransactionID, nil, 204, nil) + communityRequest(t, f, owner, http.MethodDelete, "/community-experiments/"+experiment.ID, nil, 204, nil) + communityRequest(t, f, owner, http.MethodGet, "/debuglet/"+submission.IDs[0]+"/detail", nil, 200, nil) +} + +func TestCommunityInvitationSingleUseUnderConcurrentAcceptance(t *testing.T) { + f := ccNewFixtureWith(t) + _, owner, _ := authAccount(t, f, "Owner") + _, one, _ := authAccount(t, f, "One") + _, two, _ := authAccount(t, f, "Two") + group := communityCreate(t, f, owner) + var invite struct { + Token string `json:"token"` + } + communityRequest(t, f, owner, http.MethodPost, "/communities/"+group.ID+"/invitations", nil, 201, &invite) + body, _ := json.Marshal(map[string]string{"token": invite.Token}) + results := make(chan int, 2) + var workers sync.WaitGroup + for _, token := range []string{one, two} { + workers.Add(1) + go func(token string) { + defer workers.Done() + status, _ := authAs(t, f, token, http.MethodPost, "/community-invitations/accept", body) + results <- status + }(token) + } + workers.Wait() + close(results) + counts := map[int]int{} + for status := range results { + counts[status]++ + } + if counts[200] != 1 || counts[404] != 1 { + t.Fatalf("invitation results: %v", counts) + } +} + +func TestCommunityQuotasAndExpiredInvitationsDoNotAccumulate(t *testing.T) { + f := ccNewFixtureWith(t) + _, owner, _ := authAccount(t, f, "Owner") + group := communityCreate(t, f, owner) + for i := 1; i < 16; i++ { + communityCreate(t, f, owner) + } + communityRequest(t, f, owner, http.MethodPost, "/communities", map[string]string{"name": "One too many"}, 409, nil) + route := "/communities/" + group.ID + "/invitations" + for i := 0; i < 8; i++ { + communityRequest(t, f, owner, http.MethodPost, route, nil, 201, nil) + } + communityRequest(t, f, owner, http.MethodPost, route, nil, 409, nil) + if _, err := f.db.Exec("UPDATE community_invitations SET expires_at=0"); err != nil { + t.Fatal(err) + } + communityRequest(t, f, owner, http.MethodPost, route, nil, 201, nil) + var count int + if err := f.db.QueryRow("SELECT count(*) FROM community_invitations").Scan(&count); err != nil || count != 1 { + t.Fatalf("expired invitations remain: %d %v", count, err) + } + project := communityProject(t, f, owner, group.ID) + input := communityExperimentRequest{Name: "Revision limit", Kind: "workflow", Document: communityTestJSON(t, communityTestWorkflow())} + var experiment wire.CommunityExperiment + communityRequest(t, f, owner, http.MethodPost, "/community-projects/"+project.ID+"/experiments", input, 201, &experiment) + for revision := int64(1); revision < 32; revision++ { + input.Revision = revision + communityRequest(t, f, owner, http.MethodPut, "/community-experiments/"+experiment.ID, input, 200, nil) + } + input.Revision = 32 + communityRequest(t, f, owner, http.MethodPut, "/community-experiments/"+experiment.ID, input, 409, nil) + communityRequest(t, f, owner, http.MethodGet, "/community-experiments/"+experiment.ID, nil, 200, &experiment) + if experiment.Revision != 32 { + t.Fatal("failed update changed revision") + } +} + +func TestCommunityExperimentDeletionReleasesStoredRevisions(t *testing.T) { + f := ccNewFixtureWith(t) + _, owner, _ := authAccount(t, f, "Owner") + _, creator, _ := authAccount(t, f, "Creator") + _, member, _ := authAccount(t, f, "Member") + group := communityCreate(t, f, owner) + communityJoin(t, f, owner, creator, group.ID) + communityJoin(t, f, owner, member, group.ID) + project := communityProject(t, f, owner, group.ID) + wf := communityTestWorkflow() + program, err := workflow.Generate(wf) + if err != nil { + t.Fatal(err) + } + input := communityExperimentRequest{Name: "Unused compiled experiment", Kind: "workflow", Document: communityTestJSON(t, wf), Bundle: communityTestJSON(t, communityTestBundle(t, wf, program))} + var item wire.CommunityExperiment + route := "/community-projects/" + project.ID + "/experiments" + communityRequest(t, f, creator, http.MethodPost, route, input, 201, &item) + input.Revision = 1 + communityRequest(t, f, creator, http.MethodPut, "/community-experiments/"+item.ID, input, 200, nil) + var before, after int64 + storage := `SELECT COALESCE(sum(length(CAST(document AS BLOB))+COALESCE(length(CAST(bundle AS BLOB)),0)),0) FROM community_experiment_revisions` + if err = f.db.QueryRow(storage).Scan(&before); err != nil || before == 0 { + t.Fatalf("storage before deletion %d: %v", before, err) + } + communityRequest(t, f, member, http.MethodDelete, "/community-experiments/"+item.ID, nil, 404, nil) + communityRequest(t, f, creator, http.MethodDelete, "/community-experiments/"+item.ID, nil, 204, nil) + if err = f.db.QueryRow(storage).Scan(&after); err != nil || after != 0 { + t.Fatalf("deletion did not free quota bytes %d: %v", after, err) + } + communityRequest(t, f, creator, http.MethodGet, "/community-experiments/"+item.ID+"?revision=1", nil, 404, nil) + input.Revision = 0 + communityRequest(t, f, creator, http.MethodPost, route, input, 201, &item) + communityRequest(t, f, owner, http.MethodDelete, "/community-experiments/"+item.ID, nil, 204, nil) +} + +func TestCommunityFundedHistoryCannotBeUnshared(t *testing.T) { + s := newCommunityFundingFixture(t, true) + request := s.request() + intent := s.intent(t, request) + route := "/community-projects/" + s.project.ID + "/measurements/" + intent.Intent.TransactionID + for _, token := range []string{s.ownerToken, s.memberToken} { + var failure ErrorResponse + communityRequest(t, s.f, token, http.MethodDelete, route, nil, 409, &failure) + if failure.Message != "Measurements funded by a community remain in its project history." { + t.Fatalf("unshare diagnostic: %+v", failure) + } + } + communityRequest(t, s.f, s.outsiderToken, http.MethodDelete, route, nil, 404, nil) + var ids []string + communityRequest(t, s.f, s.memberToken, http.MethodPut, "/debuglet", SubmitDebugletsRequest{Debuglets: request.Debuglets, TransactionId: intent.Intent.TransactionID, AuthKey: intent.Intent.AuthKey}, 200, &ids) + communityRequest(t, s.f, s.ownerToken, http.MethodDelete, route, nil, 409, nil) + var history []wire.CommunityMeasurement + communityRequest(t, s.f, s.ownerToken, http.MethodGet, "/community-projects/"+s.project.ID+"/measurements", nil, 200, &history) + if len(history) != 1 || history[0].MeasurementID != intent.Intent.TransactionID { + t.Fatal("funded history was lost") + } + exported := communityProject(t, s.f, s.ownerToken, s.community.ID) + exportRoute := "/community-projects/" + exported.ID + "/measurements" + communityRequest(t, s.f, s.memberToken, http.MethodPost, exportRoute, map[string]string{"measurement_id": intent.Intent.TransactionID}, 201, nil) + communityRequest(t, s.f, s.memberToken, http.MethodDelete, exportRoute+"/"+intent.Intent.TransactionID, nil, 204, nil) + communityRequest(t, s.f, s.memberToken, http.MethodGet, "/measurements/"+intent.Intent.TransactionID, nil, 200, nil) +} diff --git a/internal/dispatcher/transport/api/handlers_community_funding.go b/internal/dispatcher/transport/api/handlers_community_funding.go new file mode 100644 index 00000000..448893ea --- /dev/null +++ b/internal/dispatcher/transport/api/handlers_community_funding.go @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package api + +import ( + "context" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "github.com/labstack/echo/v4" + "github.com/netsec-ethz/debuglet/internal/dispatcher/database" + "github.com/netsec-ethz/debuglet/internal/dispatcher/models" + "github.com/netsec-ethz/debuglet/internal/dispatcher/payments" + "github.com/netsec-ethz/debuglet/internal/dispatcher/payments/sui" + "github.com/netsec-ethz/debuglet/internal/studio" +) + +func (h *Handler) registerCommunityFundingRoutes(e *echo.Echo) { + e.GET("/communities/:id/wallet", h.GetCommunityWallet) + e.POST("/communities/:id/wallet/top-ups", h.CreateCommunityTopUp) +} + +type communityWalletResponse struct { + Mode string `json:"mode"` + Currency string `json:"currency"` + Unit string `json:"unit"` + Network string `json:"network"` + Enabled bool `json:"enabled"` + Funded string `json:"funded"` + Reserved string `json:"reserved"` + Spent string `json:"spent"` + Available string `json:"available"` +} + +func communityFundingError(err error) error { + var insufficient *payments.CommunityFundsError + var allowance *payments.AllowanceExceededError + switch { + case errors.Is(err, payments.ErrCommunityNotFound): + return communityNotFound() + case errors.Is(err, payments.ErrCommunityOwner): + return apiError(http.StatusForbidden, CodeForbidden, err.Error()) + case errors.Is(err, payments.ErrCommunityAllowanceDisabled): + return apiError(http.StatusServiceUnavailable, CodeUnavailable, err.Error()) + case errors.Is(err, payments.ErrCommunityNetwork): + return apiError(http.StatusServiceUnavailable, CodeUnavailable, err.Error()) + case errors.Is(err, payments.ErrCommunityDeposit): + return apiError(http.StatusBadRequest, CodeInvalidRequest, err.Error()) + case errors.As(err, &insufficient): + return apiError(http.StatusConflict, CodeInvalidRequest, err.Error()) + case errors.As(err, &allowance): + return allowanceExceeded(allowance) + case errors.Is(err, payments.ErrPaymentsDisabled): + return paymentMethodError("USDC", err) + default: + return apiErrorFrom(http.StatusInternalServerError, CodeInternal, "community funding failed", err) + } +} + +func (h *Handler) GetCommunityWallet(c echo.Context) error { + if _, err := h.requireCommunity(c, c.Param("id"), false); err != nil { + return err + } + currency, network := h.dispatcher.Payment.CommunityCurrency() + tx, err := h.db.BeginTx(c.Request().Context(), &sql.TxOptions{ReadOnly: true}) + if err != nil { + return communityFundingError(err) + } + defer tx.Rollback() + budget, err := h.dispatcher.Payment.CommunityBalance(c.Request().Context(), tx, c.Param("id"), currency) + enabled := !errors.Is(err, payments.ErrCommunityAllowanceDisabled) + if err != nil && enabled { + return communityFundingError(err) + } + response := communityWalletResponse{Mode: currency, Currency: currency, Unit: currencyUnit(currency), Network: network, Enabled: enabled, + Funded: strconv.FormatInt(budget.Funded, 10), Reserved: strconv.FormatInt(budget.Reserved, 10), + Spent: strconv.FormatInt(budget.Spent, 10), Available: strconv.FormatInt(budget.Available, 10)} + if currency == "TEST" { + response.Unit = "TEST" + } + return c.JSON(http.StatusOK, response) +} + +func (h *Handler) CreateCommunityTopUp(c echo.Context) error { + account, err := requireAccount(c) + if err != nil { + return err + } + if _, err := h.requireCommunity(c, c.Param("id"), false); err != nil { + return err + } + if err := h.allowAdmissionRequest(c, account); err != nil { + return err + } + var request struct { + Amount string `json:"amount"` + RefundAddress string `json:"refund_address"` + } + c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, 1024) + if err := decodeMeasurementRequest(c, &request); err != nil { + return err + } + amount, err := strconv.ParseInt(request.Amount, 10, 64) + if err != nil || amount <= 0 || amount > 1<<53-1 || strconv.FormatInt(amount, 10) != request.Amount { + return apiError(http.StatusBadRequest, CodeInvalidRequest, "amount must be an integer string between 1 and 9007199254740991 micro-USDC") + } + if err := sui.ValidAddress(request.RefundAddress); err != nil { + return apiError(http.StatusBadRequest, CodeInvalidRequest, "refund_address must be a Sui address") + } + id, err := h.dispatcher.Payment.NewTransactionID() + if err != nil { + return communityFundingError(err) + } + ctx := c.Request().Context() + tx, err := h.db.BeginTx(ctx, nil) + if err != nil { + return communityFundingError(err) + } + defer tx.Rollback() + intent, err := h.dispatcher.Payment.CreateCommunityTopUpIn(tx, account.UserUUID, c.Param("id"), id, amount, request.RefundAddress, ctx) + if err != nil { + return communityFundingError(err) + } + response, err := paymentIntentResponse("USDC", intent) + if err != nil { + return err + } + if err := tx.Commit(); err != nil { + return communityFundingError(err) + } + return c.JSON(http.StatusCreated, response) +} + +func (h *Handler) checkCommunityFundingRequest(c echo.Context, req PaymentIntentRequest) error { + if req.CommunityProjectID == "" { + if req.CommunityExperimentID != "" || req.CommunityExperimentRevision != nil { + return apiError(http.StatusBadRequest, CodeInvalidRequest, "an experiment association requires a community project") + } + return nil + } + if _, err := h.requireCommunityProject(c, req.CommunityProjectID); err != nil { + return err + } + if (req.CommunityExperimentID == "") != (req.CommunityExperimentRevision == nil) || + (req.CommunityExperimentRevision != nil && *req.CommunityExperimentRevision <= 0) { + return apiError(http.StatusBadRequest, CodeInvalidRequest, "experiment ID and positive revision must be supplied together") + } + return nil +} + +func shareFundedMeasurement(ctx context.Context, db database.DBTX, account *caller, req PaymentIntentRequest, transactionID string) error { + if err := pruneUnadmittedCommunityShares(ctx, db, req.CommunityProjectID); err != nil { + return communityFailure(err) + } + var experiment any + var revision any + if req.CommunityExperimentID != "" { + var kind string + var bundle sql.NullString + err := db.QueryRowContext(ctx, `SELECT r.kind,r.bundle FROM community_experiments e + JOIN community_experiment_revisions r ON r.experiment_id=e.id + WHERE e.id=? AND e.project_id=? AND r.revision=?`, req.CommunityExperimentID, req.CommunityProjectID, *req.CommunityExperimentRevision).Scan(&kind, &bundle) + if errors.Is(err, sql.ErrNoRows) { + return communityNotFound() + } + if err != nil { + return communityFailure(err) + } + if bundle.Valid && !fundedProgramsMatch(kind, []byte(bundle.String), req.Debuglets) { + return apiError(http.StatusBadRequest, CodeIntentMismatch, "submitted programs do not match the saved experiment revision") + } + experiment, revision = req.CommunityExperimentID, *req.CommunityExperimentRevision + } + result, err := db.ExecContext(ctx, `INSERT INTO community_measurements(project_id,measurement_id,shared_by,shared_at,experiment_id,experiment_revision) + SELECT ?,?,id,unixepoch(),?,? FROM users WHERE uuid=? + AND (SELECT count(*) FROM community_measurements WHERE project_id=?)<1024`, + req.CommunityProjectID, transactionID, experiment, revision, account.UserUUID, req.CommunityProjectID) + if err != nil { + return communityFailure(err) + } + if n, err := result.RowsAffected(); err != nil { + return communityFailure(err) + } else if n != 1 { + return apiError(http.StatusConflict, CodeInvalidRequest, "project measurement history is full; remove an existing shared measurement first") + } + return nil +} + +// An intent reserves a history slot before admission. Unused expired intents +// release that slot; admitted measurements retain their association. +func pruneUnadmittedCommunityShares(ctx context.Context, db database.DBTX, projectID string) error { + // Release the reservation before its association, so an old intent cannot + // subsequently admit work without a reserved place in the project history. + if _, err := db.ExecContext(ctx, `UPDATE transactions SET status=? + WHERE status=? AND expires_at