From 11c3c86d420d48c54cfdd985a4cbe7452a9d1e13 Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Thu, 27 Aug 2026 17:11:34 +0100 Subject: [PATCH 01/16] feat(docs): getting started for agents --- .github/prompts/docs-sync.md | 6 +- SUMMARY.md | 5 +- ai-tooling/molecule-skill.md | 25 +- api-reference/README.md | 55 +- api-reference/authentication.md | 131 +- api-reference/changelog.md | 52 +- api-reference/getting-started/README.md | 194 +++ api-reference/getting-started/for-agents.md | 170 +++ api-reference/labs-api/README.md | 32 +- api-reference/labs-api/browse-and-search.md | 42 +- api-reference/labs-api/example-workflow.md | 1526 ++++++++++++++----- api-reference/labs-api/files.md | 5 +- api-reference/labs-api/lab-management.md | 32 +- api-reference/labs-api/legal-agreements.md | 8 + api-reference/labs-api/service-tokens.md | 31 +- api-reference/tokenization-api.md | 15 +- api-reference/x402-gateway.md | 164 +- user-guides/developers-ai-agents.md | 9 +- 18 files changed, 1978 insertions(+), 524 deletions(-) create mode 100644 api-reference/getting-started/README.md create mode 100644 api-reference/getting-started/for-agents.md diff --git a/.github/prompts/docs-sync.md b/.github/prompts/docs-sync.md index 96a2d48..27b332e 100644 --- a/.github/prompts/docs-sync.md +++ b/.github/prompts/docs-sync.md @@ -52,12 +52,14 @@ touches one of its source paths. | Docs page | Source paths in `desci-infra` | | -- | -- | | `api-reference/README.md` | `graphql/schemas/*.graphql` (surface inventory only), `lib/shared-api-stack.ts` | -| `api-reference/authentication.md` | `lambda/appsync-authorizer-lambda/**`, service-token resolvers in `lambda/appsync-resolver-labs-lambda/**` (`services/token-manager-service.ts`), `docs/service-auth.md` | +| `api-reference/getting-started/README.md` | The onboarding entry point. `lib/desci-api-app-sync/constructs/api-config.ts` (endpoints, introspection, depth limit), `lambda/x402-gateway-lambda/**` (prices/base URLs quoted in the cost table), `lambda/appsync-authorizer-lambda/**` (credential shape). **Contains verified live values** — `mintFeeWei()` readings, x402 prices, gateway URLs — each stamped with the date it was checked. Never update one of those from a diff alone: if the diff suggests a value changed, flag it in the PR body and let a human re-verify against the deployed environment. | +| `api-reference/getting-started/for-agents.md` | Condensed mirror of Tutorial 1 plus the error contract. Sources: `graphql/schemas/ip-hubs.graphql`, `graphql/schemas/api-error.graphql`, `graphql/schemas/encryption.graphql`, `lambda/appsync-resolver-labs-lambda/**` (per-mutation role gates). **Must stay consistent with `example-workflow.md`** — a change to the flow touches both pages or neither. | | `api-reference/labs-api/README.md` | `graphql/schemas/ip-hubs.graphql`, `lambda/appsync-resolver-labs-lambda/**` | +| `api-reference/labs-api/example-workflow.md` | The tutorials page (H1 "Tutorials"; the filename is kept for inbound links). The end-to-end flow across four tutorials: `graphql/schemas/ip-hubs.graphql`, `graphql/schemas/encryption.graphql`, `lambda/appsync-resolver-labs-lambda/**` (especially the per-mutation authorization gates — `authorizeServiceAdmin` = Owner, `authorizeServiceMember` = Contributor), `services/token-manager-service.ts`, `lib/utils/token-expiration.ts` (the `expiresIn` bounds). Every code block is expected to be runnable, so a signature change here is a real breakage — correct it and say so in the PR body. | | `api-reference/labs-api/lab-management.md` | `createLab`, `updateLabNftMetadata`, `generateLabImageUploadUrl` in `lambda/appsync-resolver-labs-lambda/**`; `lambda/labnft-metadata-lambda/**`; `lambda/ocl-processor/**` | | `api-reference/labs-api/files.md` | file operations in `lambda/appsync-resolver-labs-lambda/**` (`initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata`, `moveEntry`), `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts` | | `api-reference/labs-api/browse-and-search.md` | `labs`, `searchLabs`, `labWithDataRoomAndFiles`, `dataRoomFile`, `activities`, `labActivity` resolvers; `graphql/schemas/onchain-activity.graphql` | -| `api-reference/labs-api/legal-agreements.md` | `signLegalAgreement`, `legalAgreementTemplate`, `legalAgreementStatus` resolvers | +| `api-reference/labs-api/legal-agreements.md` | `signLegalAgreement`, `legalAgreementTemplate`, `legalAgreementStatus` resolvers — **frozen and hidden** (`hidden: true`, out of `SUMMARY.md`) since IP-3028: the assignment agreement is no longer a gate on anything and the feature may be removed. Correct outright errors only; never expand it, and never re-introduce a `legalAgreement*` mention into `getting-started/**`, `authentication.md`, `labs-api/README.md` or `example-workflow.md` | | `api-reference/labs-api/service-tokens.md` | `generateServiceToken`, `extendServiceToken`, `revokeServiceToken` resolvers in `lambda/appsync-resolver-labs-lambda/**` (`services/token-manager-service.ts`); `lambda/appsync-authorizer-lambda/**` | | `api-reference/tokenization-api.md` | `graphql/schemas/evm-tokenization.graphql`, `lambda/appsync-resolver-evm-tokenization/**`, `lib/evm-tokenization-service-stack.ts` | | `api-reference/x402-gateway.md` | `lambda/x402-gateway-lambda/**` | diff --git a/SUMMARY.md b/SUMMARY.md index 5661e8e..4e80536 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -34,14 +34,15 @@ ## API Reference * [📡 API Overview](api-reference/README.md) +* [🚀 Getting Started](api-reference/getting-started/README.md) + * [🤖 For Agents: One-Pager](api-reference/getting-started/for-agents.md) * [🔐 Authentication](api-reference/authentication.md) * [⚙️ Labs API](api-reference/labs-api/README.md) + * [Tutorials](api-reference/labs-api/example-workflow.md) * [Lab Management](api-reference/labs-api/lab-management.md) * [Files](api-reference/labs-api/files.md) * [Browse & Search](api-reference/labs-api/browse-and-search.md) - * [Legal Agreements](api-reference/labs-api/legal-agreements.md) * [Service Tokens](api-reference/labs-api/service-tokens.md) - * [Example Workflow: Mint → Upload](api-reference/labs-api/example-workflow.md) * [🔐 Tokenization API](api-reference/tokenization-api.md) * [x402 Gateway](api-reference/x402-gateway.md) * [📊 IPNFT API (Deprecated)](api-reference/ipnft-api-deprecated.md) diff --git a/ai-tooling/molecule-skill.md b/ai-tooling/molecule-skill.md index 996e8c6..8d103b8 100644 --- a/ai-tooling/molecule-skill.md +++ b/ai-tooling/molecule-skill.md @@ -74,7 +74,24 @@ The operating wallet pays real costs: USDC on Base for x402-billed mutations plu ### Configuration -All configuration and secrets are plain **process environment variables** read by the MCP server subprocess — set them wherever your harness injects env into MCP servers (the `env` block of the MCP registration, or Claude Code's settings files as shown in [Installation](molecule-skill.md#claude-code)). Tools read credentials from the environment — the agent passes file paths, queries, and addresses, not keys. The x402 Gateway base URL and contract addresses for each environment are provided by the Molecule team (see [Getting the Plugin](molecule-skill.md#getting-the-plugin)). +All configuration and secrets are plain **process environment variables** read by the MCP server subprocess — set them wherever your harness injects env into MCP servers (the `env` block of the MCP registration, or Claude Code's settings files as shown in [Installation](molecule-skill.md#claude-code)). Tools read credentials from the environment — the agent passes file paths, queries, and addresses, not keys. + +Every non-secret value is published: the GraphQL endpoints on [API Overview](../api-reference/README.md), the [x402 Gateway base URLs](../api-reference/x402-gateway.md#gateway-base-urls), and the contract addresses in the [Contracts reference](../references/contracts/). The only thing you have to request is a `mol_` consumer credential — see [Getting Started](../api-reference/getting-started/README.md#1-a-mol-consumer-credential-the-one-manual-step) for the template. + +**Ready-to-paste values per environment:** + +| Variable | Staging | Production | +| -------- | ------- | ---------- | +| `ENVIRONMENT` | `staging` | `production` | +| `CHAIN_ID` | `84532` | `8453` | +| `MOLECULE_LABS_URL` | `https://staging.graphql.api.molecule.xyz/graphql` | `https://production.graphql.api.molecule.xyz/graphql` | +| `MOLECULE_CLIENT_URL` | `https://testnet.labs.molecule.xyz` | `https://labs.molecule.xyz` | +| `X402_GATEWAY_URL` | `https://0go1j7o645.execute-api.eu-central-2.amazonaws.com/prod` | `https://0qb5gyw72f.execute-api.eu-central-2.amazonaws.com/prod` | +| `ONCHAIN_LAB_FACTORY_ADDRESS` | `0xd629FE2310b4309a212495F10A47f8436dcEfD90` | `0xECdF4f05384056507485C90aeAb0a83268760D6E` | +| `LABNFT_ADDRESS` | `0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28` | `0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92` | +| `ACCESS_RESOLVER_ADDRESS` | `0x5493F472602C87318EA5Eff753cDD593bf9bF559` | `0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B` | + +Run **`config_doctor`** after setting these: it reports which environment profile and wallet backend are active and names exactly which configuration is still missing, instead of letting a tool guess. | Variable | Purpose | | ------------------------------------------------------------ | -------------------------------------------------------------------------------------- | @@ -127,7 +144,7 @@ The plugin is open source — install it from [moleculeprotocol/mol-labs-plugin] git clone https://github.com/moleculeprotocol/mol-labs-plugin.git ``` -You'll still need the environment-specific configuration values that aren't published — the x402 Gateway base URL, contract addresses, and a `mol_` consumer credential — request them on our [Discord community](https://t.co/L0VEiy4Bjk). The repository layout: +The one value you have to request is a `mol_` **consumer credential** — ask on our [Discord community](https://t.co/L0VEiy4Bjk) using the [template in Getting Started](../api-reference/getting-started/README.md#1-a-mol-consumer-credential-the-one-manual-step). Everything else — endpoints, gateway base URLs, contract addresses — is in the [Configuration](molecule-skill.md#configuration) table above. The repository layout: ``` mol-labs-plugin/ @@ -202,7 +219,7 @@ ENVIRONMENT = "staging" MOLECULE_LABS_URL = "https://staging.graphql.api.molecule.xyz/graphql" CHAIN_ID = "84532" WALLET_BACKEND = "privy" -# ...plus the gateway URL, contract addresses, and secrets from the Molecule team +# ...plus the gateway URL and contract addresses from the Configuration table, and your secrets ``` Then copy `skills/aura-orchestrator/SKILL.md` into the skills directory your Codex version scans (check `/skills`), or surface it through `AGENTS.md`. @@ -233,6 +250,8 @@ This lists every tool and exercises the pure-compute ones (encryption round-trip ### Related Pages +* [Getting Started](../api-reference/getting-started/README.md) — the four lanes, prerequisites and costs; this plugin is the "agent runner" lane +* [Tutorials](../api-reference/labs-api/example-workflow.md) — the same workflow as raw GraphQL, if you want to see the calls underneath * [Molecule Labs](../technical-deep-dive/onchain-lab.md) — what an Onchain Lab is * [Roles & Permissions](../technical-deep-dive/roles-and-permissions.md) — the role model used by access conditions * [Data Privacy & Access](../technical-deep-dive/data/data-privacy-and-access.md) — encryption and access evaluation in depth diff --git a/api-reference/README.md b/api-reference/README.md index 7f6de94..9125531 100644 --- a/api-reference/README.md +++ b/api-reference/README.md @@ -4,6 +4,8 @@ The Molecule Protocol provides programmatic APIs for building applications, integrations, and automated workflows on top of decentralized science infrastructure. These APIs enable developers to query project data, tokenize research, and manage research datarooms. +> **New here? Start at [🚀 Getting Started](getting-started/README.md).** It picks your lane, lists the two prerequisites, and walks a ten-minute quickstart that ends in a lab with a file in it. Agents: the [one-pager](getting-started/for-agents.md) is the whole default flow on one page. + ## API Areas ### 📁 Labs API @@ -18,14 +20,14 @@ Upload files to lab datarooms for secure, decentralized research data storage, a * Batch upload research data * Manage file versions, metadata, and LabNFT display metadata * Query labs, files, members, activity, and onchain events (mostly public access) -* Manage service tokens and legal-agreement signing +* Manage service tokens **Authentication:** -* **Most queries** (read operations): consumer credential only — public. One exception, `legalAgreementTemplate`, needs a Service Token or an authenticated session. +* **Queries** (read operations): consumer credential only — public. * **Write mutations** (write operations): consumer credential plus **either** a Service Token (`X-Service-Token`) **or** a Privy user session (`Authorization` + `x-wallet-address`) — the two paths are interchangeable. Exceptions: `extendServiceToken` and `revokeServiceToken` are Service-Token-only, and `generateServiceToken` bootstraps a token from a Privy session or wallet signature. -[View Labs API Documentation →](labs-api/README.md) +[View Labs API Documentation →](labs-api/README.md) · [Tutorials →](labs-api/example-workflow.md) *** @@ -61,6 +63,22 @@ Pay-per-call HTTP 402 gateway that fronts a set of Labs API write mutations with *** +### 🪄 Molecule Skill (agent plugin) + +Not an API surface of its own — the whole Labs workflow packaged as an agent skill plus a typed MCP server, so an AI coding agent runs it as tool calls instead of hand-written requests. + +**Purpose:** + +* Give Claude Code, Codex, or any MCP-capable harness the full Lab lifecycle in one plugin +* Wrap every network, onchain, and cryptographic step as a single typed tool call +* Settle paid mutations automatically through the x402 Gateway + +**Authentication:** Your `mol_` consumer credential, plus a wallet the plugin operates (Privy agentic wallet or raw EOA) + +[View Molecule Skill Documentation →](../ai-tooling/molecule-skill.md) + +*** + ### 📊 IPNFT API (Deprecated) Query and browse IP-NFTs, IP Tokens (IPTs), and market data across the Molecule ecosystem. @@ -80,7 +98,7 @@ Query and browse IP-NFTs, IP Tokens (IPTs), and market data across the Molecule ## Authentication -All Molecule APIs require a consumer credential; the Labs API additionally uses a Service Token for write operations. Obtaining credentials, the per-API header requirements, and the full Labs API authentication model (public queries vs. protected mutations) are documented on the dedicated [Authentication](authentication.md) page. +All Molecule APIs require a consumer credential; the Labs API additionally uses a Service Token for write operations, which callers **issue for themselves** by signing a message with their wallet — no manual provisioning. Obtaining credentials, the per-API header requirements, and the full Labs API authentication model (public queries vs. protected mutations) are documented on the dedicated [Authentication](authentication.md) page. *** @@ -95,23 +113,22 @@ Staging: https://staging.graphql.api.molecule.xyz/graphql *** -## Quick Start Guide - -### 1. Get API Access - -Contact the Molecule team via [Discord](https://t.co/L0VEiy4Bjk) to obtain your consumer credential. +## Quick Start -### 2. Choose Your API +The full quickstart — prerequisites, costs, and a ten-minute path to a lab with a file in it — is on **[🚀 Getting Started](getting-started/README.md)**. In short: -| If you want to... | Use this API | -| ---------------------------------------- | --------------------------------------- | -| Upload files to a Lab dataroom | [Labs API](labs-api/README.md) | -| Tokenize a Lab into IP Tokens (IPTs) | [Tokenization API](tokenization-api.md) | -| Pay per call without a long-lived token | [x402 Gateway](x402-gateway.md) | -| Browse IP-NFTs and IPTs (legacy) | [IPNFT API (Deprecated)](ipnft-api-deprecated.md) | +| If you want to... | Go to | +| --------------------------------------------- | ------------------------------------------------- | +| Get from zero to a lab with a file in it | [Getting Started](getting-started/README.md) | +| Point an AI agent at this API | [Agent one-pager](getting-started/for-agents.md) · [Molecule Skill](../ai-tooling/molecule-skill.md) | +| Upload files to a Lab dataroom | [Labs API](labs-api/README.md) · [Tutorials](labs-api/example-workflow.md) | +| Let an agent write into a lab someone else owns | [Tutorial 3](labs-api/example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app) | +| Tokenize a Lab into IP Tokens (IPTs) | [Tokenization API](tokenization-api.md) | +| Pay per call without a long-lived token | [x402 Gateway](x402-gateway.md) | +| Browse IP-NFTs and IPTs (legacy) | [IPNFT API (Deprecated)](ipnft-api-deprecated.md) | | Check market prices and trading data (legacy) | [IPNFT API (Deprecated)](ipnft-api-deprecated.md) | -### 3. Make Your First Request +### Make your first request **Example (Labs API — public `labs` query, consumer credential only):** @@ -138,6 +155,10 @@ If you encounter any issues or have questions about the APIs: ## Additional Resources +* [Getting Started](getting-started/README.md) — prerequisites, costs, ten-minute quickstart +* [Agent one-pager](getting-started/for-agents.md) — the default flow, paste-ready +* [Getting the schema](getting-started/README.md#getting-the-schema) — staging introspection is enabled; production's is not +* [Molecule Skill](../ai-tooling/molecule-skill.md) — the agent plugin that drives this API * [Smart Contract Addresses](../references/contracts/) *** diff --git a/api-reference/authentication.md b/api-reference/authentication.md index 5218292..e20a25a 100644 --- a/api-reference/authentication.md +++ b/api-reference/authentication.md @@ -2,17 +2,34 @@ All Molecule APIs require authentication. This page covers how to obtain credentials, which headers each API expects, and the specific authentication model for the Labs API. +There are two credentials, and they do different jobs: + +| Credential | Answers | How you get it | +| ---------- | ------- | -------------- | +| **Consumer credential** (`mol__`) | *Which API consumer is calling?* | Requested once from the Molecule team — the one manual step | +| **Service Token** (JWT) | *Which wallet is calling, so what may it do?* | **Self-issued**: sign a message with your wallet. No human in the loop | + ## Obtaining API Access -All Molecule APIs require authentication with a consumer credential. To request access: +Every request carries a **consumer credential** in the `Authorization` header. Request one on the [Molecule Discord](https://t.co/L0VEiy4Bjk) with this template: + +``` +Consumer credential request +- Who: +- What you're building: +- Environment: staging (add production if you need both) +- Contact: +``` + +What comes back is one opaque string per environment: -1. Join our [Discord community](https://t.co/L0VEiy4Bjk) -2. Contact the Molecule team with your use case -3. You'll receive: - * **Consumer credential** (`mol__`) - Required for all APIs - * **Service Token** - Additional token for Labs API (if needed) +``` +mol__ +``` -Send the consumer credential as the `Authorization` header value directly — **no `Bearer` prefix**: `Authorization: mol__`. This differs from the Privy path below, which does use `Bearer`; adding `Bearer` in front of a consumer credential makes the request fail authentication. Treat the entire string as a secret — it is not split into a public/private part. +Send it as the `Authorization` header value directly — **no `Bearer` prefix**: `Authorization: mol__`. This differs from the Privy path below, which does use `Bearer`; adding `Bearer` in front of a consumer credential makes the request fail authentication. Treat the entire string as a secret — it is not split into a public/private part. Credentials are per environment: a staging credential does not authenticate against production. + +You do **not** need to ask anyone for a Service Token. Write mutations need one, and you mint it yourself by signing a message with your wallet — see [Service Tokens](labs-api/service-tokens.md#obtaining-a-token), or [Tutorial 1 Step 1](labs-api/example-workflow.md#step-1-get-a-service-token) for the runnable version. ## Authentication Headers @@ -32,19 +49,19 @@ Send the consumer credential as the `Authorization` header value directly — ** The Labs API has different authentication requirements depending on the operation type: -> **Rule of thumb**: Most **queries** are public (consumer credential only). Write **mutations** are authenticated, and most accept **either** a Service Token **or** a Privy user session — pick whichever fits your caller. The exceptions are called out below: one query is gated, the two Service Token lifecycle mutations are service-token-only, and `generateServiceToken` bootstraps a token with a Privy session or wallet signature. +> **Rule of thumb**: **reads are public** (consumer credential only). Write **mutations** are authenticated, and most accept **either** a Service Token **or** a Privy user session — pick whichever fits your caller. The exceptions are called out below: the two Service Token lifecycle mutations are service-token-only, and `generateServiceToken` bootstraps a token with a wallet signature or a Privy session. Summary of the model: -* **Most queries are public**: consumer credential only for read operations. Exception: `legalAgreementTemplate` requires a Service Token or an authenticated session. +* **Reads are public**: consumer credential only for the queries listed below. * **Write mutations are authenticated, with two interchangeable paths**: consumer credential plus **either** `X-Service-Token` (machine callers — services, bots, agents) **or** `Authorization` + `x-wallet-address` (Privy user session — browser and app callers). Authorization is then evaluated against the caller's identity either way. -* **Exceptions**: `extendServiceToken` and `revokeServiceToken` accept **only** a Service Token. `generateServiceToken` accepts **only** a Privy session or wallet signature, since it mints the token in the first place. -* **Service Token**: Identifies which specific lab/dataroom you have write access to. +* **Exceptions**: `extendServiceToken` and `revokeServiceToken` accept **only** a Service Token. `generateServiceToken` accepts **only** a consumer credential plus a wallet signature or a Privy session, since it mints the token in the first place. +* **A Service Token is bound to a wallet, not to a lab.** It carries the wallet's identity; what it may do on a given lab is resolved per request from that wallet's onchain role. See [What a Service Token actually authorizes](#what-a-service-token-actually-authorizes). * File-level access control is handled via Molecule's Onchain-Verified Envelope Encryption, not query authentication — see [Data Privacy & Access](../technical-deep-dive/data/data-privacy-and-access.md). ### Public Queries (Read-Only) -**These queries** are public and only require a consumer credential: +The Labs read surface is public — these queries need only a consumer credential: - `labs` - List all labs with pagination - `labWithDataRoomAndFiles` - Get lab details and files @@ -55,7 +72,6 @@ Summary of the model: - `fileCategoriesAndTags` - List valid file categories and their tags - `getServiceSignInMessage` - Get the message a service signs to obtain a token - `getDidLinkStatus` - Get background DID-linking status for a lab -- `legalAgreementStatus` - Check whether a lab's legal agreement is signed - `onChainActivity` - Onchain event feed for a lab or wallet - `listLabMembers` - List a lab's members @@ -63,9 +79,7 @@ Summary of the model: Authorization: YOUR_CONSUMER_CREDENTIAL ``` -**Authenticated query** — consumer credential **plus** a Service Token, or an authenticated user session: - -- `legalAgreementTemplate` - Get the populated agreement to sign (the signer's authenticated session, or a service token) +Sending `X-Service-Token` on a public query is unnecessary, and sending an empty one is worse than omitting the header entirely. ### Protected Mutations (Write Operations) @@ -85,21 +99,25 @@ Authorization: Bearer YOUR_PRIVY_TOKEN x-wallet-address: YOUR_WALLET_ADDRESS ``` -Either way, the caller still has to be authorized for the target lab — a Service Token carries its own lab scope, and a Privy session is checked against the wallet's onchain role (LabNFT owner, authorized multisig signer, or an active role on `AccessResolver`). Supplying neither returns a `NO_AUTH` error naming both paths. +Either way, the caller still has to be authorized for the target lab, and the check is the same on both paths: the wallet's onchain role on that lab (LabNFT owner, authorized multisig signer, or an active Contributor/Viewer grant on `AccessResolver`). Supplying neither path returns an `UNAUTHENTICATED` error with `details.reason` `NO_AUTH`, naming both. **Mutations accepting either path:** -- `createLab` - Create a lab (data room) for an onchain lab (OCL) · 💳 also available pay-per-call via [x402 Gateway](x402-gateway.md) -- `initiateCreateOrUpdateFile` - Initiate file upload · 💳 also available pay-per-call via [x402 Gateway](x402-gateway.md) -- `finishCreateOrUpdateFile` - Complete file upload · 💳 also available pay-per-call via [x402 Gateway](x402-gateway.md) -- `updateFileMetadata` - Update file metadata -- `deleteDataRoomFile` - Delete a file -- `createAnnouncement` - Create an announcement · 💳 also available pay-per-call via [x402 Gateway](x402-gateway.md) -- `updateLabNftMetadata` - Update LabNFT display metadata (OCL admin only) -- `generateLabImageUploadUrl` - Get a presigned URL to upload a LabNFT image (OCL admin only) -- `signLegalAgreement` - Record acceptance of a legal agreement -- `generateDataEncryptionKey` - Generate a standalone data encryption key · 💳 also available pay-per-call via [x402 Gateway](x402-gateway.md) -- `decryptDataKey` - Decrypt a file's data key for an authorized caller · 💳 also available pay-per-call via [x402 Gateway](x402-gateway.md) +| Mutation | Minimum role | Notes | +| -------- | ------------ | ----- | +| `createLab` - Create a lab (data room) for an onchain lab (OCL) | Owner of the OCL | 💳 also pay-per-call via [x402](x402-gateway.md) | +| `initiateCreateOrUpdateFile` - Initiate file upload | Contributor | 💳 also pay-per-call via [x402](x402-gateway.md) | +| `finishCreateOrUpdateFile` - Complete file upload | Contributor | 💳 also pay-per-call via [x402](x402-gateway.md) | +| `updateFileMetadata` - Update file metadata | Contributor | | +| `deleteDataRoomFile` - Delete a file | Contributor | | +| `moveEntry` - Move a file or folder | Contributor | | +| `createAnnouncement` - Create an announcement | Contributor | 💳 also pay-per-call via [x402](x402-gateway.md) | +| `updateLabNftMetadata` - Update LabNFT display metadata | **Owner only** | | +| `generateLabImageUploadUrl` - Presigned URL for a LabNFT image | **Owner only** | | +| `generateDataEncryptionKey` - Generate a standalone data encryption key | Authenticated, no role | Takes no `oclId`, so there is no lab to check against. 💳 also pay-per-call via [x402](x402-gateway.md) | +| `decryptDataKey` - Decrypt a file's data key | **Viewer**, *and* the file's own conditions | Two gates: the Viewer check on the lab, then a live onchain evaluation of the file's `accessControlConditions`. 💳 also pay-per-call via [x402](x402-gateway.md) | + +The Owner passes every check; a Contributor passes Contributor and Viewer checks. Full capability matrix: [Roles & Permissions](../technical-deep-dive/roles-and-permissions.md). **Service-Token-only mutations** — these manage token lifecycle and reject Privy sessions: @@ -111,24 +129,44 @@ Authorization: YOUR_CONSUMER_CREDENTIAL X-Service-Token: YOUR_SERVICE_TOKEN ``` -> **`generateServiceToken` is the bootstrap exception**, in the opposite direction: it mints a Service Token, so it accepts *only* a consumer credential plus either a Privy session or a wallet signature — not a pre-existing Service Token. See [Obtaining Tokens](labs-api/service-tokens.md#obtaining-tokens). +Both are scoped to the caller's **own** tokens: the token presented must own the `tokenId` it names, so one caller cannot extend or revoke another's. A `tokenId` belonging to someone else returns the same `NOT_FOUND` as one that does not exist. + +> **`generateServiceToken` is the bootstrap exception**, in the opposite direction: it mints a Service Token, so it accepts *only* a consumer credential plus either a wallet signature or a Privy session — not a pre-existing Service Token. See [Obtaining a Token](labs-api/service-tokens.md#obtaining-a-token). > **Pay-per-call alternative.** Mutations tagged 💳 above can also be called through the [x402 Gateway](x402-gateway.md), which settles a USDC payment on Base per request and mints a short-lived service token on the fly — no long-lived credentials required. Useful for autonomous AI agents and third-party tools that pay for users. -### Obtaining a Consumer Credential and Service Token +### Obtaining a Service Token + +Self-service, two calls, no human in the loop. Full reference with parameters and failure modes: [Service Tokens](labs-api/service-tokens.md#obtaining-a-token). Runnable: [Tutorial 1 Step 1](labs-api/example-workflow.md#step-1-get-a-service-token). + +1. **`getServiceSignInMessage(walletAddress, serviceName)`** — a public query returning the deterministic message to sign. +2. **Sign it verbatim** with the wallet, as a plain personal message (EIP-191 `personal_sign` — **not** typed data). Re-wording or re-formatting the string breaks verification. +3. **`generateServiceToken(serviceName, walletAddress, messageSignature, expiresIn)`** — returns the JWT to send as `X-Service-Token`, plus a `tokenId` for lifecycle operations. + +| `expiresIn` | Value | +| ----------- | ----- | +| Default when omitted | `180d` | +| Format | ``, unit one of `s` `m` `h` `d` `w` `M` `y` (e.g. `"30d"`, `"720h"`, `"6M"`) | +| Minimum | 1 hour | +| Maximum | 2 years | + +A value outside those bounds, or in another format, is rejected with `VALIDATION_FAILED`. + +Issuance is **not** gated on holding a role on any lab — any wallet can mint a token for itself. The role is what makes the token useful. + +### What a Service Token actually authorizes + +A Service Token is **wallet-bound, not lab-bound.** It says "this wallet is calling"; it does not carry a list of labs. + +On every request, the API resolves what the token's wallet may do on the lab named in the call from that wallet's **live onchain role**. Three consequences worth internalising: + +* **One token works across every lab the wallet has a role on.** You do not issue a token per lab. +* **A role granted after the token was issued takes effect without re-issuing it.** Likewise a revoked role stops the token on that lab immediately, while leaving it valid elsewhere. +* **A token for a wallet with no role authenticates but cannot write.** You will see `UNAUTHENTICATED` become `UNAUTHORIZED`: the caller is known, just not permitted. -To obtain access credentials: +This is why an agent can be handed access to a lab it does not own — the human grants the agent's wallet a role, and the agent's own token starts working on that lab. See [Tutorial 3](labs-api/example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app). -1. Join our [Discord community](https://t.co/L0VEiy4Bjk) -2. Contact the Molecule team and provide: - - Your wallet address (will be linked to the service token) - - Intended use case / service name - - Which lab/dataroom you need access to - - Desired token expiration period -3. The team will generate and provide you with: - - **Consumer credential** (`mol__`) - Used for all Molecule APIs - - **Service Token** (JWT string) - Grants access to specific lab - - **Token ID** - For management operations +> Because role state reaches the API through an event indexer, there is a short window after a role grant confirms onchain in which a write can still return `UNAUTHORIZED`. Retry with backoff; re-issuing the token does not help. ### Using Your Credentials @@ -155,17 +193,18 @@ x-wallet-address: YOUR_WALLET_ADDRESS **Why more than one header for mutations?** - **Consumer credential**: Authenticates you as a valid Molecule API consumer -- **Service Token**: Identifies which specific lab/dataroom your service has write access to -- **Privy token + wallet address**: Identifies the human caller instead, whose write access is derived from their wallet's onchain role +- **Service Token**: Identifies the *wallet* the request acts as; its permissions come from that wallet's onchain role on the target lab +- **Privy token + wallet address**: Identifies a human caller instead, whose write access is derived from the same onchain role model -Which path to choose: use a Service Token for unattended callers (backends, bots, agents, CI/CD) where there is no user session to draw on. Use the Privy path when a signed-in user is driving the request, so the action is attributed to their wallet and governed by their onchain role rather than a shared service credential. +Which path to choose: use a Service Token for unattended callers (backends, bots, agents, CI/CD) where there is no user session to draw on. Use the Privy path when a signed-in user is driving the request, so the action is attributed to their wallet. **Security Warnings:** -- Service tokens are shown only once during generation - store them securely immediately +- Service tokens are returned only once, at generation — store them securely immediately - Never commit tokens or consumer credentials to version control - Never log credentials in application logs - Store in environment variables or secure secret management systems -- Rotate tokens regularly (quarterly recommended) +- Give an agent's token an `expiresIn` matching its role grant's expiry rather than taking the 180-day default +- Rotate tokens regularly, and `revokeServiceToken` immediately on compromise > Service Token lifecycle operations (extending, revoking) are documented in [Service Tokens](labs-api/service-tokens.md). diff --git a/api-reference/changelog.md b/api-reference/changelog.md index d52b987..b81b495 100644 --- a/api-reference/changelog.md +++ b/api-reference/changelog.md @@ -10,6 +10,24 @@ This page tracks breaking changes, deprecations, and additions across the Molecu ## Authentication +### Service tokens are self-issued, and scoped to their own lifecycle + +Two clarifications and one hardening, all now reflected across the API docs: + +- **Nobody provisions a service token for you.** `generateServiceToken` accepts a wallet signature, so any caller mints its own: `getServiceSignInMessage` → sign the message verbatim (EIP-191 `personal_sign`) → `generateServiceToken`. The only credential that still comes from the Molecule team is the `mol_` consumer credential. Earlier pages described service tokens as team-issued; that was never the only path and is no longer the documented one. +- **A service token is bound to a wallet, not to a lab.** Docs that described it as identifying "which lab you have write access to" were wrong. It carries a wallet identity; what it may do on a given lab is resolved per request from that wallet's live onchain role. One token therefore works across every lab the wallet has a role on, and a role granted *after* issuance takes effect without re-issuing. +- **`extendServiceToken` and `revokeServiceToken` are scoped to the caller's own tokens.** The presented token must own the `tokenId` it names; a `tokenId` belonging to another wallet returns the byte-identical `NOT_FOUND` of one that does not exist, so token existence cannot be enumerated. + +**Migration:** None required if you already self-issue. If you hold a team-provisioned token, it keeps working — but you can mint and rotate your own. If you built per-lab token issuance, you can collapse it to one token per wallet. If any code called `extendServiceToken` / `revokeServiceToken` for a `tokenId` issued to a different wallet, it now receives `NOT_FOUND`. See [Authentication](authentication.md#obtaining-a-service-token) and [Service Tokens](labs-api/service-tokens.md#obtaining-a-token). + +### Contributor role parity for service-token content writes + +The six content-write mutations — `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createAnnouncement`, `deleteDataRoomFile`, `updateFileMetadata`, `moveEntry` — now gate a service token on the **Contributor** role, matching the Privy user path per mutation. Previously the service path required lab ownership for these, which meant a wallet granted Contributor on a lab could act through a user session but not through its own service token. + +This is what unblocks the "human owns the lab, agent contributes to it" flow: the owner grants the agent's wallet Contributor, and the agent's self-issued token can write. Owner-gated surfaces are unchanged — `createLab`, `updateLabNftMetadata` and `generateLabImageUploadUrl` still require ownership. + +**Migration:** None — this is a widening. Note that role state reaches the API through an event indexer, so a write can still return `UNAUTHORIZED` for a few seconds after a grant confirms onchain; retry with backoff rather than re-issuing the token. Walkthrough: [Tutorial 3](labs-api/example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app). + ### Backend credential stores confined to the platform network The data stores behind API authentication — the consumer credential registry, machine service tokens, and the access whitelist — are now network-confined to Molecule's private cloud network. They are unreachable from outside it, even with valid cloud-account credentials; only the API's own backend can read or write them. This is a hardening change with **no effect on any API, header, token format, or SDK** — consumer credentials, `X-Service-Token`, and Privy user tokens all work exactly as before. @@ -25,17 +43,33 @@ All Molecule APIs (Labs, Tokenization, and IPNFT (Deprecated) — they share one + Authorization: mol__ ``` -**Migration:** Contact the Molecule team for a consumer credential and send it as `Authorization: mol__` instead of `x-api-key` — do not prefix it with `Bearer`, which is reserved for Privy user tokens and will fail authentication. Nothing else changes: `X-Service-Token` for machine-authorized mutations, and `Authorization: Bearer ` + `x-wallet-address` for user-authorized mutations, work exactly as before. See [Authentication](authentication.md) for the full header reference. +**Migration:** Request a consumer credential from the Molecule team ([template](getting-started/README.md#1-a-mol-consumer-credential-the-one-manual-step)) and send it as `Authorization: mol__` instead of `x-api-key` — do not prefix it with `Bearer`, which is reserved for Privy user tokens and will fail authentication. Nothing else changes: `X-Service-Token` for machine-authorized mutations, and `Authorization: Bearer ` + `x-wallet-address` for user-authorized mutations, work exactly as before. See [Authentication](authentication.md) for the full header reference. --- ## Labs API +### Assignment Agreement is no longer a gate, and is out of the API docs + +Signing the assignment agreement is **not** a precondition for `createLab`, for uploading files, or for any other Labs API operation. It was previously presented as a required onboarding step, and the `_NOT_SIGNED` / `AGREEMENT_CHECK_UNAVAILABLE` failure causes are now dormant — reserved in the catalogue, but not emitted. + +The `legalAgreement*` operations remain in the schema and are unchanged, but they have been removed from every onboarding and reference flow, and [Legal Agreements](labs-api/legal-agreements.md) is out of the site navigation. Do not build a new integration around them. + +**Migration:** Delete any agreement-signing step from your workflow — it does nothing. If you branch on an agreement status before writing, remove the branch. Nothing in the API changed; only what is required of you did. The onboarding tutorials no longer include the step: [Tutorials](labs-api/example-workflow.md). + +### Staging introspection is the supported way to get the schema + +Production has introspection disabled (below), but **staging has it enabled** — point codegen, a playground or an SDK generator at `https://staging.graphql.api.molecule.xyz/graphql` with your consumer credential and generate normally. Both environments serve the same schema, so generate against staging and point the generated client at production. + +This supersedes the earlier guidance to request a copy of the schema from the Molecule team. + +**Migration:** If your codegen currently fails against production, repoint it at staging. See [Getting the schema](getting-started/README.md#getting-the-schema). + ### GraphQL introspection disabled and query depth capped in production The production endpoint (shared by all Molecule APIs — see [API Overview](README.md)) no longer serves `__schema` / `__type` introspection queries: they now return a validation error. `__typename` still resolves. Selection-set depth is also capped at 10 in production, with scalar leaves counted as a level (`{ root { child { name } } }` is depth 3). A query beyond that limit fails at execution time with `errorType: "QueryDepthLimitReached"` and partial data — a plain GraphQL error, not the catalogued error shape used elsewhere, so handle both. -**Migration:** If your codegen or tooling discovers the schema by introspecting the production endpoint, that now fails — request a current copy of the schema from the Molecule team (see [Getting Support](README.md)) rather than introspecting production. If you see `QueryDepthLimitReached`, flatten the query to 10 levels of nesting or fewer; this limit was not previously enforced. +**Migration:** If your codegen or tooling discovers the schema by introspecting the production endpoint, that now fails — introspect **staging** instead, where it is enabled, and point the generated client at production (see [Getting the schema](getting-started/README.md#getting-the-schema)). If you see `QueryDepthLimitReached`, flatten the query to 10 levels of nesting or fewer; this limit was not previously enforced. ### `isSuccess` removed (unified error contract) @@ -171,6 +205,20 @@ Top-level identifiers on `Lab` / `LabRef` were renamed away from the legacy IP-N --- +## x402 Gateway + +### Gateway base URLs published, and the 402 challenge is a header + +The staging and production gateway base URLs are now published on the [x402 Gateway](x402-gateway.md#gateway-base-urls) page — they no longer have to be requested. + +Along with them, one correction that matters for anyone implementing the handshake: the payment requirements arrive as **base64-encoded JSON in the `payment-required` response header**, not in the `402` response body. The body is only `{"isSuccess":false,"message":"Payment required"}`. Client code that parsed the body for `accepts` never saw a price. + +**Migration:** Read the challenge from the `payment-required` header and base64-decode it; take `amount`, `asset`, `network` and `payTo` from `accepts[0]`. `amount` is in the asset's smallest unit (USDC has 6 decimals, so `"10000"` is $0.01). Worked example: [Reading the 402 challenge](x402-gateway.md#reading-the-402-challenge). + +Also worth knowing: payment buys a short-lived service token for the payer wallet, **not** a role. A mutation the payer is not authorized for returns `200` with `error.code: "UNAUTHORIZED"` and is still settled — check the target lab and your role on it (both free, public queries) before signing. + +--- + ## IPNFT API (Deprecated) > The IPNFT API is deprecated. The changes below are preserved for integrations that have not yet migrated. See the [IPNFT API reference](ipnft-api-deprecated.md). diff --git a/api-reference/getting-started/README.md b/api-reference/getting-started/README.md new file mode 100644 index 0000000..403920a --- /dev/null +++ b/api-reference/getting-started/README.md @@ -0,0 +1,194 @@ +--- +description: >- + Pick the lane that fits your caller, gather the two prerequisites, and get a + lab with a file in it inside ten minutes. +icon: rocket +--- + +# 🚀 Getting Started + +This is the entry point to the Molecule API. It does three things: helps you **pick a lane**, lists the **two prerequisites** you actually need, and walks a **ten-minute quickstart** that ends with a lab you can see. + +Everything below runs against **staging** (Base Sepolia, testnet funds). Nothing here spends real money. The [staging → production swap table](../labs-api/example-workflow.md#running-in-production) is at the end of the tutorials. + +*** + +## Choose your path + +Four ways to write to a Lab. They are not ranked — pick by who is calling. + +| Your situation | Lane | Start here | +| -------------- | ---- | ---------- | +| **I run an AI coding agent** (Claude Code, Codex, Cursor) and want it to do the whole workflow | **Molecule Skill plugin** — a skill + MCP server that wraps every network, onchain and crypto operation as one tool call | [Molecule Skill](../../ai-tooling/molecule-skill.md) | +| **I'm scripting against the API** in Node/TypeScript and want to see the raw calls | **Raw GraphQL + viem** — self-issue a service token, mint, upload | [Tutorial 1](../labs-api/example-workflow.md#tutorial-1-create-a-lab-and-upload-a-public-file) | +| **I have no credential, or I want to pay per call** instead of holding a long-lived token | **x402 gateway** — settle USDC on Base per request, no service token to provision | [x402 Gateway](../x402-gateway.md) | +| **I already made my lab in the app** (email sign-in, no wallet) **and now I want my agent writing into it** | **Agent-as-Contributor** — the human grants a role, the agent issues its own token | [Tutorial 3](../labs-api/example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app) | + +The lanes compose. A common shape is the plugin lane for the workflow plus x402 for the paid mutations, which is exactly what the plugin does by default. + +### If you are an agent reading this + +Go to the [**Agent one-pager**](for-agents.md) instead. It is the whole default flow on a single page with no prose detours — written to be pasted into a system prompt. + +*** + +## Prerequisites + +Two things, and only one of them involves a human. + +### 1. A `mol_` consumer credential — the one manual step + +Every request to the API carries a consumer credential in the `Authorization` header. There is no self-service issuance yet, so this is the single "ask the team" left in the API docs. + +Request it on the [Molecule Discord](https://t.co/L0VEiy4Bjk) with this template: + +``` +Consumer credential request +- Who: +- What you're building: +- Environment: staging (add production if you need both) +- Contact: +``` + +What comes back is a single opaque string per environment: + +``` +mol__ +``` + +Send it as the `Authorization` header value **directly — no `Bearer` prefix**: + +```bash +Authorization: mol_your-consumer-id_your-secret +``` + +Treat the whole string as one secret: it is not split into a public and a private half. Full header reference: [Authentication](../authentication.md). + +{% hint style="warning" %} +`Authorization: Bearer mol_…` fails authentication. `Bearer` is reserved for Privy user tokens. +{% endhint %} + +### 2. A funded wallet on Base Sepolia + +You need an EOA with testnet ETH for the LabNFT mint. Fund it from a [Base Sepolia faucet](https://docs.base.org/base-chain/tools/network-faucets). + +If — and only if — you are taking the **x402 lane**, you also need testnet **USDC** on Base Sepolia: get it from the [Circle faucet](https://faucet.circle.com/) (select Base Sepolia). The service-token lane needs no USDC at all. + +You do **not** need a pre-issued service token. Every tutorial below mints its own from a wallet signature in the first step. + +### What it costs + +| Item | Cost | How we know | +| ---- | ---- | ----------- | +| **LabNFT mint** | Gas only. `mintFeeWei()` reads **0** on Base Sepolia **and** on Base mainnet (verified 2026-08-27 by `eth_call`) | Read it live yourself — the tutorials do, and send it as `value` | +| **`createLab`, uploads, announcements** (service-token lane) | Free | Consumer credential + self-issued service token | +| **The same mutations via x402** | Quoted per request in the `402` challenge — **$0.01 USDC** on both environments today | [Read the price off the challenge](../x402-gateway.md#reading-the-402-challenge); never hardcode it | +| **Storage** | 5 GB per lab included | [Limits](../labs-api/files.md#storage-limits) | + +`mintFeeWei()` is a live contract read, not a constant. The tutorials call it and forward the result, so a future non-zero fee needs no code change on your side — but it will need funds. + +### Tooling + +```bash +npm install viem # Node 18+ has fetch and node:crypto built in +``` + +Or, for the plugin lane, install [`mol-labs-plugin`](../../ai-tooling/molecule-skill.md#getting-the-plugin) and run `config_doctor` — it names exactly which configuration is still missing instead of letting a tool guess: + +```bash +claude --plugin-dir /path/to/mol-labs-plugin +``` + +*** + +## Ten-minute quickstart + +The shortest path from "I have a credential" to "there is a lab with my file in it". Four calls and one transaction. Each step is the condensed form of [Tutorial 1](../labs-api/example-workflow.md#tutorial-1-create-a-lab-and-upload-a-public-file), which shows the expected response and the failure modes for every call. + +```bash +export CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" +export WALLET_PRIVATE_KEY="0x…" # funded on Base Sepolia +``` + +1. **Self-issue a service token** — `getServiceSignInMessage` → sign the message with your wallet (EIP-191 `personal_sign`) → `generateServiceToken`. No human in the loop. +2. **Mint the LabNFT** — `OnChainLabFactory.mintAndCreateAccount(yourAddress)` with `value: mintFeeWei()`. Read `oclId` off the `OclIdentityCreated` event. +3. **Register the lab** — `createLab(input: { oclId })`. +4. **Upload a file** — `initiateCreateOrUpdateFile` → `PUT` the bytes to the returned presigned URL → `finishCreateOrUpdateFile` with `accessLevel: "PUBLIC"`. +5. **Verify it worked** — see below. + +The runnable version is the [Tutorial 1 complete script](../labs-api/example-workflow.md#tutorial-1-complete-script): + +```bash +node tutorial-1.js ./research-data.csv +``` + +### Verify it worked + +Two checks. The first works on every environment and needs nothing but your credential: + +```bash +curl -s -X POST https://staging.graphql.api.molecule.xyz/graphql \ + -H 'Content-Type: application/json' \ + -H "Authorization: $CONSUMER_CREDENTIAL" \ + -d '{ + "query": "query($oclId: String!) { labWithDataRoomAndFiles(oclId: $oclId) { oclId shortname name dataRoom { id files { path contentType accessLevel version } } } }", + "variables": { "oclId": "0xYOUR_OCL_ID" } + }' +``` + +Your file appears in `dataRoom.files` with the `path` you sent and `accessLevel: "PUBLIC"`. If `labWithDataRoomAndFiles` returns `null`, `createLab` did not complete — it is one of the two nullable queries on this API, so a missing lab nulls the field rather than throwing. + +The second is visual — once `shortname` is populated, the lab has a page: + +| Environment | Lab page | +| ----------- | -------- | +| Staging | `https://testnet.labs.molecule.xyz/projects/` | +| Production | `https://labs.molecule.xyz/projects/` | + +`shortname` is derived server-side from the lab's name and is `null` until it has been derived — a freshly minted lab is reachable by `oclId` before it is reachable by slug. + +*** + +## Then what + +| Next | Page | +| ---- | ---- | +| Every step with expected responses and failure handling | [Tutorial 1 — public upload](../labs-api/example-workflow.md#tutorial-1-create-a-lab-and-upload-a-public-file) | +| Encrypt a file so only wallets with a role can read it | [Tutorial 2 — encrypted upload](../labs-api/example-workflow.md#tutorial-2-upload-an-encrypted-file) | +| Let an agent write into a lab a human created in the app | [Tutorial 3 — agent access](../labs-api/example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app) | +| Publish an update that attaches the dataset | [Tutorial 4 — announce](../labs-api/example-workflow.md#tutorial-4-announce-the-dataset) | +| Pay per call instead of holding a token | [x402 Gateway](../x402-gateway.md) | +| Full operation reference | [Labs API](../labs-api/README.md) | +| What every error code means | [Error handling](../labs-api/README.md#error-handling) | + +*** + +## Getting the schema + +**Staging has GraphQL introspection enabled** — point codegen, a playground or an SDK generator straight at it: + +``` +https://staging.graphql.api.molecule.xyz/graphql +``` + +```bash +# graphql-codegen, apollo, gql.tada… all work against staging +DESCI_API_SCHEMA=https://staging.graphql.api.molecule.xyz/graphql npx graphql-codegen +``` + +Introspection requires only the `Authorization` consumer-credential header, the same as any query. + +**Production has introspection disabled**, deliberately — `__schema` and `__type` return a validation error there (`__typename` still resolves), and selection-set depth is capped at 10. Generate against staging and point the generated client at production; the two environments serve the same schema. + +Production also enforces a query-depth limit of 10, which fails at execution time with `errorType: "QueryDepthLimitReached"` and partial data — a plain GraphQL error, not the catalogued shape. Handle both. + +*** + +## Endpoints + +``` +Staging: https://staging.graphql.api.molecule.xyz/graphql +Production: https://production.graphql.api.molecule.xyz/graphql +``` + +x402 gateway base URLs are published on the [x402 Gateway](../x402-gateway.md#gateway-base-urls) page. diff --git a/api-reference/getting-started/for-agents.md b/api-reference/getting-started/for-agents.md new file mode 100644 index 0000000..ed23ea6 --- /dev/null +++ b/api-reference/getting-started/for-agents.md @@ -0,0 +1,170 @@ +--- +description: >- + The whole default Labs flow on one page, no prose — the page to paste into a + system prompt. +icon: robot +--- + +# 🤖 For Agents: One-Pager + +The complete default flow — self-issue a token, mint a lab, upload a public file, verify — with nothing else on the page. Copy it into a system prompt or a context file. For the same flow with expected responses, failure modes and the encrypted variant, use [the tutorials](../labs-api/example-workflow.md). + +## Constants (staging / Base Sepolia) + +``` +GRAPHQL_URL https://staging.graphql.api.molecule.xyz/graphql +CHAIN baseSepolia (84532) +ONCHAIN_LAB_FACTORY 0xd629FE2310b4309a212495F10A47f8436dcEfD90 +LABNFT 0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28 +ACCESS_RESOLVER 0x5493F472602C87318EA5Eff753cDD593bf9bF559 +ACCESS_CONDITION_CHAIN "baseSepolia" +LAB_PAGE https://testnet.labs.molecule.xyz/projects/ +``` + +Production: `https://production.graphql.api.molecule.xyz/graphql`, `base` (8453), factory `0xECdF4f05384056507485C90aeAb0a83268760D6E`, LabNFT `0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92`, AccessResolver `0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B`, condition chain `"base"`, lab page `https://labs.molecule.xyz/projects/`. + +## Headers + +``` +Content-Type: application/json +Authorization: mol__ # NEVER prefixed with "Bearer" +X-Service-Token: # mutations only; omit the header entirely if you have none +``` + +Public queries take `Authorization` alone. Sending `X-Service-Token` on a public query is unnecessary; sending an empty one is worse than omitting it. + +## Error contract + +* **Queries throw.** Failure lands in top-level `errors[]`. Branch on `errors[i].errorType`. `errors[i].errorInfo` is `{ requestId, retryable, details }` and `details` is already an object. +* **Mutations return errors in-band.** Every `*Result` has `error: ApiError`. **Success ⇔ `error == null`.** Select `error { code message requestId retryable details }` on every mutation. In-band `details` is a **JSON-encoded string** — `JSON.parse(error.details ?? "{}")`. +* Branch on `code`, never on `message`. Retry only when `retryable` is `true`, with exponential backoff. Quote `requestId` in any bug report. +* Codes: `UNAUTHENTICATED`, `UNAUTHORIZED`, `NOT_FOUND`, `VALIDATION_FAILED`, `CONFLICT`, `FAILED_PRECONDITION`, `COMPLEXITY_LIMIT_EXCEEDED`, `RATE_LIMITED`*, `TIMEOUT`*, `UPSTREAM_UNAVAILABLE`*, `INTERNAL_ERROR`* (`*` = retryable). An unrecognised code: treat as non-retryable, preserve it, surface it. + +## Step 1 — Self-issue a service token + +```graphql +query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } +} +``` + +Sign `message` **verbatim** with the wallet as a plain personal message (EIP-191 `personal_sign`, **not** typed data). Then: + +```graphql +mutation GenerateServiceToken($serviceName: String!, $walletAddress: String!, $messageSignature: String!, $expiresIn: String) { + generateServiceToken(serviceName: $serviceName, walletAddress: $walletAddress, messageSignature: $messageSignature, expiresIn: $expiresIn) { + token tokenId expiresAt + error { code message requestId retryable details } + } +} +``` + +`token` → `X-Service-Token` on everything after this. `expiresIn` defaults to `180d`; format `` with unit one of `s m h d w M y`; bounds 1 hour to 2 years. The token is **wallet-bound, not lab-bound**: authorisation is resolved per request from that wallet's onchain role on the lab you name. + +## Step 2 — Mint the LabNFT (onchain) + +``` +OnChainLabFactory.mintAndCreateAccount(address to) payable returns (address account, uint256 tokenId) +LabNFT.mintFeeWei() view returns (uint256) // send as `value`; reads 0 on both chains today +LabNFT event OclIdentityCreated(address indexed account, bytes32 indexed oclId, uint256 indexed tokenId, bytes32 salt, uint256 canonicalChainId) +``` + +Read `oclId` off `OclIdentityCreated`. The event fires on **LabNFT**, not the factory — filter receipt logs to the LabNFT address before decoding. + +## Step 3 — Register the lab + +```graphql +mutation CreateLab($oclId: String!) { + createLab(input: { oclId: $oclId }) { + message + lab { oclId shortname labAccountAddress labNftTokenId } + error { code message requestId retryable details } + } +} +``` + +`CONFLICT` / `reason: PROJECT_CONFLICT` means the lab is already registered — treat as success and continue. + +## Step 4 — Upload a public file (3 calls) + +```graphql +mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { + initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { + uploadToken uploadUrl method headers { key value } uploadUrlExpiry + error { code message requestId retryable details } + } +} +``` + +`PUT` the raw bytes to `uploadUrl` with **exactly** the returned `headers` (key/value pairs) and the returned `method`. Presigned URLs expire in ~15 minutes. + +```graphql +mutation Finish($oclId: String!, $uploadToken: String!, $path: String!, $accessLevel: String!, $changeBy: String!, + $description: String, $tags: [String!], $categories: [String!], $contentText: String) { + finishCreateOrUpdateFile(oclId: $oclId, uploadToken: $uploadToken, path: $path, accessLevel: $accessLevel, + changeBy: $changeBy, description: $description, tags: $tags, + categories: $categories, contentText: $contentText) { + datasetId contentHash version + error { code message requestId retryable details } + } +} +``` + +* `accessLevel`: `"PUBLIC"` | `"HOLDERS"` | `"ADMIN"`. +* `changeBy`: the caller's wallet address. +* `path` for a **new** file (no underscores), `ref` for a **new version** of an existing one. Use one or the other, never both. +* Valid `tags` / `categories` come from the public `fileCategoriesAndTags` query. + +## Step 5 — Verify + +```graphql +query Verify($oclId: String!) { + labWithDataRoomAndFiles(oclId: $oclId) { + oclId shortname name + dataRoom { id files { path contentType accessLevel version createdBy } } + } +} +``` + +Public query, `Authorization` only. Your `path` is in `dataRoom.files`. A `null` result means the lab is not registered — this query is nullable and does not throw for a missing lab. + +## Optional — announce it + +```graphql +mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!, $attachments: [String!]) { + createAnnouncement(oclId: $oclId, headline: $headline, body: $body, attachments: $attachments) { + message + error { code message requestId retryable details } + } +} +``` + +`attachments` takes the `datasetId` values returned by `finishCreateOrUpdateFile`. + +## Encrypted files, in four lines + +1. `generateDataEncryptionKey` → `{ plaintextDEK, encryptedDek, encryptionSystem }`. +2. AES-256-GCM the bytes locally with `plaintextDEK` and a fresh 12-byte IV; discard the plaintext key. +3. `finishCreateOrUpdateFile` with `accessLevel: "HOLDERS"` (or `"ADMIN"`) and `encryptionMetadata: { encryptionSystem, encryptedDek, iv, contentHash, accessControlConditions, encryptedBy, encryptedAt }` — echo `encryptionSystem` verbatim, never hardcode it. +4. To read it back: `decryptDataKey(oclId:, filePath:)` → `{ plaintextDEK, iv }` after the backend re-evaluates the file's onchain access conditions against your wallet. + +Full recipe including `accessControlConditions`: [Tutorial 2](../labs-api/example-workflow.md#tutorial-2-upload-an-encrypted-file). + +## Rules that break runs when ignored + +1. No `Bearer` in front of a `mol_` credential. +2. Sign the sign-in message **verbatim**; any reformatting fails verification. +3. Success on a mutation is `error == null` — never a truthy payload field, and never a `message` string. +4. In-band `error.details` is a JSON string; thrown `errorInfo.details` is an object. They differ. +5. Filter mint receipt logs to the **LabNFT** address before decoding `OclIdentityCreated`. +6. Send the presigned `PUT` with the returned headers unchanged, and the raw bytes as the body. +7. Writing into a lab you do not own needs a **Contributor** role on it — see [Tutorial 3](../labs-api/example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app). After a role grant, an indexer lag of a few seconds can still return `UNAUTHORIZED`; retry with backoff. +8. Production has introspection off and a depth limit of 10. Generate types against staging. + +## Related + +* [Getting Started](README.md) — prerequisites, costs, the lane you should be in +* [Tutorials](../labs-api/example-workflow.md) — the same flow with responses and failure handling +* [Labs API](../labs-api/README.md) — full operation reference +* [Molecule Skill](../../ai-tooling/molecule-skill.md) — the same workflow as MCP tool calls +* [x402 Gateway](../x402-gateway.md) — pay per call, no service token diff --git a/api-reference/labs-api/README.md b/api-reference/labs-api/README.md index 16e2fad..179eb51 100644 --- a/api-reference/labs-api/README.md +++ b/api-reference/labs-api/README.md @@ -12,15 +12,26 @@ The Labs API allows developers to interact with Molecule Labs datarooms without - **Batch Operations**: Upload multiple files programmatically - **Monitoring & Alerting**: Automated upload of logs and metrics -> **Ready for Production**: This API is production-ready and actively used by projects for automated data management. To request API access, please join our [Discord community](https://t.co/L0VEiy4Bjk) and reach out to our team. +> **Ready for Production**: This API is production-ready and actively used by projects for automated data management. To get started, see [🚀 Getting Started](../getting-started/README.md) — it covers the one credential you need to request and gets you to a lab with a file in it in about ten minutes. + +--- + +## Where to start + +| | | +| --- | --- | +| **First time here** | [🚀 Getting Started](../getting-started/README.md) — prerequisites, costs, ten-minute quickstart | +| **You want runnable code** | [Tutorials](example-workflow.md) — public upload, encrypted upload, agent access, announce | +| **You're an AI agent** | [Agent one-pager](../getting-started/for-agents.md), or drive this API through the [Molecule Skill](../../ai-tooling/molecule-skill.md) plugin | +| **You want to pay per call** | [x402 Gateway](../x402-gateway.md) | --- ## Authentication -The Labs API uses consumer-credential authentication for reads and an additional Service Token for writes. Full details — public queries vs. protected mutations, obtaining and using credentials — are on the [Authentication](../authentication.md) page. +The Labs API uses consumer-credential authentication for reads and an additional Service Token for writes — which callers **issue for themselves** by signing a message with their wallet. Full details — public queries vs. protected mutations, obtaining and using credentials — are on the [Authentication](../authentication.md) page. -See also the functional sections: [Lab Management](lab-management.md), [Files](files.md), [Browse & Search](browse-and-search.md), [Legal Agreements](legal-agreements.md), and [Service Tokens](service-tokens.md). For a full end-to-end walkthrough — mint a LabNFT, register its dataroom, sign the assignment agreement, then encrypt and upload a file — see [Example Workflow](example-workflow.md). +See also the functional sections: [Tutorials](example-workflow.md), [Lab Management](lab-management.md), [Files](files.md), [Browse & Search](browse-and-search.md), and [Service Tokens](service-tokens.md). --- @@ -130,13 +141,15 @@ When `retryable` is `true`, retry with exponential backoff; when `false`, the re - Ensure the `X-Service-Token` header is included in mutation requests - Verify the token is not empty or malformed -- If the token has expired, request a new token from the Molecule team, or use the `extendServiceToken` mutation to extend expiration +- If the token has expired, issue a new one yourself — the two-call [sign-in flow](service-tokens.md#obtaining-a-token) needs no human — or extend the existing one with `extendServiceToken` A missing or malformed consumer credential is rejected before the GraphQL layer runs (an HTTP `401` from the API, not one of the error codes below) — check the `Authorization` header first, see [Authentication](../authentication.md). **`UNAUTHORIZED`** — the wallet behind the service token lacks the required role on the lab: -- Verify your wallet address (linked to the service token) has admin access to the lab/dataroom (or the role the operation requires) +- Check the wallet's role with the public `listLabMembers(oclId)` query. Content writes (uploads, metadata, announcements, moves, deletes) need **Contributor**; `createLab` and the LabNFT-metadata mutations need **Owner** +- Not the right role? The lab owner grants one onchain — see [Tutorial 3](example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app) +- **Just granted the role?** Role state reaches the API through an event indexer, so a write can still return `UNAUTHORIZED` for a few seconds after the grant confirms onchain. Retry with backoff; re-issuing the token does not help **Upload to presigned URL fails:** @@ -202,12 +215,11 @@ The legacy `*V2` operations and the pre-OCL naming have been **removed**. The cu ## Getting Support -If you encounter any issues or have questions about the Programmatic File Upload API: +If you encounter any issues or have questions about the Labs API: -1. Check this documentation and [troubleshooting section](#troubleshooting) -2. Review the [complete example](files.md#complete-example) for implementation guidance -3. Join our [Discord community](https://t.co/L0VEiy4Bjk) for support -4. Contact the Molecule Labs development team directly +1. Check this documentation and the [troubleshooting section](#troubleshooting) +2. Run the [Tutorials](example-workflow.md) against staging — each step lists its expected response and failure modes +3. Join our [Discord community](https://t.co/L0VEiy4Bjk) for support, quoting the `requestId` from the failing response --- diff --git a/api-reference/labs-api/browse-and-search.md b/api-reference/labs-api/browse-and-search.md index e2d96fc..4e30dc1 100644 --- a/api-reference/labs-api/browse-and-search.md +++ b/api-reference/labs-api/browse-and-search.md @@ -1,6 +1,6 @@ # Browse & Search -Cross-cutting read operations that aren't scoped to a single lab you administer: browsing and reading labs and their files, full-text search, and activity feeds. Reads tied to a specific resource live with that resource — e.g. members and DID-link status in [Lab Management](lab-management.md), and legal-agreement status in [Legal Agreements](legal-agreements.md). +Cross-cutting read operations that aren't scoped to a single lab you administer: browsing and reading labs and their files, full-text search, and activity feeds. Reads tied to a specific resource live with that resource — e.g. members and DID-link status in [Lab Management](lab-management.md). ## Listing Labs & Activity @@ -394,7 +394,6 @@ query SearchLabs( curl -X POST https://production.graphql.api.molecule.xyz/graphql \ -H 'Content-Type: application/json' \ -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \ - -H 'X-Service-Token: YOUR_SERVICE_TOKEN' \ -d '{ "query": "query SearchLabs($prompt: String!, $page: Int, $perPage: Int) { searchLabs(prompt: $prompt, page: $page, perPage: $perPage) { nodes { __typename ... on SearchLabsFileHit { entry { lab { oclId shortname } path file { contentType description tags } } } ... on SearchLabsAnnouncementHit { announcement { headline body } lab { shortname } } } totalCount pageInfo { hasNextPage currentPage totalPages } } }", "variables": { @@ -411,7 +410,6 @@ curl -X POST https://production.graphql.api.molecule.xyz/graphql \ curl -X POST https://production.graphql.api.molecule.xyz/graphql \ -H 'Content-Type: application/json' \ -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \ - -H 'X-Service-Token: YOUR_SERVICE_TOKEN' \ -d '{ "query": "query SearchLabs($prompt: String!, $filters: SearchLabsFilters) { searchLabs(prompt: $prompt, filters: $filters) { nodes { __typename ... on SearchLabsFileHit { entry { path file { tags accessLevel } } } } totalCount } }", "variables": { @@ -443,7 +441,6 @@ const searchResults = await fetch(apiUrl, { headers: { "Content-Type": "application/json", "Authorization": process.env.CONSUMER_CREDENTIAL, - "X-Service-Token": process.env.SERVICE_TOKEN, }, body: JSON.stringify({ query: `query SearchLabs($prompt: String!) { @@ -497,7 +494,9 @@ nodes.forEach((node) => { ### Onchain Activity Feed -Return the onchain event feed for an OCL or a wallet. Exactly one of `oclId` / `wallet` must be supplied. Paginate with a cursor of the form `":"` — pass the last row's `id` to fetch the next page. +Return the onchain event feed for an OCL or a wallet. At least one of `oclId` / `wallet` must be supplied (they are AND-ed when both are). Paginate with a cursor of the form `":"` — pass the last entry's `id` to fetch the next page. + +`onChainActivity` returns **one entry per transaction**: the decoded events of a transaction are classified into a single timeline entry, so an OCL creation renders as one "New Onchain Lab created" row rather than a burst of raw events. The constituent events stay available under `events`. For the flat, one-row-per-event stream, use `rawOnChainActivity` (same filters and cursor semantics). ```graphql query OnChainActivity( @@ -514,14 +513,20 @@ query OnChainActivity( ) { id chainId - contractAddress - contractName - eventName + txHash blockNumber blockTimestamp - txHash - logIndex + type + title args + events { + id + contractAddress + contractName + eventName + logIndex + args + } } } ``` @@ -532,8 +537,19 @@ query OnChainActivity( | --------- | ------ | -------- | ------------------------------------------------------------------ | | oclId | String | No\* | Canonical 32-byte oclId of the lab | | wallet | String | No\* | Wallet address to filter events by | -| limit | Int | No | Max rows to return (default: 50) | -| cursor | String | No | Pagination cursor `":"` (last row's `id`) | +| limit | Int | No | Max transaction groups to return (default: 50, max 200) | +| cursor | String | No | Pagination cursor `":"` (last entry's `id`) | + +\* Provide at least one of `oclId` or `wallet`. + +**Entry fields:** + +| Field | Description | +| ----- | ----------- | +| `type` | Machine-readable classification: `OCL_CREATED`, `OCL_TOKENIZED`, `OCL_TRANSFERRED`, `OCL_DID_LINKED`, `ROLE_GRANTED`, `ROLE_REVOKED`, `ROLE_CHANGED`, `IPT_TOKENIZED`, `IPNFT_MINTED`, `IPNFT_TRANSFERRED`, `IPNFT_METADATA_UPDATED`, `OTHER` | +| `title` | Human-readable summary, e.g. `"Contributor role granted to 0x1234…cdef"` | +| `args` | Structured facts of the classified action (JSON; addresses lowercased) | +| `events` | The transaction's raw events in ascending log order — including events that did not match the filter, for full transaction context | -\* Provide exactly one of `oclId` or `wallet`. `contractName` is one of `accessresolver`, `ocl`, `ipnft` or `ipt`. `args` is a JSON object of the decoded event arguments (BigInts as decimal strings, addresses lowercased). +On a raw event, `contractName` is one of `accessresolver`, `ocl`, `ipnft`, `ipt` or `bio-agent`, and `args` is a JSON object of the decoded event arguments (BigInts as decimal strings, addresses lowercased). diff --git a/api-reference/labs-api/example-workflow.md b/api-reference/labs-api/example-workflow.md index 05fd468..e0f142f 100644 --- a/api-reference/labs-api/example-workflow.md +++ b/api-reference/labs-api/example-workflow.md @@ -1,18 +1,29 @@ -# Example Workflow: Authenticate, Mint, Create, Sign, Upload +# Tutorials -A complete, runnable walkthrough that takes a wallet with **no prior credentials and no onchain lab** all the way to a file living in its dataroom: prove control of the wallet to mint a service token, mint the LabNFT, register the dataroom, sign the assignment agreement, then upload a file. Each step below links back to its full reference; the [Complete Script](#complete-script) at the end wires all five together. +Four step-by-step tutorials, each ending in something you can verify. They share one config block and two helper functions ([Shared setup](#shared-setup)) and are written against **staging** (Base Sepolia, testnet funds) end to end — see [Running in Production](#running-in-production) for the values to swap. -> **Encryption is optional.** Step 5 below shows the encrypted path since it's the more involved one to get right, but most files don't need it — a plain `PUBLIC` upload skips the DEK request and `encryptionMetadata` entirely and is just the three-call `initiateCreateOrUpdateFile` → PUT → `finishCreateOrUpdateFile` flow from [Files](files.md). Reach for encryption when the file is confidential and access should be gated by onchain role or ownership — see [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md). +| Tutorial | What you end up with | Start from | +| -------- | -------------------- | ---------- | +| [**1 — Create a lab and upload a public file**](#tutorial-1-create-a-lab-and-upload-a-public-file) | A lab you own, with a readable file in its data room | Nothing but a credential and a funded wallet | +| [**2 — Upload an encrypted file**](#tutorial-2-upload-an-encrypted-file) | A confidential file only authorised wallets can decrypt | Tutorial 1, or any lab you have a role on | +| [**3 — Give your agent access to a lab you created in the app**](#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app) | An agent wallet writing into a lab a human owns | A lab created in the Labs app | +| [**4 — Announce the dataset**](#tutorial-4-announce-the-dataset) | A public update on the lab's activity feed, attaching your file | Tutorial 1 or 2 | -This is the workflow an autonomous agent needs to run end-to-end without any browser-based user interaction or manually provisioned Service Token — the only thing it needs ahead of time is a consumer credential and a funded wallet. It's written against **staging** (Base Sepolia, testnet ETH) end to end; see [Running in Production](#running-in-production) at the bottom for the values to swap. +New here? Read [Getting Started](../getting-started/README.md) first — it covers the two prerequisites, what things cost, and which of the four lanes you should be in. If you are an agent, the [one-pager](../getting-started/for-agents.md) is the same flow with no prose. ## Prerequisites -* A funded EOA on **Base Sepolia** — get testnet ETH from a [Base Sepolia faucet](https://docs.base.org/base-chain/tools/network-faucets) -* A **consumer credential** — see [Authentication](../authentication.md). No pre-issued Service Token needed; the workflow mints its own in Step 1. -* `viem` and `node-fetch` (`npm install viem node-fetch`) +* A **consumer credential** — `mol__`. See [Getting Started](../getting-started/README.md#1-a-mol-consumer-credential-the-one-manual-step) for the request template. **No pre-issued Service Token needed** — every tutorial mints its own from a wallet signature. +* A funded EOA on **Base Sepolia** — testnet ETH from a [Base Sepolia faucet](https://docs.base.org/base-chain/tools/network-faucets). `mintFeeWei()` reads **0** on both Base Sepolia and Base mainnet (verified 2026-08-27), so minting costs gas only — but the tutorials read the live value and forward it, so a future fee needs no code change. +* Node 18+ and `viem` (`npm install viem`). `fetch` and `node:crypto` are built in. -Every environment-specific value used below — the GraphQL endpoint, contract addresses, and the viem chain — lives in this one block. Swapping to production later is a matter of replacing this block with the table in [Running in Production](#running-in-production). +Tutorial 3 additionally needs a lab created in the Labs app by a human, and Tutorial 2's verification step needs the wallet to satisfy the file's own access conditions. + +*** + +## Shared setup + +Every environment-specific value lives in this one block; swapping to production is a matter of replacing it with the table in [Running in Production](#running-in-production). ```javascript import { baseSepolia } from "viem/chains"; // production: `base` @@ -24,6 +35,7 @@ const FACTORY_ADDRESS = "0xd629FE2310b4309a212495F10A47f8436dcEfD90"; // OnChain const LABNFT_ADDRESS = "0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28"; // LabNFT (proxy) const ACCESS_RESOLVER_ADDRESS = "0x5493F472602C87318EA5Eff753cDD593bf9bF559"; // AccessResolver const ACCESS_CONDITION_CHAIN = "baseSepolia"; // the `chain` string inside accessControlConditions +const LAB_APP_URL = "https://testnet.labs.molecule.xyz"; // production: https://labs.molecule.xyz const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; // mol__ — no "Bearer" prefix const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY; @@ -67,15 +79,23 @@ function assertOk(result, op) { } ``` -## Step 1: Get a Service Token +*** + +## Tutorial 1: Create a lab and upload a public file + +The default path, and the one to run first. Five steps: get a token, mint the LabNFT, register the lab, upload the file, verify. A public file is stored as-is — no key management, no access conditions. + +> **Want the file to be confidential instead?** Steps 1–3 are identical; branch at Step 4 into [Tutorial 2](#tutorial-2-upload-an-encrypted-file). -Prove control of the wallet instead of waiting on a manually issued token — the self-service path for agents, bots, and CI/CD. Fetch the deterministic sign-in message, sign it as a plain wallet message (EIP-191 `personal_sign` — not typed data), then exchange the signature for a token. Full reference: [Service Tokens — Obtaining Tokens](service-tokens.md#obtaining-tokens). +### Step 1: Get a service token + +Prove control of the wallet instead of waiting on a manually issued token — the self-service path for agents, bots and CI/CD. Fetch the deterministic sign-in message, sign it as a plain wallet message (EIP-191 `personal_sign` — **not** typed data), then exchange the signature for a token. Full reference: [Service Tokens](service-tokens.md#obtaining-a-token). ```javascript import { createPublicClient, createWalletClient, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -const SERVICE_NAME = "example-workflow-agent"; +const SERVICE_NAME = "tutorial-agent"; const account = privateKeyToAccount(WALLET_PRIVATE_KEY); // publicClient: read-only RPC calls (readContract, waitForTransactionReceipt). @@ -122,9 +142,34 @@ assertOk(tokenResult.generateServiceToken, "generateServiceToken"); serviceToken = tokenResult.generateServiceToken.token; ``` -`generateServiceToken` reports failure the same way as every other mutation: `error` is `null` on success, and on failure it carries the catalogue `code` while `token`, `tokenId` and `expiresAt` are `null` (`message` mirrors `error.message`). The returned `token` authorizes this wallet's onchain-resolved role for whatever lab it acts on; it isn't scoped to a single `oclId` up front. +**Expected response:** + +```json +{ + "data": { + "generateServiceToken": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…", + "tokenId": "b3f1c0de-…", + "expiresAt": "2027-02-23T10:31:07.000Z", + "message": "Service token generated successfully for tutorial-agent", + "error": null + } + } +} +``` + +**If it fails:** + +| `error.code` | What happened | Fix | +| ------------ | ------------- | --- | +| `UNAUTHENTICATED`, `reason: INVALID_SIGNATURE` | The signed bytes are not the message the backend recomposes | Sign the returned `message` string byte-for-byte. Use `personal_sign` / viem's `signMessage`, not `signTypedData` | +| `UNAUTHENTICATED`, `reason: WALLET_MISMATCH` | `walletAddress` isn't the signer | Pass the same address that signed | +| `VALIDATION_FAILED` | Bad `expiresIn` | Format is ``, unit one of `s m h d w M y`; between 1 hour and 2 years | +| HTTP `401` before GraphQL runs | Consumer credential missing or malformed | Check `Authorization` — no `Bearer` prefix. See [Authentication](../authentication.md) | + +`expiresIn` defaults to **`180d`** when omitted. The returned token is **wallet-bound, not lab-bound**: it carries this wallet's identity, and authorisation is resolved per request from that wallet's onchain role on whichever lab you name. -## Step 2: Mint the LabNFT +### Step 2: Mint the LabNFT Mint onchain via `OnChainLabFactory.mintAndCreateAccount` and read `oclId` off the `OclIdentityCreated` event. Reuses `account` / `publicClient` / `walletClient` from Step 1 and `FACTORY_ADDRESS` / `LABNFT_ADDRESS` from the config block. Full detail — the fee call and how `oclId` is derived — is on [Lab Management](lab-management.md#mint-the-labnft). @@ -139,6 +184,7 @@ const labNftAbi = parseAbi([ "event OclIdentityCreated(address indexed account, bytes32 indexed oclId, uint256 indexed tokenId, bytes32 salt, uint256 canonicalChainId)", ]); +// Read the fee live — it is 0 on both chains today, but never hardcode it. const mintFeeWei = await publicClient.readContract({ address: LABNFT_ADDRESS, abi: labNftAbi, @@ -160,11 +206,25 @@ const [identity] = parseEventLogs({ logs: mintReceipt.logs.filter((l) => l.address.toLowerCase() === LABNFT_ADDRESS.toLowerCase()), }); const oclId = identity.args.oclId; +const labAccountAddress = identity.args.account; ``` -## Step 3: Create the Lab +**Expected result:** `mintReceipt.status === "success"`, and -Register the Kamu-backed dataroom for the freshly-minted `oclId`. Full reference: [Create Lab](lab-management.md#create-lab). +``` +oclId: 0x0101000000000000000000000000abc… (32-byte hex) +tokenId: 1274 +labAccountAddress: 0x… (the ERC-6551 Token Bound Account) +``` + +**If it fails:** + +* **Transaction reverts** — the wallet is unfunded, or `value` didn't match `mintFeeWei()`. Read the fee live and forward it; don't hardcode `0`. +* **`parseEventLogs` returns `[]`** — the logs weren't filtered to the LabNFT. `OclIdentityCreated` fires on the **LabNFT contract**, not the factory; the factory's own `AccountProvisioned` event does not carry `oclId` as a topic. + +### Step 3: Register the lab + +Register the Kamu-backed data room for the freshly-minted `oclId`. Full reference: [Create Lab](lab-management.md#create-lab). ```javascript const createLabResult = await graphql( @@ -178,166 +238,79 @@ const createLabResult = await graphql( { oclId }, ); assertOk(createLabResult.createLab, "createLab"); -const { labAccountAddress } = createLabResult.createLab.lab; ``` -## Step 4: Sign the Assignment Agreement +**Expected response:** + +```json +{ + "data": { + "createLab": { + "message": "Lab created successfully", + "error": null, + "lab": { + "oclId": "0x0101000000000000000000000000abc…", + "shortname": null, + "labAccountAddress": "0x…", + "labNftTokenId": "1274" + } + } + } +} +``` -Fetch the populated agreement, sign the `LegalAgreementAcceptance` EIP-712 payload, then submit it. Full schema and a self-test vector: [Legal Agreements — EIP-712 Envelope](legal-agreements.md#eip-712-envelope). +`shortname` is derived server-side from the lab's name and is `null` until it has been derived — that is expected on a lab that has just been minted and not yet named. -```javascript -const template = await graphql( - `query Template($oclId: String!, $walletAddress: String!) { - legalAgreementTemplate( - oclId: $oclId - type: ASSIGNMENT_AGREEMENT - walletAddress: $walletAddress - ) { - contentHash - templateVersion - issuedAt - } - }`, - { oclId, walletAddress: account.address }, -); -const { contentHash, templateVersion, issuedAt } = template.legalAgreementTemplate; - -const signature = await walletClient.signTypedData({ - domain: { - name: "MoleculeOcl", - version: "1", - chainId: CHAIN.id, - verifyingContract: LABNFT_ADDRESS.toLowerCase(), - }, - types: { - LegalAgreementAcceptance: [ - { name: "oclId", type: "bytes32" }, - { name: "agreementType", type: "string" }, - { name: "contentHash", type: "bytes32" }, - { name: "templateVersion", type: "string" }, - { name: "signer", type: "address" }, - { name: "issuedAt", type: "uint64" }, - ], - }, - primaryType: "LegalAgreementAcceptance", - message: { - oclId, - agreementType: "assignment-agreement", // registry slug, NOT the "ASSIGNMENT_AGREEMENT" enum value - contentHash, - templateVersion, - signer: account.address.toLowerCase(), - issuedAt: BigInt(issuedAt), - }, -}); +**If it fails:** -const signResult = await graphql( - `mutation Sign($input: SignLegalAgreementInput!) { - signLegalAgreement(input: $input) { - path - message - error { code message requestId retryable details } - } - }`, - { - input: { - oclId, - type: "ASSIGNMENT_AGREEMENT", - walletAddress: account.address, - signature, - issuedAt, - }, - }, -); -assertOk(signResult.signLegalAgreement, "signLegalAgreement"); -``` +| `error.code` | What happened | Fix | +| ------------ | ------------- | --- | +| `CONFLICT`, `reason: PROJECT_CONFLICT` | The lab is already registered | Treat as success and continue — this is what a re-run looks like | +| `NOT_FOUND` | The `oclId` isn't indexed yet | The indexer trails the mint by a few seconds. Retry with backoff | +| `UNAUTHENTICATED`, `reason: NO_AUTH` | No `X-Service-Token` and no Privy session | Step 1 didn't set `serviceToken` | +| `UPSTREAM_UNAVAILABLE` | A dependency is down (`reason: KAMU`) | `retryable: true` — retry with backoff | -## Step 5: Upload a File (Encrypted) +DID-linking for the new lab starts automatically in the background; [`getDidLinkStatus`](lab-management.md#get-did-link-status) reports its progress. You do not need to wait for it. -> **Skip 5a–5c if you don't need encryption.** For a `PUBLIC` file, go straight to 5d with `accessLevel: "PUBLIC"` and omit `encryptionMetadata` — that's the whole upload. The DEK request, local AES-256-GCM encryption, and `accessControlConditions` below are only for files that must be access-gated. +### Step 4: Upload the file -Request a data encryption key, AES-256-GCM encrypt the file locally via Web Crypto, then run the standard three-step upload with `encryptionMetadata` attached. Uses `ACCESS_RESOLVER_ADDRESS` / `ACCESS_CONDITION_CHAIN` from the config block. Full reference: [Files — Advanced: Encrypted File Upload](files.md#advanced-encrypted-file-upload) and [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md). +Three calls: get a presigned URL, `PUT` the bytes, finalise with metadata. Full reference: [Files](files.md). ```javascript -import { webcrypto, randomBytes, createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { basename } from "node:path"; const filePath = "./research-data.csv"; -const plaintext = readFileSync(filePath); - -// 5a. Get a DEK -const dekResult = await graphql(` - mutation { - generateDataEncryptionKey { - plaintextDEK - encryptedDek - encryptionSystem - error { code message requestId retryable details } - } - } -`); -assertOk(dekResult.generateDataEncryptionKey, "generateDataEncryptionKey"); -const { plaintextDEK, encryptedDek, encryptionSystem } = dekResult.generateDataEncryptionKey; - -// 5b. Encrypt locally (Web Crypto SubtleCrypto), then wipe the plaintext key -const cryptoKey = await webcrypto.subtle.importKey( - "raw", - Buffer.from(plaintextDEK, "base64"), - "AES-GCM", - false, - ["encrypt"], -); -const iv = randomBytes(12); -const ciphertext = Buffer.from( - await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, cryptoKey, plaintext), -); -const contentHashHex = "sha256-" + createHash("sha256").update(plaintext).digest("hex"); - -// 5c. Gate decryption to the lab owner (LabNFT owner / authorized TBA signer). -// See the Data Privacy & Access worked example for OR-composing in -// Contributor/Viewer roles too. -const accessControlConditions = JSON.stringify([ - { - conditionType: "evmContract", - contractAddress: ACCESS_RESOLVER_ADDRESS, - chain: ACCESS_CONDITION_CHAIN, - functionName: "isAuthorizedSignerForTba", - functionParams: [":userAddress", labAccountAddress], - functionAbi: { - name: "isAuthorizedSignerForTba", - inputs: [ - { name: "signer", type: "address" }, - { name: "account", type: "address" }, - ], - outputs: [{ name: "", type: "bool" }], - stateMutability: "view", - type: "function", - }, - returnValueTest: { key: "", comparator: "=", value: "true" }, - }, -]); +const bytes = readFileSync(filePath); -// 5d. Standard three-step upload, ciphertext in place of the raw file +// 4a. Get a presigned URL const initiateResult = await graphql( `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { uploadToken uploadUrl + uploadUrlExpiry method headers { key value } error { code message requestId retryable details } } }`, - { oclId, contentType: "application/octet-stream", contentLength: ciphertext.length }, + { oclId, contentType: "text/csv", contentLength: bytes.length }, ); assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); -const { uploadToken, uploadUrl, headers } = initiateResult.initiateCreateOrUpdateFile; +const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile; +// 4b. PUT the bytes with EXACTLY the returned headers const uploadHeaders = {}; headers.forEach((h) => (uploadHeaders[h.key] = h.value)); -const putResponse = await fetch(uploadUrl, { method: "PUT", headers: uploadHeaders, body: ciphertext }); -if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.statusText}`); +const putResponse = await fetch(uploadUrl, { + method: method || "PUT", + headers: uploadHeaders, + body: bytes, +}); +if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status} ${putResponse.statusText}`); +// 4c. Finalise const finishResult = await graphql( `mutation Finish( $oclId: String! @@ -345,7 +318,10 @@ const finishResult = await graphql( $path: String! $accessLevel: String! $changeBy: String! - $encryptionMetadata: EncryptionMetadataInput + $description: String + $tags: [String!] + $categories: [String!] + $contentText: String ) { finishCreateOrUpdateFile( oclId: $oclId @@ -353,9 +329,14 @@ const finishResult = await graphql( path: $path accessLevel: $accessLevel changeBy: $changeBy - encryptionMetadata: $encryptionMetadata + description: $description + tags: $tags + categories: $categories + contentText: $contentText ) { datasetId + contentHash + version message error { code message requestId retryable details } } @@ -364,32 +345,105 @@ const finishResult = await graphql( oclId, uploadToken, path: basename(filePath), - accessLevel: "HOLDERS", // DataRoomAccessLevel: PUBLIC | HOLDERS | ADMIN — encrypted files use HOLDERS or ADMIN + accessLevel: "PUBLIC", // DataRoomAccessLevel: PUBLIC | HOLDERS | ADMIN changeBy: account.address, - encryptionMetadata: { - encryptionSystem, // echo verbatim — never hardcode - encryptedDek, - iv: iv.toString("base64"), - contentHash: contentHashHex, - accessControlConditions, - encryptedBy: account.address.toLowerCase(), - encryptedAt: new Date().toISOString(), - }, + description: "Baseline assay results", + tags: ["preliminary"], + categories: ["raw-data"], + contentText: "assay,replicate,value", }, ); assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); -console.log("Uploaded. datasetId:", finishResult.finishCreateOrUpdateFile.datasetId); +const { datasetId } = finishResult.finishCreateOrUpdateFile; ``` -*** +**Expected responses:** + +```json +{ + "data": { + "initiateCreateOrUpdateFile": { + "uploadToken": "eyJ…", + "uploadUrl": "https://…s3….amazonaws.com/…?X-Amz-Signature=…", + "uploadUrlExpiry": "2026-08-27T14:05:00.000Z", + "method": "PUT", + "headers": [{ "key": "Content-Type", "value": "text/csv" }], + "error": null + } + } +} +``` + +The `PUT` returns HTTP `200` with an empty body. Then: + +```json +{ + "data": { + "finishCreateOrUpdateFile": { + "datasetId": "did:odf:fed01…", + "contentHash": "f162…", + "version": 1, + "message": "…", + "error": null + } + } +} +``` + +`message` on this result is passed through from the storage layer, so its exact wording varies and is deliberately not shown here — it is **not part of the contract**. Assert on `error == null`, never on `message`. + +Keep `datasetId` — Tutorial 4 attaches it to an announcement. + +**If it fails:** + +| Symptom | Cause | Fix | +| ------- | ----- | --- | +| `initiate` → `UNAUTHORIZED` | The wallet behind the token has no write role on this lab | You must be Owner or Contributor. See [Tutorial 3](#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app) | +| `PUT` → `403` | URL expired (~15 min), or headers altered | Re-run `initiate`; send the returned `headers` verbatim | +| `PUT` → `400`/`411` | Body wasn't sent as raw bytes | Send the buffer, not a JSON wrapper. In curl: `--data-binary` | +| `finish` → `VALIDATION_FAILED`, `details.field: "path"` | `path` contains an underscore, or both `path` and `ref` were sent | Underscores are not allowed in `path`; use `path` for a new file **or** `ref` for a new version, never both | +| `finish` → `VALIDATION_FAILED`, `reason: INVALID_TAGS_OR_CATEGORIES` | Unknown tag or category | Valid values come from the public `fileCategoriesAndTags` query | +| `finish` → `VALIDATION_FAILED`, `reason: INVALID_ACCESS_LEVEL` | Bad `accessLevel` | One of `PUBLIC`, `HOLDERS`, `ADMIN` | -## Complete Script +### Step 5: Verify it worked -All five steps combined into one file, against **staging**. Run with `WALLET_PRIVATE_KEY` and `CONSUMER_CREDENTIAL` set, and a file at the path passed on the command line — no pre-issued Service Token needed. See [Running in Production](#running-in-production) below to point this at mainnet instead. +Two checks. The first needs nothing but your consumer credential: + +```javascript +const verify = await graphql( + `query Verify($oclId: String!) { + labWithDataRoomAndFiles(oclId: $oclId) { + oclId + shortname + name + dataRoom { + id + files { path contentType accessLevel version createdBy downloadUrl } + } + } + }`, + { oclId }, +); + +const file = verify.labWithDataRoomAndFiles.dataRoom.files.find( + (f) => f.path.endsWith(basename(filePath)), +); +if (!file) throw new Error("File not found in the data room"); +console.log("Verified:", file.path, file.accessLevel, "v" + file.version); +``` + +Your file is in `dataRoom.files` with `accessLevel: "PUBLIC"` and `version: 1`. Because the file is public, `downloadUrl` is a fetchable presigned URL — `fetch` it and compare the bytes to what you uploaded for an end-to-end check. + +If `labWithDataRoomAndFiles` comes back `null`, the lab is not registered: Step 3 didn't complete. This is one of only two nullable queries on the Labs API, so a missing lab nulls the field instead of throwing. + +The second check is visual. Once `shortname` is populated, the lab has a page at `${LAB_APP_URL}/projects/` — `https://testnet.labs.molecule.xyz/projects/` on staging. + +### Tutorial 1: complete script + +All five steps in one file, against staging. No pre-issued service token needed. ```javascript #!/usr/bin/env node -import { webcrypto, randomBytes, createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { basename } from "node:path"; import { @@ -407,43 +461,29 @@ const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql"; const CHAIN = baseSepolia; const FACTORY_ADDRESS = "0xd629FE2310b4309a212495F10A47f8436dcEfD90"; // OnChainLabFactory const LABNFT_ADDRESS = "0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28"; // LabNFT (proxy) -const ACCESS_RESOLVER_ADDRESS = "0x5493F472602C87318EA5Eff753cDD593bf9bF559"; // AccessResolver -const ACCESS_CONDITION_CHAIN = "baseSepolia"; // the `chain` string inside accessControlConditions -const SERVICE_NAME = "example-workflow-agent"; +const LAB_APP_URL = "https://testnet.labs.molecule.xyz"; +const SERVICE_NAME = "tutorial-agent"; const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; // mol__ — no "Bearer" prefix const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY; -// Set once Step 1 exchanges a wallet signature for a token. let serviceToken; async function graphql(query, variables) { - // Authorization is always required. X-Service-Token is added once we have - // one — omit it entirely rather than sending an empty header. const headers = { "Content-Type": "application/json", Authorization: CONSUMER_CREDENTIAL }; if (serviceToken) headers["X-Service-Token"] = serviceToken; - const res = await fetch(GRAPHQL_URL, { method: "POST", headers, body: JSON.stringify({ query, variables }), }); const { data, errors } = await res.json(); - // Queries report failure here: a top-level errors[] entry whose errorType is - // the catalogue code. Mutations report expected failures in-band instead (see - // assertOk); a top-level entry on a mutation means a transport/infrastructure - // failure or an invalid request document. if (errors) throw new Error(JSON.stringify(errors)); return data; } -// Mutations report failure in-band: `error` is null on success. Throw on a -// non-null `error` so a failed step stops the workflow with the catalogue -// `code` and the `requestId` to quote in a bug report. function assertOk(result, op) { if (result.error) { - // `details` is a JSON-encoded string; JSON.parse(result.error.details ?? "{}").reason - // carries the specific cause when there is one. const { code, message, requestId } = result.error; throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`); } @@ -452,19 +492,16 @@ function assertOk(result, op) { async function main() { const filePath = process.argv[2]; - if (!filePath) throw new Error("Usage: node workflow.js "); + if (!filePath) throw new Error("Usage: node tutorial-1.js "); const account = privateKeyToAccount(WALLET_PRIVATE_KEY); - // publicClient: read-only RPC calls. walletClient: signing and sending. const publicClient = createPublicClient({ chain: CHAIN, transport: http() }); const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() }); - // ---- Step 1: Get a Service Token ---- + // ---- Step 1: service token ---- const signInMessage = await graphql( `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { - getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { - message - } + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } }`, { walletAddress: account.address, serviceName: SERVICE_NAME }, ); @@ -472,18 +509,9 @@ async function main() { message: signInMessage.getServiceSignInMessage.message, }); const tokenResult = await graphql( - `mutation GenerateServiceToken( - $serviceName: String! - $walletAddress: String! - $messageSignature: String! - ) { - generateServiceToken( - serviceName: $serviceName - walletAddress: $walletAddress - messageSignature: $messageSignature - ) { + `mutation GenerateServiceToken($serviceName: String!, $walletAddress: String!, $messageSignature: String!) { + generateServiceToken(serviceName: $serviceName, walletAddress: $walletAddress, messageSignature: $messageSignature) { token - message error { code message requestId retryable details } } }`, @@ -493,7 +521,7 @@ async function main() { serviceToken = tokenResult.generateServiceToken.token; console.log("1/5 Got service token"); - // ---- Step 2: Mint the LabNFT ---- + // ---- Step 2: mint the LabNFT ---- const factoryAbi = parseAbi([ "function mintAndCreateAccount(address to) external payable returns (address account, uint256 tokenId)", ]); @@ -521,165 +549,49 @@ async function main() { logs: mintReceipt.logs.filter((l) => l.address.toLowerCase() === LABNFT_ADDRESS.toLowerCase()), }); const oclId = identity.args.oclId; - console.log("2/5 Minted LabNFT — oclId:", oclId); + console.log("2/5 Minted LabNFT — tx:", mintTxHash, "oclId:", oclId); - // ---- Step 3: Create the Lab ---- + // ---- Step 3: register the lab ---- const createLabResult = await graphql( `mutation CreateLab($oclId: String!) { createLab(input: { oclId: $oclId }) { message error { code message requestId retryable details } - lab { labAccountAddress } + lab { shortname labAccountAddress labNftTokenId } } }`, { oclId }, ); assertOk(createLabResult.createLab, "createLab"); - const { labAccountAddress } = createLabResult.createLab.lab; - console.log("3/5 Lab created — TBA:", labAccountAddress); - - // ---- Step 4: Sign the Assignment Agreement ---- - const template = await graphql( - `query Template($oclId: String!, $walletAddress: String!) { - legalAgreementTemplate(oclId: $oclId, type: ASSIGNMENT_AGREEMENT, walletAddress: $walletAddress) { - contentHash - templateVersion - issuedAt - } - }`, - { oclId, walletAddress: account.address }, - ); - const { contentHash, templateVersion, issuedAt } = template.legalAgreementTemplate; - - const signature = await walletClient.signTypedData({ - domain: { - name: "MoleculeOcl", - version: "1", - chainId: CHAIN.id, - verifyingContract: LABNFT_ADDRESS.toLowerCase(), - }, - types: { - LegalAgreementAcceptance: [ - { name: "oclId", type: "bytes32" }, - { name: "agreementType", type: "string" }, - { name: "contentHash", type: "bytes32" }, - { name: "templateVersion", type: "string" }, - { name: "signer", type: "address" }, - { name: "issuedAt", type: "uint64" }, - ], - }, - primaryType: "LegalAgreementAcceptance", - message: { - oclId, - agreementType: "assignment-agreement", - contentHash, - templateVersion, - signer: account.address.toLowerCase(), - issuedAt: BigInt(issuedAt), - }, - }); - - const signResult = await graphql( - `mutation Sign($input: SignLegalAgreementInput!) { - signLegalAgreement(input: $input) { - path - message - error { code message requestId retryable details } - } - }`, - { input: { oclId, type: "ASSIGNMENT_AGREEMENT", walletAddress: account.address, signature, issuedAt } }, - ); - assertOk(signResult.signLegalAgreement, "signLegalAgreement"); - console.log("4/5 Agreement signed —", signResult.signLegalAgreement.path); - - // ---- Step 5: Upload a File (encrypted — see the callout above Step 5) ---- - const plaintext = readFileSync(filePath); - - const dekResult = await graphql(` - mutation { - generateDataEncryptionKey { - plaintextDEK - encryptedDek - encryptionSystem - error { code message requestId retryable details } - } - } - `); - assertOk(dekResult.generateDataEncryptionKey, "generateDataEncryptionKey"); - const { plaintextDEK, encryptedDek, encryptionSystem } = dekResult.generateDataEncryptionKey; - - const cryptoKey = await webcrypto.subtle.importKey( - "raw", - Buffer.from(plaintextDEK, "base64"), - "AES-GCM", - false, - ["encrypt"], - ); - const iv = randomBytes(12); - const ciphertext = Buffer.from( - await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, cryptoKey, plaintext), - ); - const contentHashHex = "sha256-" + createHash("sha256").update(plaintext).digest("hex"); - - const accessControlConditions = JSON.stringify([ - { - conditionType: "evmContract", - contractAddress: ACCESS_RESOLVER_ADDRESS, - chain: ACCESS_CONDITION_CHAIN, - functionName: "isAuthorizedSignerForTba", - functionParams: [":userAddress", labAccountAddress], - functionAbi: { - name: "isAuthorizedSignerForTba", - inputs: [ - { name: "signer", type: "address" }, - { name: "account", type: "address" }, - ], - outputs: [{ name: "", type: "bool" }], - stateMutability: "view", - type: "function", - }, - returnValueTest: { key: "", comparator: "=", value: "true" }, - }, - ]); + console.log("3/5 Lab registered — TBA:", createLabResult.createLab.lab.labAccountAddress); + // ---- Step 4: upload the file ---- + const bytes = readFileSync(filePath); const initiateResult = await graphql( `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { - uploadToken - uploadUrl - headers { key value } + uploadToken uploadUrl method headers { key value } error { code message requestId retryable details } } }`, - { oclId, contentType: "application/octet-stream", contentLength: ciphertext.length }, + { oclId, contentType: "application/octet-stream", contentLength: bytes.length }, ); assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); - const { uploadToken, uploadUrl, headers } = initiateResult.initiateCreateOrUpdateFile; + const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile; const uploadHeaders = {}; headers.forEach((h) => (uploadHeaders[h.key] = h.value)); - const putResponse = await fetch(uploadUrl, { method: "PUT", headers: uploadHeaders, body: ciphertext }); - if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.statusText}`); + const putResponse = await fetch(uploadUrl, { + method: method || "PUT", + headers: uploadHeaders, + body: bytes, + }); + if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status} ${putResponse.statusText}`); const finishResult = await graphql( - `mutation Finish( - $oclId: String! - $uploadToken: String! - $path: String! - $accessLevel: String! - $changeBy: String! - $encryptionMetadata: EncryptionMetadataInput - ) { - finishCreateOrUpdateFile( - oclId: $oclId - uploadToken: $uploadToken - path: $path - accessLevel: $accessLevel - changeBy: $changeBy - encryptionMetadata: $encryptionMetadata - ) { - datasetId - message + `mutation Finish($oclId: String!, $uploadToken: String!, $path: String!, $accessLevel: String!, $changeBy: String!) { + finishCreateOrUpdateFile(oclId: $oclId, uploadToken: $uploadToken, path: $path, accessLevel: $accessLevel, changeBy: $changeBy) { + datasetId version error { code message requestId retryable details } } }`, @@ -687,21 +599,30 @@ async function main() { oclId, uploadToken, path: basename(filePath), - accessLevel: "HOLDERS", + accessLevel: "PUBLIC", changeBy: account.address, - encryptionMetadata: { - encryptionSystem, - encryptedDek, - iv: iv.toString("base64"), - contentHash: contentHashHex, - accessControlConditions, - encryptedBy: account.address.toLowerCase(), - encryptedAt: new Date().toISOString(), - }, }, ); assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); - console.log("5/5 File uploaded — datasetId:", finishResult.finishCreateOrUpdateFile.datasetId); + const { datasetId } = finishResult.finishCreateOrUpdateFile; + console.log("4/5 Uploaded — datasetId:", datasetId); + + // ---- Step 5: verify ---- + const verify = await graphql( + `query Verify($oclId: String!) { + labWithDataRoomAndFiles(oclId: $oclId) { + shortname + dataRoom { files { path accessLevel version } } + } + }`, + { oclId }, + ); + const lab = verify.labWithDataRoomAndFiles; + if (!lab) throw new Error("Lab not found — createLab did not complete"); + const file = lab.dataRoom.files.find((f) => f.path.endsWith(basename(filePath))); + if (!file) throw new Error("File not found in the data room"); + console.log("5/5 Verified:", file.path, file.accessLevel, "v" + file.version); + if (lab.shortname) console.log("Lab page:", `${LAB_APP_URL}/projects/${lab.shortname}`); } main().catch((err) => { @@ -713,23 +634,912 @@ main().catch((err) => { **Usage:** ```bash -WALLET_PRIVATE_KEY="0x..." CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" node workflow.js ./research-data.csv +WALLET_PRIVATE_KEY="0x..." \ +CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" \ +node tutorial-1.js ./research-data.csv +``` + +*** + +## Tutorial 2: Upload an encrypted file + +Same lab, same three-call upload — but the bytes are AES-256-GCM encrypted locally before they leave your machine, and decryption is gated on live onchain state. Encryption is a first-class flow, not an appendix: reach for it whenever the file is confidential and access should follow the lab's roles. + +**Steps 1–3 are identical to [Tutorial 1](#tutorial-1-create-a-lab-and-upload-a-public-file)** — get a service token, mint, register. Pick up here with `oclId`, `labAccountAddress`, `account` and `serviceToken` already in hand (or with any lab you already hold a role on). + +Conceptually: the backend hands you a one-shot data encryption key (DEK) in two forms — plaintext, and wrapped by the key custodian. You encrypt with the plaintext copy, throw it away, and store the wrapped copy in the file's metadata alongside the conditions under which the custodian may unwrap it again. Full model: [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md). + +### Step 4a: Get a DEK + +```javascript +const dekResult = await graphql(` + mutation { + generateDataEncryptionKey { + plaintextDEK + encryptedDek + encryptionSystem + error { code message requestId retryable details } + } + } +`); +assertOk(dekResult.generateDataEncryptionKey, "generateDataEncryptionKey"); +const { plaintextDEK, encryptedDek, encryptionSystem } = dekResult.generateDataEncryptionKey; ``` +**Expected response:** + +```json +{ + "data": { + "generateDataEncryptionKey": { + "plaintextDEK": "3q2+7wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "encryptedDek": "AQIDAHjR…", + "encryptionSystem": "kms", + "error": null + } + } +} +``` + +`encryptionSystem` is **backend-set** — echo it verbatim in Step 4d, never hardcode `"kms"`. Key custody is on a path to threshold cryptography, and echoing the value is what keeps your integration working across that change. + +Requires authentication (service token or Privy session), so Step 1 must have run. + +### Step 4b: Encrypt locally + +```javascript +import { webcrypto, randomBytes, createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; + +const filePath = "./confidential-results.csv"; +const plaintext = readFileSync(filePath); + +const cryptoKey = await webcrypto.subtle.importKey( + "raw", + Buffer.from(plaintextDEK, "base64"), + "AES-GCM", + false, // not extractable — the key cannot be read back out + ["encrypt"], +); +const iv = randomBytes(12); // 96-bit IV, fresh per file — never reuse one +const ciphertext = Buffer.from( + await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, cryptoKey, plaintext), +); +// Hash of the PLAINTEXT — this is what a reader checks after decrypting. +const contentHashHex = "sha256-" + createHash("sha256").update(plaintext).digest("hex"); +``` + +The plaintext DEK is now unreferenced; don't log it, don't persist it, don't send it anywhere. Web Crypto's `AES-GCM` appends the 16-byte authentication tag to the ciphertext, which is what the decrypt side expects. + +### Step 4c: Write the access conditions + +`accessControlConditions` is a JSON-stringified array of predicates the backend re-evaluates against live chain state every time someone asks to decrypt. Two recipes cover almost everything. + +**Owner only** — only the LabNFT owner (and authorised signers of its Token Bound Account, so a Safe's signers resolve through) can decrypt: + +```javascript +const ownerOnlyConditions = JSON.stringify([ + { + conditionType: "evmContract", + contractAddress: ACCESS_RESOLVER_ADDRESS, + chain: ACCESS_CONDITION_CHAIN, + functionName: "isAuthorizedSignerForTba", + functionParams: [":userAddress", labAccountAddress], + functionAbi: { + name: "isAuthorizedSignerForTba", + inputs: [ + { name: "signer", type: "address" }, + { name: "account", type: "address" }, + ], + outputs: [{ name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + returnValueTest: { key: "", comparator: "=", value: "true" }, + }, +]); +``` + +**Owner or Contributor or Viewer** — the recipe to use when the lab has a team, and the one Tutorial 3's agent needs. `hasRole` is hierarchical (`ROLE_VIEWER = 1`; a Contributor and the Owner both pass a Viewer check), and the explicit `isAuthorizedSignerForTba` branch keeps the Owner covered even if conditions are ever evaluated against a non-canonical chain: + +```javascript +const accessResolverAbi = { + isAuthorizedSignerForTba: { + name: "isAuthorizedSignerForTba", + inputs: [ + { name: "signer", type: "address" }, + { name: "account", type: "address" }, + ], + outputs: [{ name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + hasRole: { + name: "hasRole", + inputs: [ + { name: "oclId", type: "bytes32" }, + { name: "account", type: "address" }, + { name: "role", type: "uint8" }, + ], + outputs: [{ name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, +}; + +const teamConditions = JSON.stringify([ + { + conditionType: "evmContract", + contractAddress: ACCESS_RESOLVER_ADDRESS, + chain: ACCESS_CONDITION_CHAIN, + functionName: "isAuthorizedSignerForTba", + functionParams: [":userAddress", labAccountAddress], + functionAbi: accessResolverAbi.isAuthorizedSignerForTba, + returnValueTest: { key: "", comparator: "=", value: "true" }, + }, + { operator: "or" }, + { + conditionType: "evmContract", + contractAddress: ACCESS_RESOLVER_ADDRESS, + chain: ACCESS_CONDITION_CHAIN, + functionName: "hasRole", + functionParams: [oclId, ":userAddress", "1"], // "1" = ROLE_VIEWER; "2" = ROLE_CONTRIBUTOR and up + functionAbi: accessResolverAbi.hasRole, + returnValueTest: { key: "", comparator: "=", value: "true" }, + }, +]); +``` + +`:userAddress` is substituted with the authenticated caller's wallet at evaluation time. Pass `"2"` instead of `"1"` to exclude Viewers. Evaluation walks the array left to right and short-circuits (`or` stops at the first true); **any RPC error fails closed** and the DEK is not released. The full condition grammar, including `EvmBasicCondition`, is on [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md#worked-example-encrypt-for-owner-or-contributor-or-viewer). + +### Step 4d: Upload the ciphertext + +The same three calls as Tutorial 1, with the ciphertext in place of the raw file and `encryptionMetadata` attached on finish. + +```javascript +const initiateResult = await graphql( + `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { + initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { + uploadToken uploadUrl method headers { key value } + error { code message requestId retryable details } + } + }`, + // Content-length is the CIPHERTEXT length, and the type is opaque bytes. + { oclId, contentType: "application/octet-stream", contentLength: ciphertext.length }, +); +assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); +const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile; + +const uploadHeaders = {}; +headers.forEach((h) => (uploadHeaders[h.key] = h.value)); +const putResponse = await fetch(uploadUrl, { + method: method || "PUT", + headers: uploadHeaders, + body: ciphertext, +}); +if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status} ${putResponse.statusText}`); + +const finishResult = await graphql( + `mutation Finish( + $oclId: String! + $uploadToken: String! + $path: String! + $accessLevel: String! + $changeBy: String! + $encryptionMetadata: EncryptionMetadataInput + ) { + finishCreateOrUpdateFile( + oclId: $oclId + uploadToken: $uploadToken + path: $path + accessLevel: $accessLevel + changeBy: $changeBy + encryptionMetadata: $encryptionMetadata + ) { + datasetId + version + message + error { code message requestId retryable details } + } + }`, + { + oclId, + uploadToken, + path: basename(filePath), + accessLevel: "HOLDERS", // encrypted files use HOLDERS or ADMIN, not PUBLIC + changeBy: account.address, + encryptionMetadata: { + encryptionSystem, // echo verbatim — never hardcode + encryptedDek, + iv: iv.toString("base64"), + contentHash: contentHashHex, + accessControlConditions: teamConditions, + encryptedBy: account.address.toLowerCase(), + encryptedAt: new Date().toISOString(), + }, + }, +); +assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); +``` + +**If it fails:** + +| `error.code` | What happened | Fix | +| ------------ | ------------- | --- | +| `VALIDATION_FAILED`, `reason: INVALID_CONDITIONS` | `accessControlConditions` isn't a valid stringified condition array | It is a **JSON string**, not an object. Check `functionAbi` is complete and `returnValueTest` present | +| `VALIDATION_FAILED`, `reason: INVALID_ACCESS_LEVEL` | `PUBLIC` on an encrypted file | Use `HOLDERS` or `ADMIN` | +| `UNAUTHORIZED` on `generateDataEncryptionKey` | No write role on the lab | Owner or Contributor required | + +### Step 5: Verify by decrypting it + +The real test is a round trip: ask the backend to unwrap the DEK, decrypt, and compare hashes. `decryptDataKey` re-evaluates the file's conditions against **live** chain state and your token's wallet, so a success here proves the gate works. + +```javascript +const decryptResult = await graphql( + `mutation DecryptDataKey($oclId: String!, $filePath: String!) { + decryptDataKey(oclId: $oclId, filePath: $filePath) { + plaintextDEK + iv + error { code message requestId retryable details } + } + }`, + { oclId, filePath: basename(filePath) }, +); +assertOk(decryptResult.decryptDataKey, "decryptDataKey"); +const { plaintextDEK: unwrappedDEK, iv: returnedIv } = decryptResult.decryptDataKey; + +// Fetch the ciphertext back from the data room +const fileQuery = await graphql( + `query GetFile($oclId: String!, $path: String!) { + dataRoomFile(oclId: $oclId, path: $path) { + path + accessLevel + downloadUrl + encryptionMetadata { encryptionSystem contentHash encryptedBy encryptedAt } + } + }`, + { oclId, path: basename(filePath) }, +); +const downloaded = Buffer.from( + await (await fetch(fileQuery.dataRoomFile.downloadUrl)).arrayBuffer(), +); + +const decryptKey = await webcrypto.subtle.importKey( + "raw", + Buffer.from(unwrappedDEK, "base64"), + "AES-GCM", + false, + ["decrypt"], +); +const recovered = Buffer.from( + await webcrypto.subtle.decrypt( + { name: "AES-GCM", iv: Buffer.from(returnedIv, "base64") }, + decryptKey, + downloaded, + ), +); + +const recoveredHash = "sha256-" + createHash("sha256").update(recovered).digest("hex"); +if (recoveredHash !== fileQuery.dataRoomFile.encryptionMetadata.contentHash) { + throw new Error("Content hash mismatch after decryption"); +} +console.log("Round trip verified —", recovered.length, "bytes recovered"); +``` + +**Expected:** the hashes match and `recovered` equals your original file byte-for-byte. + +**If it fails:** + +| `error.code` | What happened | Fix | +| ------------ | ------------- | --- | +| `UNAUTHORIZED` | Your wallet does not satisfy the file's conditions | Check the role with the public `listLabMembers(oclId)` query. After a fresh grant, allow for indexer/chain lag and retry | +| `FAILED_PRECONDITION`, `reason: LEGACY_ENCRYPTION` | The file predates onchain-verified envelope encryption | Not decryptable through this mutation; use the original encryption client | +| `FAILED_PRECONDITION`, `reason: NOT_ENCRYPTED` / `MISSING_DEK` | The file has no `encryptionMetadata` | You're pointing at a public file | +| `UPSTREAM_UNAVAILABLE` | Condition evaluation could not reach the chain RPC | Fails closed by design. `retryable: true` | +| Web Crypto throws `OperationError` | Wrong IV, or ciphertext truncated | Use the `iv` **returned by `decryptDataKey`**, and pass the whole downloaded body including the trailing GCM tag | + +### Tutorial 2: complete script + +Steps 1–3 are Tutorial 1's verbatim; this script carries them so it runs standalone. + +```javascript +#!/usr/bin/env node +import { webcrypto, randomBytes, createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; +import { + createPublicClient, + createWalletClient, + http, + parseAbi, + parseEventLogs, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; // production: `base` + +const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql"; +const CHAIN = baseSepolia; +const FACTORY_ADDRESS = "0xd629FE2310b4309a212495F10A47f8436dcEfD90"; +const LABNFT_ADDRESS = "0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28"; +const ACCESS_RESOLVER_ADDRESS = "0x5493F472602C87318EA5Eff753cDD593bf9bF559"; +const ACCESS_CONDITION_CHAIN = "baseSepolia"; +const SERVICE_NAME = "tutorial-agent"; + +const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; +const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY; +// Optional: reuse an existing lab instead of minting a new one. +const EXISTING_OCL_ID = process.env.OCL_ID; + +let serviceToken; + +async function graphql(query, variables) { + const headers = { "Content-Type": "application/json", Authorization: CONSUMER_CREDENTIAL }; + if (serviceToken) headers["X-Service-Token"] = serviceToken; + const res = await fetch(GRAPHQL_URL, { + method: "POST", + headers, + body: JSON.stringify({ query, variables }), + }); + const { data, errors } = await res.json(); + if (errors) throw new Error(JSON.stringify(errors)); + return data; +} + +function assertOk(result, op) { + if (result.error) { + const { code, message, requestId } = result.error; + throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`); + } + return result; +} + +const accessResolverAbi = { + isAuthorizedSignerForTba: { + name: "isAuthorizedSignerForTba", + inputs: [ + { name: "signer", type: "address" }, + { name: "account", type: "address" }, + ], + outputs: [{ name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + hasRole: { + name: "hasRole", + inputs: [ + { name: "oclId", type: "bytes32" }, + { name: "account", type: "address" }, + { name: "role", type: "uint8" }, + ], + outputs: [{ name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, +}; + +function buildTeamConditions(oclId, labAccountAddress) { + return JSON.stringify([ + { + conditionType: "evmContract", + contractAddress: ACCESS_RESOLVER_ADDRESS, + chain: ACCESS_CONDITION_CHAIN, + functionName: "isAuthorizedSignerForTba", + functionParams: [":userAddress", labAccountAddress], + functionAbi: accessResolverAbi.isAuthorizedSignerForTba, + returnValueTest: { key: "", comparator: "=", value: "true" }, + }, + { operator: "or" }, + { + conditionType: "evmContract", + contractAddress: ACCESS_RESOLVER_ADDRESS, + chain: ACCESS_CONDITION_CHAIN, + functionName: "hasRole", + functionParams: [oclId, ":userAddress", "1"], + functionAbi: accessResolverAbi.hasRole, + returnValueTest: { key: "", comparator: "=", value: "true" }, + }, + ]); +} + +async function main() { + const filePath = process.argv[2]; + if (!filePath) throw new Error("Usage: node tutorial-2.js "); + + const account = privateKeyToAccount(WALLET_PRIVATE_KEY); + const publicClient = createPublicClient({ chain: CHAIN, transport: http() }); + const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() }); + + // ---- Step 1: service token ---- + const signInMessage = await graphql( + `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } + }`, + { walletAddress: account.address, serviceName: SERVICE_NAME }, + ); + const messageSignature = await walletClient.signMessage({ + message: signInMessage.getServiceSignInMessage.message, + }); + const tokenResult = await graphql( + `mutation GenerateServiceToken($serviceName: String!, $walletAddress: String!, $messageSignature: String!) { + generateServiceToken(serviceName: $serviceName, walletAddress: $walletAddress, messageSignature: $messageSignature) { + token + error { code message requestId retryable details } + } + }`, + { serviceName: SERVICE_NAME, walletAddress: account.address, messageSignature }, + ); + assertOk(tokenResult.generateServiceToken, "generateServiceToken"); + serviceToken = tokenResult.generateServiceToken.token; + console.log("1/6 Got service token"); + + // ---- Steps 2 & 3: mint + register (skipped when OCL_ID is provided) ---- + let oclId = EXISTING_OCL_ID; + let labAccountAddress; + + if (oclId) { + const existing = await graphql( + `query($oclId: String!) { labWithDataRoomAndFiles(oclId: $oclId) { labAccountAddress } }`, + { oclId }, + ); + if (!existing.labWithDataRoomAndFiles) throw new Error(`Lab ${oclId} not found`); + labAccountAddress = existing.labWithDataRoomAndFiles.labAccountAddress; + console.log("2-3/6 Reusing lab", oclId); + } else { + const factoryAbi = parseAbi([ + "function mintAndCreateAccount(address to) external payable returns (address account, uint256 tokenId)", + ]); + const labNftAbi = parseAbi([ + "function mintFeeWei() external view returns (uint256)", + "event OclIdentityCreated(address indexed account, bytes32 indexed oclId, uint256 indexed tokenId, bytes32 salt, uint256 canonicalChainId)", + ]); + const mintFeeWei = await publicClient.readContract({ + address: LABNFT_ADDRESS, + abi: labNftAbi, + functionName: "mintFeeWei", + }); + const mintTxHash = await walletClient.writeContract({ + address: FACTORY_ADDRESS, + abi: factoryAbi, + functionName: "mintAndCreateAccount", + args: [account.address], + value: mintFeeWei, + }); + const mintReceipt = await publicClient.waitForTransactionReceipt({ hash: mintTxHash }); + const [identity] = parseEventLogs({ + abi: labNftAbi, + eventName: "OclIdentityCreated", + logs: mintReceipt.logs.filter((l) => l.address.toLowerCase() === LABNFT_ADDRESS.toLowerCase()), + }); + oclId = identity.args.oclId; + labAccountAddress = identity.args.account; + console.log("2/6 Minted LabNFT — oclId:", oclId); + + const createLabResult = await graphql( + `mutation CreateLab($oclId: String!) { + createLab(input: { oclId: $oclId }) { + error { code message requestId retryable details } + lab { labAccountAddress } + } + }`, + { oclId }, + ); + assertOk(createLabResult.createLab, "createLab"); + console.log("3/6 Lab registered"); + } + + // ---- Step 4a: DEK ---- + const dekResult = await graphql(` + mutation { + generateDataEncryptionKey { + plaintextDEK encryptedDek encryptionSystem + error { code message requestId retryable details } + } + } + `); + assertOk(dekResult.generateDataEncryptionKey, "generateDataEncryptionKey"); + const { plaintextDEK, encryptedDek, encryptionSystem } = dekResult.generateDataEncryptionKey; + + // ---- Step 4b: encrypt locally ---- + const plaintext = readFileSync(filePath); + const cryptoKey = await webcrypto.subtle.importKey( + "raw", + Buffer.from(plaintextDEK, "base64"), + "AES-GCM", + false, + ["encrypt"], + ); + const iv = randomBytes(12); + const ciphertext = Buffer.from( + await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, cryptoKey, plaintext), + ); + const contentHashHex = "sha256-" + createHash("sha256").update(plaintext).digest("hex"); + console.log("4/6 Encrypted locally —", plaintext.length, "→", ciphertext.length, "bytes"); + + // ---- Step 4c + 4d: conditions + upload ---- + const initiateResult = await graphql( + `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { + initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { + uploadToken uploadUrl method headers { key value } + error { code message requestId retryable details } + } + }`, + { oclId, contentType: "application/octet-stream", contentLength: ciphertext.length }, + ); + assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); + const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile; + + const uploadHeaders = {}; + headers.forEach((h) => (uploadHeaders[h.key] = h.value)); + const putResponse = await fetch(uploadUrl, { + method: method || "PUT", + headers: uploadHeaders, + body: ciphertext, + }); + if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status} ${putResponse.statusText}`); + + const finishResult = await graphql( + `mutation Finish($oclId: String!, $uploadToken: String!, $path: String!, $accessLevel: String!, $changeBy: String!, $encryptionMetadata: EncryptionMetadataInput) { + finishCreateOrUpdateFile(oclId: $oclId, uploadToken: $uploadToken, path: $path, accessLevel: $accessLevel, changeBy: $changeBy, encryptionMetadata: $encryptionMetadata) { + datasetId version + error { code message requestId retryable details } + } + }`, + { + oclId, + uploadToken, + path: basename(filePath), + accessLevel: "HOLDERS", + changeBy: account.address, + encryptionMetadata: { + encryptionSystem, + encryptedDek, + iv: iv.toString("base64"), + contentHash: contentHashHex, + accessControlConditions: buildTeamConditions(oclId, labAccountAddress), + encryptedBy: account.address.toLowerCase(), + encryptedAt: new Date().toISOString(), + }, + }, + ); + assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); + console.log("5/6 Uploaded — datasetId:", finishResult.finishCreateOrUpdateFile.datasetId); + + // ---- Step 5: verify by decrypting ---- + const decryptResult = await graphql( + `mutation DecryptDataKey($oclId: String!, $filePath: String!) { + decryptDataKey(oclId: $oclId, filePath: $filePath) { + plaintextDEK iv + error { code message requestId retryable details } + } + }`, + { oclId, filePath: basename(filePath) }, + ); + assertOk(decryptResult.decryptDataKey, "decryptDataKey"); + + const fileQuery = await graphql( + `query GetFile($oclId: String!, $path: String!) { + dataRoomFile(oclId: $oclId, path: $path) { + downloadUrl + encryptionMetadata { contentHash } + } + }`, + { oclId, path: basename(filePath) }, + ); + const downloaded = Buffer.from( + await (await fetch(fileQuery.dataRoomFile.downloadUrl)).arrayBuffer(), + ); + const decryptKey = await webcrypto.subtle.importKey( + "raw", + Buffer.from(decryptResult.decryptDataKey.plaintextDEK, "base64"), + "AES-GCM", + false, + ["decrypt"], + ); + const recovered = Buffer.from( + await webcrypto.subtle.decrypt( + { name: "AES-GCM", iv: Buffer.from(decryptResult.decryptDataKey.iv, "base64") }, + decryptKey, + downloaded, + ), + ); + const recoveredHash = "sha256-" + createHash("sha256").update(recovered).digest("hex"); + if (recoveredHash !== fileQuery.dataRoomFile.encryptionMetadata.contentHash) { + throw new Error("Content hash mismatch after decryption"); + } + console.log("6/6 Round trip verified —", recovered.length, "bytes recovered"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +**Usage:** + +```bash +WALLET_PRIVATE_KEY="0x..." \ +CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" \ +node tutorial-2.js ./confidential-results.csv + +# or against a lab you already have +OCL_ID="0x0101…" WALLET_PRIVATE_KEY="0x..." CONSUMER_CREDENTIAL="mol_…" \ +node tutorial-2.js ./confidential-results.csv +``` + +*** + +## Tutorial 3: Give your agent access to a lab you created in the app + +The most common real-world shape: a researcher created their Lab in the Labs app with an email address — no wallet, no code — and now wants an agent contributing to it. The agent gets its **own** identity rather than borrowing the human's; the human grants it a role; the agent authenticates itself from then on. + +**Who does what:** + +| # | Actor | Action | +| - | ----- | ------ | +| 1 | Agent | Generate a wallet and report its address | +| 2 | Human | Add that address to the lab as **Contributor**, flagged as an agent, with an expiry | +| 3 | Agent | Self-issue a service token by signing the sign-in message | +| 4 | Agent | Upload files and announce; the human sees the result in the app | + +The human never hands over a private key, a token, or their session. Revoking the agent is one onchain revoke, and it does not touch anything else. + +### Step 1: The agent reports its address + +With the plugin: run `wallet_address`. With viem: + +```javascript +import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; + +// Generate once, store it as the agent's own secret — never the human's key. +const agentPrivateKey = process.env.AGENT_PRIVATE_KEY ?? generatePrivateKey(); +const agentAccount = privateKeyToAccount(agentPrivateKey); +console.log("Agent wallet address:", agentAccount.address); +``` + +Give that address to the lab owner. The agent needs **no gas** for this tutorial — it never sends a transaction, only signs a message. (Persist `agentPrivateKey` if you generated it, or the next run is a different agent with no role.) + +### Step 2: The human grants Contributor + +In the Labs app, the lab owner adds the agent's address to the lab's members and grants it the **Contributor** role, setting: + +* **`isAgent = true`** — informational metadata that marks the member as an agent identity in the members list and UI. It does not change authorisation. +* **an expiry** — typically the agent's session lifetime. When it lapses the agent loses access until it is re-granted; a permanent grant is possible but not the default you want for an agent. + +Members can be invited by wallet address, ENS name or email, and the app sponsors the gas for the grant. Under the hood this is one onchain call on the `AccessResolver`, which the owner (or an existing Contributor, for Viewer grants) can also make directly: + +```solidity +function grantRole(bytes32 oclId, address account, uint8 role, uint64 expiry, bool isAgent) external; +// role: 2 = ROLE_CONTRIBUTOR, 1 = ROLE_VIEWER +``` + +Only the **Owner** may grant Contributor. Contributors can grant Viewer, but not Contributor. Full capability matrix: [Roles & Permissions](../../technical-deep-dive/roles-and-permissions.md). + +**Why Contributor and not Viewer:** a Viewer can decrypt and read but cannot write. Uploading files and posting announcements needs Contributor. + +Both parties can confirm the grant landed with a public query — no authentication beyond the consumer credential: + +```javascript +const members = await graphql( + `query ListLabMembers($oclId: String!) { + listLabMembers(oclId: $oclId) { + members { walletAddress role isAgent expiry grantedAt } + } + }`, + { oclId }, +); +const grant = members.listLabMembers.members.find( + (m) => m.walletAddress.toLowerCase() === agentAccount.address.toLowerCase(), +); +console.log("Agent role:", grant?.role, "expiry:", grant?.expiry ?? "permanent"); +``` + +Expect `role: "CONTRIBUTOR"` and `isAgent: true`. `expiry` is unix seconds as a decimal string, or `null` for a permanent grant. Expired grants are excluded from this list entirely, so a missing entry after a while means the grant lapsed. + +### Step 3: The agent self-issues a service token + +Identical to Tutorial 1 Step 1, signed by the **agent's** wallet: + +```javascript +const AGENT_SERVICE_NAME = "research-agent-1"; + +const signInMessage = await graphql( + `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } + }`, + { walletAddress: agentAccount.address, serviceName: AGENT_SERVICE_NAME }, +); + +const messageSignature = await agentAccount.signMessage({ + message: signInMessage.getServiceSignInMessage.message, +}); + +const tokenResult = await graphql( + `mutation GenerateServiceToken( + $serviceName: String! + $walletAddress: String! + $messageSignature: String! + $expiresIn: String + ) { + generateServiceToken( + serviceName: $serviceName + walletAddress: $walletAddress + messageSignature: $messageSignature + expiresIn: $expiresIn + ) { + token tokenId expiresAt + error { code message requestId retryable details } + } + }`, + { + serviceName: AGENT_SERVICE_NAME, + walletAddress: agentAccount.address, + messageSignature, + expiresIn: "30d", // match the role grant's expiry rather than taking the 180d default + }, +); +assertOk(tokenResult.generateServiceToken, "generateServiceToken"); +serviceToken = tokenResult.generateServiceToken.token; +``` + +Issuance is **not** gated on holding a role — any wallet can mint a token for itself. The role is what makes the token *useful*: authorisation is resolved per request from the token's wallet against the lab you name. So a token issued before the grant lands keeps working once it does; you do not need to re-issue it. + +### Step 4: The agent uploads and announces + +From here the agent is an ordinary caller. Run [Tutorial 1 Step 4](#step-4-upload-the-file) with `changeBy: agentAccount.address`, or [Tutorial 2](#tutorial-2-upload-an-encrypted-file) for a confidential file — the `hasRole` branch of the team conditions is exactly what lets the agent decrypt too. Then [Tutorial 4](#tutorial-4-announce-the-dataset) to surface it on the lab's feed. + +Writes by a Contributor service token are gated per mutation, matching the Privy user path: `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createAnnouncement`, `deleteDataRoomFile`, `updateFileMetadata` and `moveEntry` all accept Contributor. A few surfaces remain **Owner-only** and an agent Contributor cannot reach them: `updateLabNftMetadata`, `generateLabImageUploadUrl` and the legal-agreement mutations. + +{% hint style="warning" %} +**Retry on `UNAUTHORIZED` right after the grant.** Role state reaches the API through an event indexer, so there is a short window after `grantRole` confirms onchain in which a write still returns `UNAUTHORIZED` (`reason: NOT_CONTRIBUTOR`). It is not a permissions problem and re-issuing the token will not help — wait and retry: + +```javascript +async function withRoleGrantRetry(fn, { attempts = 5, baseMs = 2000 } = {}) { + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err) { + const isAuthzLag = /UNAUTHORIZED/.test(String(err)); + if (!isAuthzLag || i === attempts - 1) throw err; + await new Promise((r) => setTimeout(r, baseMs * 2 ** i)); // 2s, 4s, 8s, 16s + } + } +} + +await withRoleGrantRetry(() => uploadFile(oclId, "./findings.csv")); +``` +{% endhint %} + +### Step 5: Verify from both sides + +**The agent** verifies as in Tutorial 1 Step 5 — the file is in `dataRoom.files` with `createdBy` set to the agent's address: + +```javascript +const verify = await graphql( + `query Verify($oclId: String!) { + labWithDataRoomAndFiles(oclId: $oclId) { + shortname + dataRoom { files { path accessLevel version createdBy } } + announcements { headline changeBy } + } + }`, + { oclId }, +); +``` + +**The human** verifies in the app: the file appears in the lab's data room and the announcement on its activity feed, both attributed to the agent's address, which the members list shows flagged as an agent. + +### Revoking the agent + +One onchain call, and the agent's writes stop: + +```solidity +function revokeRole(bytes32 oclId, address account) external; // Owner only, for a Contributor +``` + +Or let the grant's `expiry` lapse. Independently, the agent's token can be killed with `revokeServiceToken(tokenId)` — a token can only revoke or extend **its own** record, so one agent cannot interfere with another's. + +*** + +## Tutorial 4: Announce the dataset + +An announcement is the lab's public update stream: a headline, a body, and optionally the datasets it is about. Attaching the file makes the announcement the discoverable surface for it — announcements are indexed by `searchLabs` alongside files, and they appear on the lab's activity feed and public page. + +Requires Owner or Contributor (a Viewer cannot announce). Pick up with `oclId`, `serviceToken` and the `datasetId` returned by `finishCreateOrUpdateFile`. + +```javascript +const announcementResult = await graphql( + `mutation CreateAnnouncement( + $oclId: String! + $headline: String! + $body: String! + $attachments: [String!] + ) { + createAnnouncement(oclId: $oclId, headline: $headline, body: $body, attachments: $attachments) { + message + error { code message requestId retryable details } + } + }`, + { + oclId, + headline: "Baseline assay results published", + body: "First replicate set for the ApoB series. 240 samples, three conditions. Raw CSV attached.", + attachments: [datasetId], // the datasetId from finishCreateOrUpdateFile + }, +); +assertOk(announcementResult.createAnnouncement, "createAnnouncement"); +``` + +**Expected response:** + +```json +{ + "data": { + "createAnnouncement": { + "message": "…", + "error": null + } + } +} +``` + +`createAnnouncement` returns no announcement object, and its `message` is passed through from the storage layer rather than being a fixed string — so success is `error == null` and nothing else. Verify by reading the announcement back. + +**If it fails:** + +| `error.code` | What happened | Fix | +| ------------ | ------------- | --- | +| `UNAUTHORIZED` | Caller is a Viewer, or has no role | Contributor or Owner required. Fresh grant? See the [retry note](#step-4-the-agent-uploads-and-announces) | +| `NOT_FOUND` | An `attachments` entry isn't a dataset in this lab | Pass the exact `datasetId` strings from `finishCreateOrUpdateFile`, from this lab | +| `VALIDATION_FAILED` | Empty `headline` or `body` | Both are required and non-empty | + +### Verify it worked + +```javascript +const feed = await graphql( + `query LabActivity($oclId: String!) { + labActivity(oclId: $oclId, page: 0, perPage: 10, filter: ANNOUNCEMENT) { + nodes { + __typename + ... on LabEventAnnouncement { + announcement { + id + headline + body + changeBy + eventTime + attachments { path contentType accessLevel } + } + } + } + pageInfo { totalPages currentPage } + } + }`, + { oclId }, +); +console.log(JSON.stringify(feed.labActivity.nodes[0], null, 2)); +``` + +Your announcement is the newest node, with `attachments` resolved to the full file objects — not just ids — and `changeBy` set to the wallet that posted it. It is also on the lab's public page at `${LAB_APP_URL}/projects/`. + +`labActivity` is a **public** query: anyone with a consumer credential can read the feed, which is the point of an announcement. + *** ## Running in Production -Everything above runs against staging (Base Sepolia, testnet ETH). To run the same script against production, replace the six values in the config block — nothing else in the script changes, since every step reads from these constants: +Everything above runs against staging (Base Sepolia, testnet funds). To run the same scripts against production, replace the values in the config block — nothing else changes, since every step reads from these constants: -| Constant | Staging (this walkthrough) | Production | -| ---------------------------- | ---------------------------------------------- | ----------------------------------------------- | -| `GRAPHQL_URL` | `https://staging.graphql.api.molecule.xyz/graphql` | `https://production.graphql.api.molecule.xyz/graphql` | -| `CHAIN` (viem import) | `baseSepolia` from `viem/chains` | `base` from `viem/chains` | -| `FACTORY_ADDRESS` | `0xd629FE2310b4309a212495F10A47f8436dcEfD90` | `0xECdF4f05384056507485C90aeAb0a83268760D6E` | -| `LABNFT_ADDRESS` | `0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28` | `0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92` | -| `ACCESS_RESOLVER_ADDRESS` | `0x5493F472602C87318EA5Eff753cDD593bf9bF559` | `0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B` | -| `ACCESS_CONDITION_CHAIN` | `"baseSepolia"` | `"base"` | +| Constant | Staging (these tutorials) | Production | +| -------- | ------------------------- | ---------- | +| `GRAPHQL_URL` | `https://staging.graphql.api.molecule.xyz/graphql` | `https://production.graphql.api.molecule.xyz/graphql` | +| `CHAIN` (viem import) | `baseSepolia` from `viem/chains` | `base` from `viem/chains` | +| `FACTORY_ADDRESS` | `0xd629FE2310b4309a212495F10A47f8436dcEfD90` | `0xECdF4f05384056507485C90aeAb0a83268760D6E` | +| `LABNFT_ADDRESS` | `0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28` | `0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92` | +| `ACCESS_RESOLVER_ADDRESS` | `0x5493F472602C87318EA5Eff753cDD593bf9bF559` | `0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B` | +| `ACCESS_CONDITION_CHAIN` | `"baseSepolia"` | `"base"` | +| `LAB_APP_URL` | `https://testnet.labs.molecule.xyz` | `https://labs.molecule.xyz` | ```javascript import { base } from "viem/chains"; // instead of baseSepolia @@ -740,16 +1550,18 @@ const FACTORY_ADDRESS = "0xECdF4f05384056507485C90aeAb0a83268760D6E"; const LABNFT_ADDRESS = "0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92"; const ACCESS_RESOLVER_ADDRESS = "0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B"; const ACCESS_CONDITION_CHAIN = "base"; +const LAB_APP_URL = "https://labs.molecule.xyz"; ``` -A few things that follow automatically from that swap and don't need separate handling: +A few things follow automatically from that swap: -* **EIP-712 `chainId`** in Step 4 is read as `CHAIN.id` (`8453` for `base`, `84532` for `baseSepolia`) — it tracks `CHAIN` and needs no separate edit. See [Legal Agreements — EIP-712 Envelope](legal-agreements.md#eip-712-envelope) for why this must match the LabNFT's actual deployment chain. -* **Headers and the `graphql()` helper** are identical in both environments — `Authorization` (consumer credential, no `Bearer` prefix) and the self-issued `X-Service-Token` from Step 1 work the same way against both endpoints. See [Authentication](../authentication.md). -* **The `mintFeeWei()` read** in Step 2 already queries the live contract, so it picks up whatever fee production has configured without a code change. +* **Headers and the `graphql()` helper** are identical — `Authorization` (consumer credential, no `Bearer`) and the self-issued `X-Service-Token` work the same against both endpoints. Note that credentials are **per environment**: a staging `mol_` credential does not authenticate against production. +* **The `mintFeeWei()` read** already queries the live contract, so it picks up whatever fee production has configured with no code change. It reads `0` today on both chains. +* **The condition ABIs** are unchanged; only `contractAddress` and `chain` differ, and both come from the config block. -What doesn't follow automatically, and is on you to handle: +What doesn't follow automatically, and is on you: -* **Real funds.** Minting on `base` spends real ETH from the wallet behind `WALLET_PRIVATE_KEY`, and the assignment agreement you sign is a real one. Test the full flow on staging first. -* **`SERVICE_NAME`** should identify the real integration once you're not just testing — it's echoed into the sign-in message and stored against the issued token. +* **Real funds.** Minting on `base` spends real ETH. Test on staging first. +* **Introspection is off in production** and query depth is capped at 10. Generate types against staging — see [Getting the schema](../getting-started/README.md#getting-the-schema). +* **`SERVICE_NAME`** should identify the real integration; it is echoed into the sign-in message and stored against the issued token. * Full deployment list, including every other OCL contract on both chains: [Contracts reference](../../references/contracts/README.md). diff --git a/api-reference/labs-api/files.md b/api-reference/labs-api/files.md index c1a8689..574413b 100644 --- a/api-reference/labs-api/files.md +++ b/api-reference/labs-api/files.md @@ -2,6 +2,8 @@ Working with files in a Lab dataroom: the three-step upload flow (initiate → upload → finish), plus announcements, metadata updates, deletion, storage limits, and client-side encryption. Creating the Lab itself is covered in [Lab Management](lab-management.md). +> **Looking for a runnable walkthrough?** This page is the per-operation reference. For a first upload with expected responses and failure handling at every step, use [Tutorial 1](example-workflow.md#tutorial-1-create-a-lab-and-upload-a-public-file) (public file) or [Tutorial 2](example-workflow.md#tutorial-2-upload-an-encrypted-file) (encrypted, with a decrypt round trip). + > **Note**: Every mutation on this page returns its failure in-band: the result carries `error: ApiError`, and success means `error` is `null`. Branch on `error.code` — never on `message` text — and quote `requestId` when reporting a problem. See [Error Handling](README.md#error-handling) for the `ApiError` shape, how to read `details`, and the list of error codes. ## Step 1: Initiate File Upload @@ -648,7 +650,6 @@ query GetFile($oclId: String!, $path: String!) { curl -X POST https://production.graphql.api.molecule.xyz/graphql \ -H 'Content-Type: application/json' \ -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \ - -H 'X-Service-Token: YOUR_SERVICE_TOKEN' \ -d '{ "query": "query GetFile($oclId: String!, $path: String!) { dataRoomFile(oclId: $oclId, path: $path) { did path contentType accessLevel downloadUrl } }", "variables": { @@ -707,6 +708,8 @@ Enhance file discoverability with optional metadata: ## Advanced: Encrypted File Upload +> **Step-by-step version:** [Tutorial 2 — Upload an encrypted file](example-workflow.md#tutorial-2-upload-an-encrypted-file), including both access-condition recipes (owner-only, and owner/contributor/viewer) and a decrypt round trip that verifies the gate actually works. + For files requiring client-side encryption, obtain a data encryption key via the `generateDataEncryptionKey` mutation, encrypt locally, upload as normal, and include an `encryptionMetadata` object on `finishCreateOrUpdateFile`. The full end-to-end model — key wrapping, onchain access conditions, and condition-gated decryption — is documented on the [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md) page. ### Obtain a DEK, then encrypt locally diff --git a/api-reference/labs-api/lab-management.md b/api-reference/labs-api/lab-management.md index def6dd5..61c7851 100644 --- a/api-reference/labs-api/lab-management.md +++ b/api-reference/labs-api/lab-management.md @@ -6,7 +6,7 @@ Operations for creating and administering a Lab: creating the dataroom, managing ## Mint the LabNFT -Before `createLab` can attach a dataroom, an onchain lab (OCL) has to exist: a LabNFT minted to your wallet with its ERC-6551 account (Token Bound Account) deployed. This step is **onchain only** — there is no Labs API mutation for it. See [Lab Creation](../../technical-deep-dive/architecture.md#lab-creation) for the contract-level flow and [Molecule Labs](../../technical-deep-dive/onchain-lab.md) for what a Lab is. If you'd rather not touch contracts directly, the Molecule app does this for you in [Step 1: Create Your Onchain Lab](../../user-guides/scientists-researchers.md#step-1-create-your-onchain-lab). +Before `createLab` can attach a dataroom, an onchain lab (OCL) has to exist: a LabNFT minted to your wallet with its ERC-6551 account (Token Bound Account) deployed. This step is **onchain only** — there is no Labs API mutation for it. See [Lab Creation](../../technical-deep-dive/architecture.md#lab-creation) for the contract-level flow and [Molecule Labs](../../technical-deep-dive/onchain-lab.md) for what a Lab is. If you'd rather not touch contracts directly, the Molecule app does this for you — see [Creating a Lab](../../user-guides/scientists-researchers.md#creating-a-lab). For a runnable end-to-end version of the mint, see [Tutorial 1 Step 2](example-workflow.md#step-2-mint-the-labnft). ### Contract Addresses @@ -118,9 +118,9 @@ Once you have `oclId`, continue to [Create Lab](#create-lab) below. Register a Kamu-backed lab (data room) for an onchain lab (OCL) that already exists onchain. The lab is identified by its canonical `oclId` (a 32-byte hex string, 0x-prefixed). -> **Prerequisite — the LabNFT must be minted first.** `createLab` does not mint anything; it attaches a dataroom to an OCL that already exists onchain. See [Mint the LabNFT](#mint-the-labnft) above for the contract call and how to derive `oclId` from the result. If you'd rather not touch the contracts directly, the Molecule app does this for you in [Step 1: Create Your Onchain Lab](../../user-guides/scientists-researchers.md#step-1-create-your-onchain-lab). +> **Prerequisite — the LabNFT must be minted first.** `createLab` does not mint anything; it attaches a dataroom to an OCL that already exists onchain. See [Mint the LabNFT](#mint-the-labnft) above for the contract call and how to derive `oclId` from the result. If you'd rather not touch the contracts directly, the Molecule app does this for you — see [Creating a Lab](../../user-guides/scientists-researchers.md#creating-a-lab). -> **Admin Authorization Required**: This mutation requires either a service token (JWT) from the Molecule team OR a valid Privy authentication token. The caller must be the LabNFT owner (or an authorized multisig signer) for the given `oclId`. +> **Owner authorization required**: this mutation requires either a **self-issued** service token (see [Service Tokens](service-tokens.md#obtaining-a-token) — no need to ask anyone for one) or a valid Privy session. The caller must be the LabNFT owner (or an authorized multisig signer) for the given `oclId`. **GraphQL Mutation:** @@ -160,7 +160,7 @@ The mutation takes a single `CreateLabInput` object: * For multisig/Safe wallets: You must be one of the Safe owners * For ERC-4337 accounts: You must be an authorized account owner 2. **Authentication**: One of the following: - * **Service Token** (recommended for automation): Obtain from Molecule team via Discord + * **Service Token** (recommended for automation): [issue one yourself](service-tokens.md#obtaining-a-token) by signing a message with the owner wallet * **Privy Token** (for user-initiated requests): Use your authenticated Privy session 3. **LabNFT Must Be Minted**: The onchain lab (LabNFT / `oclId`) must already exist onchain before registering the lab @@ -220,6 +220,8 @@ curl -X POST https://production.graphql.api.molecule.xyz/graphql \ **Not Authenticated (No Token):** +> The `message` below is returned verbatim by the API and its "contact Molecule tech team" wording is out of date: service tokens are self-issued. Branch on `error.code` / `details.reason`, never on message text — and [issue your own token](service-tokens.md#obtaining-a-token). + ```json { "data": { @@ -293,20 +295,16 @@ curl -X POST https://production.graphql.api.molecule.xyz/graphql \ * **Batch Operations**: Register multiple labs for a portfolio of onchain labs * **User Self-Service**: Allow users to create their own lab data rooms -**Getting Service Token Access:** +**Getting a service token:** + +Issue it yourself — no request, no waiting. Two calls with the owner wallet: + +1. `getServiceSignInMessage(walletAddress, serviceName)` — public query, returns the message to sign. +2. Sign it verbatim (EIP-191 `personal_sign`), then `generateServiceToken(serviceName, walletAddress, messageSignature)` — returns the JWT for `X-Service-Token`. -To obtain a service token for automated lab creation: +Full parameters and bounds: [Service Tokens](service-tokens.md#obtaining-a-token). Runnable: [Tutorial 1 Step 1](example-workflow.md#step-1-get-a-service-token). -1. Join our [Discord community](https://t.co/L0VEiy4Bjk) -2. Contact the Molecule team -3. Provide: - * Your wallet address - * Use case description - * Intended automation workflow -4. You'll receive: - * Consumer credential (for all APIs) - * Service Token (JWT for lab creation) - * Token expiration date +The only credential you have to request is the **consumer credential** — see [Getting Started](../getting-started/README.md#1-a-mol-consumer-credential-the-one-manual-step) for the request template. *** @@ -314,7 +312,7 @@ To obtain a service token for automated lab creation: Retrieve complete details for a specific lab including all files. This is a **public endpoint** - no authentication required. Look up a lab by its `oclId` or, alternatively, by its human-readable `shortname` — provide exactly one. -> **🔓 Public Endpoint**: The `labWithDataRoomAndFiles` query does not require authentication. You only need a consumer credential (`Authorization: Bearer`) - no Service Token is needed. File-level access control is handled via encryption rather than query-level authentication. +> **🔓 Public Endpoint**: The `labWithDataRoomAndFiles` query does not require authentication. You only need a consumer credential — `Authorization: mol__`, with **no `Bearer` prefix** — and no Service Token. File-level access control is handled via encryption rather than query-level authentication. **GraphQL Query:** diff --git a/api-reference/labs-api/legal-agreements.md b/api-reference/labs-api/legal-agreements.md index c8553fa..713c0a2 100644 --- a/api-reference/labs-api/legal-agreements.md +++ b/api-reference/labs-api/legal-agreements.md @@ -1,5 +1,13 @@ +--- +hidden: true +--- + # Legal Agreements +{% hint style="warning" %} +**Not part of onboarding, and not a gate on anything.** Signing a legal agreement is no longer a precondition for creating a lab, uploading files, or any other Labs API operation. This page is retained as a reference for the existing `legalAgreement*` operations while they remain in the schema, and it is deliberately out of the site navigation. Do not build a new integration around it. Onboarding starts at [Getting Started](../getting-started/README.md). +{% endhint %} + The legal-agreement flow is three operations: fetch the populated template + `contentHash`, sign it as an EIP-712 typed-data payload, then submit the signature. The backend regenerates and verifies the document server-side and stores the signed artifact in the lab's data room. > `type` is a `LegalAgreementType` enum. Current value: `ASSIGNMENT_AGREEMENT`. diff --git a/api-reference/labs-api/service-tokens.md b/api-reference/labs-api/service-tokens.md index 859de21..290fde1 100644 --- a/api-reference/labs-api/service-tokens.md +++ b/api-reference/labs-api/service-tokens.md @@ -1,10 +1,14 @@ # Service Token Management -## Obtaining Tokens +A service token is the credential that proves *which wallet* a write request acts as. It is **self-issued** — you mint your own by proving control of the wallet, and nobody has to provision one for you. -Service tokens must be requested from the Molecule team (see [Authentication](../authentication.md) section above). +> **Wallet-bound, not lab-bound.** A service token carries a wallet identity, not a list of labs. What it may do on a given lab is resolved per request from that wallet's live onchain role, so one token works across every lab the wallet has a role on, and a role granted after issuance takes effect without re-issuing. See [What a Service Token actually authorizes](../authentication.md#what-a-service-token-actually-authorizes). -Alternatively, a service can obtain a token **self-service** by proving control of its wallet — useful for autonomous agents, bots, and CI/CD pipelines that don't have a browser-based Privy session. This is a two-step flow: fetch the deterministic sign-in message, sign it with the service wallet, then exchange the signature for a token. +## Obtaining a Token + +Two calls, no human in the loop — the path for autonomous agents, bots and CI/CD pipelines that have no browser-based Privy session. Fetch the deterministic sign-in message, sign it with the service wallet, then exchange the signature for a token. For the runnable version, see [Tutorial 1 Step 1](example-workflow.md#step-1-get-a-service-token). + +Issuance is **not** gated on holding a role on any lab: any wallet can mint a token for itself. The role is what makes the token useful. **Step 1 — Get the sign-in message (`getServiceSignInMessage`):** @@ -28,7 +32,7 @@ Public query — no authentication required. **Step 2 — Exchange the signature for a token (`generateServiceToken`):** -Sign the returned `message` with the service wallet, then submit the signature: +Sign the returned `message` **verbatim** with the service wallet, as a plain personal message (EIP-191 `personal_sign` — **not** typed data). The backend recomposes and verifies the same string server-side, so re-wording or re-formatting it fails with `UNAUTHENTICATED` / `reason: INVALID_SIGNATURE`. Then submit the signature: ```graphql mutation GenerateServiceToken( @@ -65,15 +69,26 @@ mutation GenerateServiceToken( | serviceName | String | Yes | Name of the service the token is issued for | | walletAddress | String | No\* | Service wallet address (required together with `messageSignature`) | | messageSignature | String | No\* | Hex-encoded signature of the sign-in message (required with `walletAddress`) | -| expiresIn | String | No | Token lifetime (e.g. `"30d"`, `"720h"`) | +| expiresIn | String | No | Token lifetime — defaults to `180d`. See the bounds below | \* `walletAddress` and `messageSignature` must be provided together for signature-based issuance. The returned `token` is the JWT to pass as `X-Service-Token` on subsequent requests. +**`expiresIn`:** + +| | | +| --- | --- | +| Default when omitted | `180d` | +| Format | ``, unit one of `s` `m` `h` `d` `w` `M` `y` — e.g. `"30d"`, `"720h"`, `"6M"`, `"1y"` | +| Minimum | 1 hour | +| Maximum | 2 years | + +`M` is a 30-day month and `y` is a 365-day year. Anything outside the bounds, or in another format, is rejected with `VALIDATION_FAILED`. Prefer a short lifetime matched to the caller's purpose — for an agent, match the expiry of its role grant — over the 180-day default. + Success ⇔ `error == null`. On failure `error` carries the catalogue `code` (e.g. `UNAUTHENTICATED` when the signature does not verify), `message` mirrors `error.message`, and `token`, `tokenId`, `expiresAt` and `createdAt` are `null` (`serviceName` may echo the name you sent) — guard for `null`, not for empty strings, and branch on `error`, never on the token fields. ## Extending Token Expiration -You can extend your service token's expiration using the `extendServiceToken` mutation: +You can extend your service token's expiration using the `extendServiceToken` mutation. **Scoped to your own tokens:** the token you present must own the `tokenId` you name. A `tokenId` belonging to another wallet returns the same `NOT_FOUND` as one that does not exist, so token existence cannot be probed. ```graphql mutation ExtendServiceToken($tokenId: String!, $expiresIn: String!) { @@ -105,6 +120,7 @@ mutation ExtendServiceToken($tokenId: String!, $expiresIn: String!) { ```bash curl -X POST https://production.graphql.api.molecule.xyz/graphql \ -H 'Content-Type: application/json' \ + -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \ -H 'X-Service-Token: YOUR_CURRENT_TOKEN' \ -d '{ "query": "mutation ExtendServiceToken($tokenId: String!, $expiresIn: String!) { extendServiceToken(tokenId: $tokenId, expiresIn: $expiresIn) { token tokenId expiresAt message error { code message requestId retryable details } } }", @@ -119,7 +135,7 @@ curl -X POST https://production.graphql.api.molecule.xyz/graphql \ ## Revoking Tokens -Revoke a service token immediately (e.g., if compromised): +Revoke a service token immediately (e.g., if compromised). Like `extendServiceToken`, this is **scoped to your own tokens** — the presented token must own the `tokenId`, and a foreign one returns `NOT_FOUND`. ```graphql mutation RevokeServiceToken($tokenId: String!) { @@ -145,6 +161,7 @@ Success ⇔ `error == null`. On failure `message` mirrors `error.message`; do no ```bash curl -X POST https://production.graphql.api.molecule.xyz/graphql \ -H 'Content-Type: application/json' \ + -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \ -H 'X-Service-Token: YOUR_CURRENT_TOKEN' \ -d '{ "query": "mutation RevokeServiceToken($tokenId: String!) { revokeServiceToken(tokenId: $tokenId) { tokenId message revokedAt error { code message requestId retryable details } } }", diff --git a/api-reference/tokenization-api.md b/api-reference/tokenization-api.md index d3b6464..1ae7117 100644 --- a/api-reference/tokenization-api.md +++ b/api-reference/tokenization-api.md @@ -19,15 +19,12 @@ All Tokenization API mutations require a consumer credential. ### Obtaining a Consumer Credential -To request a consumer credential and access to the full technical integration guide: +The consumer credential is the same one every Molecule API uses — if you already have one, you are ready. To request one, use the [credential request template](getting-started/README.md#1-a-mol-consumer-credential-the-one-manual-step) on our [Discord community](https://t.co/L0VEiy4Bjk), adding your expected tokenization volume. -1. Join our [Discord community](https://t.co/L0VEiy4Bjk) -2. Contact the Molecule team with: - * Your use case and project details - * Expected tokenization volume -3. You'll receive: - * **Consumer credential** (`mol__`) for authentication - * **Technical Integration Guide** with complete code examples and ABI files +You'll receive: + +* **Consumer credential** (`mol__`) for authentication +* **Technical Integration Guide** with complete code examples and ABI files (not yet published; requested with the credential) ### Using Your Consumer Credential @@ -170,7 +167,7 @@ The terms message is reconstructed onchain by `OclTermsPermissioner.specificTerm ### For Lab Tokenization -* **Consumer credential**: Obtained from Molecule team +* **Consumer credential**: the same credential every Molecule API uses — see [Getting Started](getting-started/README.md#1-a-mol-consumer-credential-the-one-manual-step) * **Lab Control**: The caller must be the Lab's controller (the current LabNFT owner) * **Base ETH Balance**: Sufficient for gas fees on Base * **Token Details**: Symbol and initial supply amount (the token name is derived automatically) diff --git a/api-reference/x402-gateway.md b/api-reference/x402-gateway.md index 6c4ae2c..ee2f5b2 100644 --- a/api-reference/x402-gateway.md +++ b/api-reference/x402-gateway.md @@ -25,12 +25,27 @@ Use the standard [Labs API](labs-api/README.md) with a service token when you ha --- +## Gateway base URLs + +| Environment | Base URL | Network | Asset | +| ----------- | -------- | ------- | ----- | +| **Staging** | `https://0go1j7o645.execute-api.eu-central-2.amazonaws.com/prod` | Base Sepolia (`eip155:84532`) | [USDC `0x036CbD…dCF7e`](https://sepolia.basescan.org/address/0x036CbD53842c5426634e7929541eC2318f3dCF7e) | +| **Production** | `https://0qb5gyw72f.execute-api.eu-central-2.amazonaws.com/prod` | Base (`eip155:8453`) | [USDC `0x833589…02913`](https://basescan.org/address/0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) | + +Endpoints are `POST {base}/x402/labs/{mutation}`. Note the `/prod` stage segment on the base URL: it is part of the path you call. (The `resource.url` echoed back inside the `402` challenge omits it — call the URL you built, not the one in the challenge.) + +Testnet USDC for the staging gateway comes from the [Circle faucet](https://faucet.circle.com/) — select **Base Sepolia**. You also need a little Base Sepolia ETH for gas if you are doing anything onchain alongside; the payment itself is signed, not sent, so it costs the payer no gas. + +Both base URLs are also what the [Molecule Skill](../ai-tooling/molecule-skill.md) plugin expects in `X402_GATEWAY_URL`. + +--- + ## Endpoints The gateway exposes one HTTP endpoint per allow-listed mutation. All endpoints accept `POST` with a JSON body containing a GraphQL mutation. ``` -POST /x402/labs/{mutation} +POST {base}/x402/labs/{mutation} ``` | Path | Wraps mutation | Purpose | @@ -80,8 +95,8 @@ The gateway implements the standard x402 three-phase flow: **verify → serve │◀──────────────────────────────────────┤ │ ``` -1. **402 challenge** — The gateway returns an x402-standard payment-requirements response describing network, asset, price, and payTo address. -2. **Sign** — The client signs an EIP-3009 `transferWithAuthorization` (or Permit2) for the quoted amount to `X402_PAY_TO_ADDRESS` on the configured network. +1. **402 challenge** — The gateway responds `402` with the x402 payment requirements — network, asset, amount, and `payTo` address — as **base64-encoded JSON in the `payment-required` response header**. The response *body* is only `{"isSuccess":false,"message":"Payment required"}`; the requirements are not in it. See [Reading the 402 challenge](#reading-the-402-challenge). +2. **Sign** — The client signs an EIP-3009 `transferWithAuthorization` (or Permit2) for the quoted amount to the challenge's `payTo` on the quoted network. 3. **Retry with payment** — The signed authorization is submitted as a base64 JSON header under any of `Payment-Signature`, `X-Payment`, or `Payment`. 4. **Verify** — The gateway calls the Coinbase facilitator's `/verify` endpoint. On failure it returns `402` with the original requirements. 5. **Serve** — The gateway mints a scoped, short-lived JWT service token (`allowedMutations: [mutation]`, `authMethod: "x402"`, `ttl = X402_TOKEN_TTL_SECONDS`, default `300s`) with the payer wallet as `adminAddress`, then forwards the GraphQL mutation to AppSync using that token. @@ -99,6 +114,104 @@ If none resolves to a valid address the gateway returns `400`. Source: `lambda/x --- +## Reading the 402 challenge + +The price is **quoted per request** — always read it from the challenge rather than hardcoding an amount. + +**Step 1 — call without a payment header:** + +```bash +curl -i -X POST \ + https://0go1j7o645.execute-api.eu-central-2.amazonaws.com/prod/x402/labs/createAnnouncement \ + -H 'Content-Type: application/json' \ + -d '{ + "query": "mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!) { createAnnouncement(oclId: $oclId, headline: $headline, body: $body) { message error { code message requestId retryable details } } }", + "variables": { "oclId": "0x0101…", "headline": "Milestone 1 complete", "body": "…" } + }' +``` + +**Step 2 — read the `payment-required` header, not the body:** + +```http +HTTP/2 402 +content-type: application/json +payment-required: eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOiJQYXltZW50IHJlcXVpcmVkIiw… + +{"isSuccess":false,"message":"Payment required"} +``` + +Base64-decode the header: + +```bash +curl -sD - -o /dev/null -X POST "$URL" -H 'Content-Type: application/json' -d "$BODY" \ + | grep -i '^payment-required:' | cut -d' ' -f2- | tr -d '\r' | base64 -d | jq +``` + +```json +{ + "x402Version": 2, + "error": "Payment required", + "resource": { + "url": "https://0go1j7o645.execute-api.eu-central-2.amazonaws.com/x402/labs/createAnnouncement", + "description": "x402 payment for createAnnouncement", + "mimeType": "" + }, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:84532", + "amount": "10000", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "payTo": "0xb016Eb733479874b67c6BeC2470e86a64b33AD76", + "maxTimeoutSeconds": 300, + "extra": { "name": "USDC", "version": "2" } + } + ] +} +``` + +`amount` is in the asset's smallest unit — USDC has 6 decimals, so `"10000"` is **$0.01**. That is the price on both staging and production today, for every allow-listed mutation. `extra.name` / `extra.version` are the EIP-712 domain values for the token's `transferWithAuthorization`. + +**Step 3 — sign the authorization and retry.** Build an EIP-3009 `transferWithAuthorization` for `amount` to `payTo` on `network`, wrap it in the x402 payment payload, base64 it, and re-send the *same* request with the header: + +```javascript +async function callX402(base, mutation, query, variables, wallet) { + const url = `${base}/x402/labs/${mutation}`; + const body = JSON.stringify({ query, variables }); + const init = { method: "POST", headers: { "content-type": "application/json" }, body }; + + // 1. Challenge + const challenge = await fetch(url, init); + if (challenge.status !== 402) return challenge.json(); // already paid / different error + + const raw = challenge.headers.get("payment-required"); + if (!raw) throw new Error("402 without a payment-required header"); + const requirements = JSON.parse(Buffer.from(raw, "base64").toString("utf8")); + const accepts = requirements.accepts[0]; + + // 2. Sign for EXACTLY the quoted amount, asset, network and payTo. + // signX402Payment depends on your stack (viem, ethers, CDP SDK). + const paymentHeader = await signX402Payment(accepts, wallet); + + // 3. Retry the same request with the payment attached + const result = await fetch(url, { + ...init, + headers: { ...init.headers, "payment-signature": paymentHeader }, + }); + return result.json(); +} +``` + +The signed payload is accepted under any of `Payment-Signature`, `X-Payment`, or `Payment`. + +**Step 4 — read the result as an ordinary GraphQL response.** A `200` body is the AppSync response verbatim; success is `error == null`, exactly as on the Labs API. + +{% hint style="warning" %} +**Authorization is still checked after payment, and settlement does not wait for the mutation to succeed.** Payment buys a short-lived service token for the payer wallet; it does not grant the payer a role. A `createLab` for an OCL the payer does not own, or a write into a lab the payer has no Contributor role on, comes back `200` with `error.code: "UNAUTHORIZED"` — and because settlement is triggered by the upstream `2xx`, you have paid for it. Validate the target lab and your role on it (`listLabMembers`) **before** signing. +{% endhint %} + +--- + ## Request Format ```http @@ -138,15 +251,17 @@ X402_PRICE_ e.g. X402_PRICE_CREATEANNOUNCEMENT X402_PRICE_DEFAULT fallback when no per-mutation price is set ``` -Values are interpreted as USDC amounts (e.g. `"2.50"` = $2.50). Prices are environment-configured — the authoritative price for a given mutation is the one quoted in the `402` payment-requirements response, so clients should always read it from the challenge rather than hardcoding amounts. +Values are interpreted as USDC amounts (e.g. `"2.50"` = $2.50). Prices are environment-configured — the authoritative price for a given mutation is the one quoted in the `402` challenge, so clients should always [read it from the challenge](#reading-the-402-challenge) rather than hardcoding amounts. As of 2026-08-27 every allow-listed mutation quotes **$0.01** on both staging and production. | Variable | Default | Purpose | | -------------------------------- | ------------------------------------------------------- | -------------------------------------------------------- | | `X402_NETWORK` | `base` (prod) / `base-sepolia` (non-prod) | CAIP-2 network (`base` → `eip155:8453`) | -| `X402_PAY_TO_ADDRESS` | — | Wallet that receives settlement | +| `X402_ASSET` | Base USDC (prod) / Base Sepolia USDC (non-prod) | Settlement asset — see [base URLs](#gateway-base-urls) | +| `X402_PAY_TO_ADDRESS` | `0xb016Eb733479874b67c6BeC2470e86a64b33AD76` | Wallet that receives settlement (echoed as `payTo`) | | `X402_FACILITATOR_URL` | `https://api.cdp.coinbase.com/platform/v2/x402` | Facilitator base URL | -| `X402_PRICE_*` / `X402_PRICE_DEFAULT` | environment-configured | Per-mutation price in USDC (quoted in the 402 challenge) | +| `X402_PRICE_*` / `X402_PRICE_DEFAULT` | `0.01` | Per-mutation price in USDC (quoted in the 402 challenge) | | `X402_TOKEN_TTL_SECONDS` | `300` | Lifetime of the minted service token | +| `X402_MAX_TIMEOUT_SECONDS` | `60` | Upstream request budget | Facilitator authentication uses Coinbase CDP API keys (`CDP_API_KEY_ID_SECRET_ARN` / `CDP_API_KEY_SECRET_SECRET_ARN` in Secrets Manager, or `CDP_API_KEY_ID` / `CDP_API_KEY_SECRET` in local mode) to sign the `/verify`, `/settle`, and `/supported` requests. @@ -169,43 +284,22 @@ Each minted service token has a unique `jti` claim, so requests are not idempote ## Agent Usage Pattern -An autonomous agent typically wraps each gateway call in a helper: - -```ts -// Pseudocode — actual wallet signing depends on your stack (viem, ethers, CDP SDK). -async function callX402(mutation: string, query: string, variables: any) { - const url = `${GATEWAY_BASE}/x402/labs/${mutation}`; - - // 1. 402 challenge - const challenge = await fetch(url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ query, variables }), - }); +The runnable helper is in [Reading the 402 challenge](#reading-the-402-challenge) above — the one detail agents get wrong is reading the requirements from the response *body* instead of the `payment-required` *header*. - const paymentRequirements = await challenge.json(); - const paymentHeader = await signX402Payment(paymentRequirements, agentWallet); +Beyond that, three habits: - // 2. Retry with payment - const result = await fetch(url, { - method: "POST", - headers: { - "content-type": "application/json", - "payment-signature": paymentHeader, - }, - body: JSON.stringify({ query, variables }), - }); - - return result.json(); -} -``` +* **Read the price every time.** It is quoted per request and per mutation; nothing guarantees it stays at $0.01. +* **Check authorization before paying.** Confirm the lab exists and the payer wallet holds the role the mutation needs (`labWithDataRoomAndFiles`, `listLabMembers` — both public and free) before signing anything. Payment does not grant a role. +* **Treat a settlement failure as "not charged yet."** Re-sign rather than assuming the transfer went through — see [Idempotency](#idempotency). -See the [Developers / AI Agents guide](../user-guides/developers-ai-agents.md) for end-to-end agent integration patterns, and the [Labs API reference](labs-api/README.md) for the full GraphQL signatures of each gated mutation. +If you would rather not implement the handshake at all, the [Molecule Skill](../ai-tooling/molecule-skill.md) plugin's `x402_pay` tool does the whole flow in one call. See the [Developers / AI Agents guide](../user-guides/developers-ai-agents.md) for broader integration patterns, and the [Labs API reference](labs-api/README.md) for the full GraphQL signatures of each gated mutation. --- ## Related +- [Getting Started](getting-started/README.md) — which lane to pick, prerequisites, costs +- [Molecule Skill](../ai-tooling/molecule-skill.md) — `x402_pay` does this handshake in one tool call - [Labs API](labs-api/README.md) — full mutation signatures and variable types - [Developers / AI Agents](../user-guides/developers-ai-agents.md) — agent integration guide - [x402 specification](https://www.x402.org/) diff --git a/user-guides/developers-ai-agents.md b/user-guides/developers-ai-agents.md index b0235c2..048751e 100644 --- a/user-guides/developers-ai-agents.md +++ b/user-guides/developers-ai-agents.md @@ -2,12 +2,13 @@ description: >- Build on Molecule: Integrate Labs, extend the protocol, and deploy autonomous research agents -hidden: true icon: robot --- # Developers/AI Agents +> **Want to start writing code now?** Go to [🚀 Getting Started](../api-reference/getting-started/README.md) — it picks your lane and gets you to a lab with a file in it in about ten minutes. This page is the narrative map of every integration surface, for when you need to decide *what* to build rather than *how* to make the first call. + ### Who This Guide Is For You're a developer building on the Molecule ecosystem. You might be integrating Lab data into a front-end, writing a smart contract module that adds new capabilities to Labs, deploying an AI agent that operates on research data, or building a tool that queries ecosystem state for analytics or trading. This guide maps out the integration surfaces, explains what's available today versus what's on the roadmap, and shows you the fastest path to a working integration for each use case. @@ -56,7 +57,7 @@ The simplest agent integration is read-only: query a Lab's data room for files, A write-enabled agent goes further: it reads data, performs analysis, and writes results back as new files in the Lab's data room. This requires both a consumer credential and a service token. Two paths to a service token: -* **Long-lived service token** — mint one via the `generateServiceToken` mutation (wallet signature or Privy session), or contact the Molecule team. The token is a JWT tied to your wallet; write authorization is resolved from that wallet's onchain role on the target Lab. +* **Long-lived service token** — the agent mints its own via the `generateServiceToken` mutation, by signing a message with its wallet (a Privy session works too). No provisioning request. The token is a JWT tied to that wallet; write authorization is resolved per request from the wallet's onchain role on the target Lab. Content writes need **Contributor**. The end-to-end version — agent wallet, human grants the role, agent self-issues and uploads — is [Tutorial 3](../api-reference/labs-api/example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app). * **Pay-per-call via the** [**x402 Gateway**](../api-reference/x402-gateway.md) — for agents that serve external users, charge per request, or don't have pre-provisioned credentials. The gateway settles a USDC payment on Base per call and mints a short-lived (default 5-minute) service token scoped to one mutation. Available for `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createAnnouncement`, `createLab`, `generateDataEncryptionKey`, and `decryptDataKey`. The three-step upload flow (initiate, PUT, finalize) lets you write any file type with metadata including descriptions, tags, categories, and searchable content text. If the file should be confidential, first request a key via `generateDataEncryptionKey` — the backend returns a one-shot plaintext DEK (plus its encrypted form) you use to encrypt locally before upload, attaching the encryption metadata on finish. Every file the agent writes becomes a permanent, versioned record in the Lab's history. @@ -99,4 +100,6 @@ If you're building an AI research agent, start with BioAgents. Fork the reposito If you just want to give an AI assistant Molecule context, add the MCP server URL to your client's config and you're done in sixty seconds. -For consumer credentials, service tokens, attestation requests, or any integration support, reach out on the Molecule Discord. +If you want an AI coding agent to run the whole Lab workflow for you, install the [Molecule Skill](../ai-tooling/molecule-skill.md) plugin — the skill plus MCP server that wraps every network, onchain and cryptographic step as one typed tool call. + +Service tokens are self-issued, and every endpoint, gateway URL and contract address is published — see [Getting Started](../api-reference/getting-started/README.md). Reach out on the Molecule Discord for a consumer credential, a module attestation request, or any integration support. From 642c9230be1d21e7c957629cf856743120eb24c3 Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Fri, 28 Aug 2026 10:27:51 +0100 Subject: [PATCH 02/16] chore(docs): split tutorials --- .github/prompts/docs-sync.md | 8 +- SUMMARY.md | 5 +- ai-tooling/molecule-skill.md | 2 +- api-reference/README.md | 6 +- api-reference/authentication.md | 6 +- api-reference/changelog.md | 4 +- api-reference/getting-started/README.md | 128 +- api-reference/getting-started/for-agents.md | 8 +- .../tutorial-1-public-upload.md | 580 ++++++ .../tutorial-2-encrypted-upload.md | 648 +++++++ .../tutorial-3-agent-access.md | 417 +++++ .../getting-started/tutorial-4-announce.md | 239 +++ api-reference/labs-api/README.md | 8 +- api-reference/labs-api/example-workflow.md | 1577 +---------------- api-reference/labs-api/files.md | 4 +- api-reference/labs-api/lab-management.md | 4 +- api-reference/labs-api/service-tokens.md | 2 +- user-guides/developers-ai-agents.md | 2 +- 18 files changed, 2050 insertions(+), 1598 deletions(-) create mode 100644 api-reference/getting-started/tutorial-1-public-upload.md create mode 100644 api-reference/getting-started/tutorial-2-encrypted-upload.md create mode 100644 api-reference/getting-started/tutorial-3-agent-access.md create mode 100644 api-reference/getting-started/tutorial-4-announce.md diff --git a/.github/prompts/docs-sync.md b/.github/prompts/docs-sync.md index 27b332e..672af3e 100644 --- a/.github/prompts/docs-sync.md +++ b/.github/prompts/docs-sync.md @@ -53,9 +53,13 @@ touches one of its source paths. | -- | -- | | `api-reference/README.md` | `graphql/schemas/*.graphql` (surface inventory only), `lib/shared-api-stack.ts` | | `api-reference/getting-started/README.md` | The onboarding entry point. `lib/desci-api-app-sync/constructs/api-config.ts` (endpoints, introspection, depth limit), `lambda/x402-gateway-lambda/**` (prices/base URLs quoted in the cost table), `lambda/appsync-authorizer-lambda/**` (credential shape). **Contains verified live values** — `mintFeeWei()` readings, x402 prices, gateway URLs — each stamped with the date it was checked. Never update one of those from a diff alone: if the diff suggests a value changed, flag it in the PR body and let a human re-verify against the deployed environment. | -| `api-reference/getting-started/for-agents.md` | Condensed mirror of Tutorial 1 plus the error contract. Sources: `graphql/schemas/ip-hubs.graphql`, `graphql/schemas/api-error.graphql`, `graphql/schemas/encryption.graphql`, `lambda/appsync-resolver-labs-lambda/**` (per-mutation role gates). **Must stay consistent with `example-workflow.md`** — a change to the flow touches both pages or neither. | +| `api-reference/getting-started/for-agents.md` | Condensed mirror of Tutorial 1 plus the error contract. Sources: `graphql/schemas/ip-hubs.graphql`, `graphql/schemas/api-error.graphql`, `graphql/schemas/encryption.graphql`, `lambda/appsync-resolver-labs-lambda/**` (per-mutation role gates). **Must stay consistent with the tutorial pages** — a change to the flow touches this page and the relevant tutorial, or neither. | +| `api-reference/getting-started/tutorial-1-public-upload.md` | Tutorial 1 — self-issue a token, mint, `createLab`, three-call public upload, verify. Sources: `graphql/schemas/ip-hubs.graphql`; `lambda/appsync-resolver-labs-lambda/**` for `generateServiceToken`, `createLab`, `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile` and their authorization gates; `services/token-manager-service.ts`; `lib/utils/token-expiration.ts` (the `expiresIn` default and bounds). Every code block is expected to be runnable — a signature change here is a real breakage, so correct it and say so in the PR body. Steps 1–3 are duplicated by reference in Tutorials 2 and 3: **fix all copies or none.** | +| `api-reference/getting-started/tutorial-2-encrypted-upload.md` | Tutorial 2 — DEK, local AES-256-GCM, `accessControlConditions`, `encryptionMetadata`, `decryptDataKey` round trip. Sources: `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts`, the encryption resolvers and condition evaluator in `lambda/appsync-resolver-labs-lambda/**`, `lib/encryption-stack.ts`. `encryptionSystem` is backend-set and must stay "echo it verbatim" — never let a literal be hardcoded here. | +| `api-reference/getting-started/tutorial-3-agent-access.md` | Tutorial 3 — a Contributor-role agent writing into a lab a human owns. Sources: the per-mutation service-token gates in `lambda/appsync-resolver-labs-lambda/**` (`authorizeServiceMember` = Contributor, `authorizeServiceAdmin` = Owner) and `services/auth-service.ts`. **If a content-write mutation moves between those two gates, this page's role claims and its Owner-only list are wrong** — that is the single highest-value thing to check here. Role grants themselves are onchain (`AccessResolver`), out of this repo. | +| `api-reference/getting-started/tutorial-4-announce.md` | Tutorial 4 — `createAnnouncement` with `attachments`, verified off `labActivity`. Sources: the `createAnnouncement` resolver and `labActivity` in `lambda/appsync-resolver-labs-lambda/**`, `graphql/schemas/ip-hubs.graphql`. | | `api-reference/labs-api/README.md` | `graphql/schemas/ip-hubs.graphql`, `lambda/appsync-resolver-labs-lambda/**` | -| `api-reference/labs-api/example-workflow.md` | The tutorials page (H1 "Tutorials"; the filename is kept for inbound links). The end-to-end flow across four tutorials: `graphql/schemas/ip-hubs.graphql`, `graphql/schemas/encryption.graphql`, `lambda/appsync-resolver-labs-lambda/**` (especially the per-mutation authorization gates — `authorizeServiceAdmin` = Owner, `authorizeServiceMember` = Contributor), `services/token-manager-service.ts`, `lib/utils/token-expiration.ts` (the `expiresIn` bounds). Every code block is expected to be runnable, so a signature change here is a real breakage — correct it and say so in the PR body. | +| `api-reference/labs-api/example-workflow.md` | **A hidden signpost, not a content page.** The tutorials moved to `getting-started/tutorial-*.md`; this file only exists so the already-published `/api-reference/labs-api/example-workflow` URL keeps resolving. No source paths — never document API behaviour here. Edit it only to fix a link, and never re-add it to `SUMMARY.md`. | | `api-reference/labs-api/lab-management.md` | `createLab`, `updateLabNftMetadata`, `generateLabImageUploadUrl` in `lambda/appsync-resolver-labs-lambda/**`; `lambda/labnft-metadata-lambda/**`; `lambda/ocl-processor/**` | | `api-reference/labs-api/files.md` | file operations in `lambda/appsync-resolver-labs-lambda/**` (`initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata`, `moveEntry`), `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts` | | `api-reference/labs-api/browse-and-search.md` | `labs`, `searchLabs`, `labWithDataRoomAndFiles`, `dataRoomFile`, `activities`, `labActivity` resolvers; `graphql/schemas/onchain-activity.graphql` | diff --git a/SUMMARY.md b/SUMMARY.md index 4e80536..201379e 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -35,10 +35,13 @@ * [📡 API Overview](api-reference/README.md) * [🚀 Getting Started](api-reference/getting-started/README.md) + * [Tutorial 1: Create a lab and upload a public file](api-reference/getting-started/tutorial-1-public-upload.md) + * [Tutorial 2: Upload an encrypted file](api-reference/getting-started/tutorial-2-encrypted-upload.md) + * [Tutorial 3: Give your agent access to a lab](api-reference/getting-started/tutorial-3-agent-access.md) + * [Tutorial 4: Announce the dataset](api-reference/getting-started/tutorial-4-announce.md) * [🤖 For Agents: One-Pager](api-reference/getting-started/for-agents.md) * [🔐 Authentication](api-reference/authentication.md) * [⚙️ Labs API](api-reference/labs-api/README.md) - * [Tutorials](api-reference/labs-api/example-workflow.md) * [Lab Management](api-reference/labs-api/lab-management.md) * [Files](api-reference/labs-api/files.md) * [Browse & Search](api-reference/labs-api/browse-and-search.md) diff --git a/ai-tooling/molecule-skill.md b/ai-tooling/molecule-skill.md index 8d103b8..eb11823 100644 --- a/ai-tooling/molecule-skill.md +++ b/ai-tooling/molecule-skill.md @@ -251,7 +251,7 @@ This lists every tool and exercises the pure-compute ones (encryption round-trip ### Related Pages * [Getting Started](../api-reference/getting-started/README.md) — the four lanes, prerequisites and costs; this plugin is the "agent runner" lane -* [Tutorials](../api-reference/labs-api/example-workflow.md) — the same workflow as raw GraphQL, if you want to see the calls underneath +* [Tutorials](../api-reference/getting-started/README.md) — the same workflow as raw GraphQL, if you want to see the calls underneath * [Molecule Labs](../technical-deep-dive/onchain-lab.md) — what an Onchain Lab is * [Roles & Permissions](../technical-deep-dive/roles-and-permissions.md) — the role model used by access conditions * [Data Privacy & Access](../technical-deep-dive/data/data-privacy-and-access.md) — encryption and access evaluation in depth diff --git a/api-reference/README.md b/api-reference/README.md index 9125531..838add5 100644 --- a/api-reference/README.md +++ b/api-reference/README.md @@ -27,7 +27,7 @@ Upload files to lab datarooms for secure, decentralized research data storage, a * **Queries** (read operations): consumer credential only — public. * **Write mutations** (write operations): consumer credential plus **either** a Service Token (`X-Service-Token`) **or** a Privy user session (`Authorization` + `x-wallet-address`) — the two paths are interchangeable. Exceptions: `extendServiceToken` and `revokeServiceToken` are Service-Token-only, and `generateServiceToken` bootstraps a token from a Privy session or wallet signature. -[View Labs API Documentation →](labs-api/README.md) · [Tutorials →](labs-api/example-workflow.md) +[View Labs API Documentation →](labs-api/README.md) · [Tutorials →](getting-started/README.md) *** @@ -121,8 +121,8 @@ The full quickstart — prerequisites, costs, and a ten-minute path to a lab wit | --------------------------------------------- | ------------------------------------------------- | | Get from zero to a lab with a file in it | [Getting Started](getting-started/README.md) | | Point an AI agent at this API | [Agent one-pager](getting-started/for-agents.md) · [Molecule Skill](../ai-tooling/molecule-skill.md) | -| Upload files to a Lab dataroom | [Labs API](labs-api/README.md) · [Tutorials](labs-api/example-workflow.md) | -| Let an agent write into a lab someone else owns | [Tutorial 3](labs-api/example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app) | +| Upload files to a Lab dataroom | [Labs API](labs-api/README.md) · [Tutorials](getting-started/README.md) | +| Let an agent write into a lab someone else owns | [Tutorial 3](getting-started/tutorial-3-agent-access.md) | | Tokenize a Lab into IP Tokens (IPTs) | [Tokenization API](tokenization-api.md) | | Pay per call without a long-lived token | [x402 Gateway](x402-gateway.md) | | Browse IP-NFTs and IPTs (legacy) | [IPNFT API (Deprecated)](ipnft-api-deprecated.md) | diff --git a/api-reference/authentication.md b/api-reference/authentication.md index e20a25a..9f88354 100644 --- a/api-reference/authentication.md +++ b/api-reference/authentication.md @@ -29,7 +29,7 @@ mol__ Send it as the `Authorization` header value directly — **no `Bearer` prefix**: `Authorization: mol__`. This differs from the Privy path below, which does use `Bearer`; adding `Bearer` in front of a consumer credential makes the request fail authentication. Treat the entire string as a secret — it is not split into a public/private part. Credentials are per environment: a staging credential does not authenticate against production. -You do **not** need to ask anyone for a Service Token. Write mutations need one, and you mint it yourself by signing a message with your wallet — see [Service Tokens](labs-api/service-tokens.md#obtaining-a-token), or [Tutorial 1 Step 1](labs-api/example-workflow.md#step-1-get-a-service-token) for the runnable version. +You do **not** need to ask anyone for a Service Token. Write mutations need one, and you mint it yourself by signing a message with your wallet — see [Service Tokens](labs-api/service-tokens.md#obtaining-a-token), or [Tutorial 1 Step 1](getting-started/tutorial-1-public-upload.md#step-1-get-a-service-token) for the runnable version. ## Authentication Headers @@ -137,7 +137,7 @@ Both are scoped to the caller's **own** tokens: the token presented must own the ### Obtaining a Service Token -Self-service, two calls, no human in the loop. Full reference with parameters and failure modes: [Service Tokens](labs-api/service-tokens.md#obtaining-a-token). Runnable: [Tutorial 1 Step 1](labs-api/example-workflow.md#step-1-get-a-service-token). +Self-service, two calls, no human in the loop. Full reference with parameters and failure modes: [Service Tokens](labs-api/service-tokens.md#obtaining-a-token). Runnable: [Tutorial 1 Step 1](getting-started/tutorial-1-public-upload.md#step-1-get-a-service-token). 1. **`getServiceSignInMessage(walletAddress, serviceName)`** — a public query returning the deterministic message to sign. 2. **Sign it verbatim** with the wallet, as a plain personal message (EIP-191 `personal_sign` — **not** typed data). Re-wording or re-formatting the string breaks verification. @@ -164,7 +164,7 @@ On every request, the API resolves what the token's wallet may do on the lab nam * **A role granted after the token was issued takes effect without re-issuing it.** Likewise a revoked role stops the token on that lab immediately, while leaving it valid elsewhere. * **A token for a wallet with no role authenticates but cannot write.** You will see `UNAUTHENTICATED` become `UNAUTHORIZED`: the caller is known, just not permitted. -This is why an agent can be handed access to a lab it does not own — the human grants the agent's wallet a role, and the agent's own token starts working on that lab. See [Tutorial 3](labs-api/example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app). +This is why an agent can be handed access to a lab it does not own — the human grants the agent's wallet a role, and the agent's own token starts working on that lab. See [Tutorial 3](getting-started/tutorial-3-agent-access.md). > Because role state reaches the API through an event indexer, there is a short window after a role grant confirms onchain in which a write can still return `UNAUTHORIZED`. Retry with backoff; re-issuing the token does not help. diff --git a/api-reference/changelog.md b/api-reference/changelog.md index b81b495..c948d5a 100644 --- a/api-reference/changelog.md +++ b/api-reference/changelog.md @@ -26,7 +26,7 @@ The six content-write mutations — `initiateCreateOrUpdateFile`, `finishCreateO This is what unblocks the "human owns the lab, agent contributes to it" flow: the owner grants the agent's wallet Contributor, and the agent's self-issued token can write. Owner-gated surfaces are unchanged — `createLab`, `updateLabNftMetadata` and `generateLabImageUploadUrl` still require ownership. -**Migration:** None — this is a widening. Note that role state reaches the API through an event indexer, so a write can still return `UNAUTHORIZED` for a few seconds after a grant confirms onchain; retry with backoff rather than re-issuing the token. Walkthrough: [Tutorial 3](labs-api/example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app). +**Migration:** None — this is a widening. Note that role state reaches the API through an event indexer, so a write can still return `UNAUTHORIZED` for a few seconds after a grant confirms onchain; retry with backoff rather than re-issuing the token. Walkthrough: [Tutorial 3](getting-started/tutorial-3-agent-access.md). ### Backend credential stores confined to the platform network @@ -55,7 +55,7 @@ Signing the assignment agreement is **not** a precondition for `createLab`, for The `legalAgreement*` operations remain in the schema and are unchanged, but they have been removed from every onboarding and reference flow, and [Legal Agreements](labs-api/legal-agreements.md) is out of the site navigation. Do not build a new integration around them. -**Migration:** Delete any agreement-signing step from your workflow — it does nothing. If you branch on an agreement status before writing, remove the branch. Nothing in the API changed; only what is required of you did. The onboarding tutorials no longer include the step: [Tutorials](labs-api/example-workflow.md). +**Migration:** Delete any agreement-signing step from your workflow — it does nothing. If you branch on an agreement status before writing, remove the branch. Nothing in the API changed; only what is required of you did. The onboarding tutorials no longer include the step: [Tutorials](getting-started/README.md). ### Staging introspection is the supported way to get the schema diff --git a/api-reference/getting-started/README.md b/api-reference/getting-started/README.md index 403920a..8828b65 100644 --- a/api-reference/getting-started/README.md +++ b/api-reference/getting-started/README.md @@ -7,9 +7,16 @@ icon: rocket # 🚀 Getting Started -This is the entry point to the Molecule API. It does three things: helps you **pick a lane**, lists the **two prerequisites** you actually need, and walks a **ten-minute quickstart** that ends with a lab you can see. +This is the entry point to the Molecule API. It helps you **pick a lane**, lists the **two prerequisites** you actually need, and hands you a **ten-minute quickstart** that ends with a lab you can see. The four tutorials underneath it take the same ground step by step. -Everything below runs against **staging** (Base Sepolia, testnet funds). Nothing here spends real money. The [staging → production swap table](../labs-api/example-workflow.md#running-in-production) is at the end of the tutorials. +Everything here runs against **staging** (Base Sepolia, testnet funds). Nothing spends real money. This page also holds the two things every tutorial shares: the [shared setup block](#shared-setup) and the [staging → production swap table](#running-in-production). + +| | | +| --- | --- | +| [**Tutorial 1**](tutorial-1-public-upload.md) | Create a lab and upload a public file — **start here** | +| [**Tutorial 2**](tutorial-2-encrypted-upload.md) | Upload an encrypted file, verified with a decrypt round trip | +| [**Tutorial 3**](tutorial-3-agent-access.md) | Give your agent access to a lab you created in the app | +| [**Tutorial 4**](tutorial-4-announce.md) | Announce the dataset | *** @@ -20,9 +27,9 @@ Four ways to write to a Lab. They are not ranked — pick by who is calling. | Your situation | Lane | Start here | | -------------- | ---- | ---------- | | **I run an AI coding agent** (Claude Code, Codex, Cursor) and want it to do the whole workflow | **Molecule Skill plugin** — a skill + MCP server that wraps every network, onchain and crypto operation as one tool call | [Molecule Skill](../../ai-tooling/molecule-skill.md) | -| **I'm scripting against the API** in Node/TypeScript and want to see the raw calls | **Raw GraphQL + viem** — self-issue a service token, mint, upload | [Tutorial 1](../labs-api/example-workflow.md#tutorial-1-create-a-lab-and-upload-a-public-file) | +| **I'm scripting against the API** in Node/TypeScript and want to see the raw calls | **Raw GraphQL + viem** — self-issue a service token, mint, upload | [Tutorial 1](tutorial-1-public-upload.md) | | **I have no credential, or I want to pay per call** instead of holding a long-lived token | **x402 gateway** — settle USDC on Base per request, no service token to provision | [x402 Gateway](../x402-gateway.md) | -| **I already made my lab in the app** (email sign-in, no wallet) **and now I want my agent writing into it** | **Agent-as-Contributor** — the human grants a role, the agent issues its own token | [Tutorial 3](../labs-api/example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app) | +| **I already made my lab in the app** (email sign-in, no wallet) **and now I want my agent writing into it** | **Agent-as-Contributor** — the human grants a role, the agent issues its own token | [Tutorial 3](tutorial-3-agent-access.md) | The lanes compose. A common shape is the plugin lane for the workflow plus x402 for the paid mutations, which is exactly what the plugin does by default. @@ -101,9 +108,69 @@ claude --plugin-dir /path/to/mol-labs-plugin *** +## Shared setup + +Every environment-specific value lives in this one block; swapping to production is a matter of replacing it with the table in [Running in Production](#running-in-production). + +```javascript +import { baseSepolia } from "viem/chains"; // production: `base` + +// ---- Staging (Base Sepolia) config — see "Running in Production" to swap ---- +const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql"; +const CHAIN = baseSepolia; +const FACTORY_ADDRESS = "0xd629FE2310b4309a212495F10A47f8436dcEfD90"; // OnChainLabFactory +const LABNFT_ADDRESS = "0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28"; // LabNFT (proxy) +const ACCESS_RESOLVER_ADDRESS = "0x5493F472602C87318EA5Eff753cDD593bf9bF559"; // AccessResolver +const ACCESS_CONDITION_CHAIN = "baseSepolia"; // the `chain` string inside accessControlConditions +const LAB_APP_URL = "https://testnet.labs.molecule.xyz"; // production: https://labs.molecule.xyz + +const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; // mol__ — no "Bearer" prefix +const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY; + +// Set once Step 1 exchanges a wallet signature for a token. Every call after +// that automatically starts sending it; public queries (like Step 1's own +// sign-in-message lookup) work fine without it. +let serviceToken; + +async function graphql(query, variables) { + // Authorization is always required. X-Service-Token is added once we have + // one — omit it entirely rather than sending an empty header. + const headers = { "Content-Type": "application/json", Authorization: CONSUMER_CREDENTIAL }; + if (serviceToken) headers["X-Service-Token"] = serviceToken; + + const res = await fetch(GRAPHQL_URL, { + method: "POST", + headers, + body: JSON.stringify({ query, variables }), + }); + const { data, errors } = await res.json(); + // Queries report failure here: a top-level errors[] entry whose errorType is + // the catalogue code. Mutations report expected failures in-band instead (see + // assertOk); a top-level entry on a mutation means a transport/infrastructure + // failure or an invalid request document. + if (errors) throw new Error(JSON.stringify(errors)); + return data; +} + +// Mutations report failure in-band: `error` is null on success. Throw on a +// non-null `error` so a failed step stops the workflow with the catalogue +// `code` and the `requestId` to quote in a bug report. +function assertOk(result, op) { + if (result.error) { + // `details` is a JSON-encoded string; JSON.parse(result.error.details ?? "{}").reason + // carries the specific cause when there is one. + const { code, message, requestId } = result.error; + throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`); + } + return result; +} +``` + +*** + ## Ten-minute quickstart -The shortest path from "I have a credential" to "there is a lab with my file in it". Four calls and one transaction. Each step is the condensed form of [Tutorial 1](../labs-api/example-workflow.md#tutorial-1-create-a-lab-and-upload-a-public-file), which shows the expected response and the failure modes for every call. +The shortest path from "I have a credential" to "there is a lab with my file in it". Four calls and one transaction. Each step is the condensed form of [Tutorial 1](tutorial-1-public-upload.md), which shows the expected response and the failure modes for every call. ```bash export CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" @@ -116,7 +183,7 @@ export WALLET_PRIVATE_KEY="0x…" # funded on Base Sepolia 4. **Upload a file** — `initiateCreateOrUpdateFile` → `PUT` the bytes to the returned presigned URL → `finishCreateOrUpdateFile` with `accessLevel: "PUBLIC"`. 5. **Verify it worked** — see below. -The runnable version is the [Tutorial 1 complete script](../labs-api/example-workflow.md#tutorial-1-complete-script): +The runnable version is the [Tutorial 1 complete script](tutorial-1-public-upload.md#complete-script): ```bash node tutorial-1.js ./research-data.csv @@ -153,16 +220,57 @@ The second is visual — once `shortname` is populated, the lab has a page: | Next | Page | | ---- | ---- | -| Every step with expected responses and failure handling | [Tutorial 1 — public upload](../labs-api/example-workflow.md#tutorial-1-create-a-lab-and-upload-a-public-file) | -| Encrypt a file so only wallets with a role can read it | [Tutorial 2 — encrypted upload](../labs-api/example-workflow.md#tutorial-2-upload-an-encrypted-file) | -| Let an agent write into a lab a human created in the app | [Tutorial 3 — agent access](../labs-api/example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app) | -| Publish an update that attaches the dataset | [Tutorial 4 — announce](../labs-api/example-workflow.md#tutorial-4-announce-the-dataset) | +| Every step with expected responses and failure handling | [Tutorial 1 — public upload](tutorial-1-public-upload.md) | +| Encrypt a file so only wallets with a role can read it | [Tutorial 2 — encrypted upload](tutorial-2-encrypted-upload.md) | +| Let an agent write into a lab a human created in the app | [Tutorial 3 — agent access](tutorial-3-agent-access.md) | +| Publish an update that attaches the dataset | [Tutorial 4 — announce](tutorial-4-announce.md) | | Pay per call instead of holding a token | [x402 Gateway](../x402-gateway.md) | | Full operation reference | [Labs API](../labs-api/README.md) | | What every error code means | [Error handling](../labs-api/README.md#error-handling) | *** +## Running in Production + +All four tutorials run against staging (Base Sepolia, testnet funds). To run the same scripts against production, replace the values in the config block — nothing else changes, since every step reads from these constants: + +| Constant | Staging (these tutorials) | Production | +| -------- | ------------------------- | ---------- | +| `GRAPHQL_URL` | `https://staging.graphql.api.molecule.xyz/graphql` | `https://production.graphql.api.molecule.xyz/graphql` | +| `CHAIN` (viem import) | `baseSepolia` from `viem/chains` | `base` from `viem/chains` | +| `FACTORY_ADDRESS` | `0xd629FE2310b4309a212495F10A47f8436dcEfD90` | `0xECdF4f05384056507485C90aeAb0a83268760D6E` | +| `LABNFT_ADDRESS` | `0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28` | `0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92` | +| `ACCESS_RESOLVER_ADDRESS` | `0x5493F472602C87318EA5Eff753cDD593bf9bF559` | `0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B` | +| `ACCESS_CONDITION_CHAIN` | `"baseSepolia"` | `"base"` | +| `LAB_APP_URL` | `https://testnet.labs.molecule.xyz` | `https://labs.molecule.xyz` | + +```javascript +import { base } from "viem/chains"; // instead of baseSepolia + +const GRAPHQL_URL = "https://production.graphql.api.molecule.xyz/graphql"; +const CHAIN = base; +const FACTORY_ADDRESS = "0xECdF4f05384056507485C90aeAb0a83268760D6E"; +const LABNFT_ADDRESS = "0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92"; +const ACCESS_RESOLVER_ADDRESS = "0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B"; +const ACCESS_CONDITION_CHAIN = "base"; +const LAB_APP_URL = "https://labs.molecule.xyz"; +``` + +A few things follow automatically from that swap: + +* **Headers and the `graphql()` helper** are identical — `Authorization` (consumer credential, no `Bearer`) and the self-issued `X-Service-Token` work the same against both endpoints. Note that credentials are **per environment**: a staging `mol_` credential does not authenticate against production. +* **The `mintFeeWei()` read** already queries the live contract, so it picks up whatever fee production has configured with no code change. It reads `0` today on both chains. +* **The condition ABIs** are unchanged; only `contractAddress` and `chain` differ, and both come from the config block. + +What doesn't follow automatically, and is on you: + +* **Real funds.** Minting on `base` spends real ETH. Test on staging first. +* **Introspection is off in production** and query depth is capped at 10. Generate types against staging — see [Getting the schema](README.md#getting-the-schema). +* **`SERVICE_NAME`** should identify the real integration; it is echoed into the sign-in message and stored against the issued token. +* Full deployment list, including every other OCL contract on both chains: [Contracts reference](../../references/contracts/README.md). + +*** + ## Getting the schema **Staging has GraphQL introspection enabled** — point codegen, a playground or an SDK generator straight at it: diff --git a/api-reference/getting-started/for-agents.md b/api-reference/getting-started/for-agents.md index ed23ea6..5c7414b 100644 --- a/api-reference/getting-started/for-agents.md +++ b/api-reference/getting-started/for-agents.md @@ -7,7 +7,7 @@ icon: robot # 🤖 For Agents: One-Pager -The complete default flow — self-issue a token, mint a lab, upload a public file, verify — with nothing else on the page. Copy it into a system prompt or a context file. For the same flow with expected responses, failure modes and the encrypted variant, use [the tutorials](../labs-api/example-workflow.md). +The complete default flow — self-issue a token, mint a lab, upload a public file, verify — with nothing else on the page. Copy it into a system prompt or a context file. For the same flow with expected responses, failure modes and the encrypted variant, use [the tutorials](README.md). ## Constants (staging / Base Sepolia) @@ -148,7 +148,7 @@ mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!, 3. `finishCreateOrUpdateFile` with `accessLevel: "HOLDERS"` (or `"ADMIN"`) and `encryptionMetadata: { encryptionSystem, encryptedDek, iv, contentHash, accessControlConditions, encryptedBy, encryptedAt }` — echo `encryptionSystem` verbatim, never hardcode it. 4. To read it back: `decryptDataKey(oclId:, filePath:)` → `{ plaintextDEK, iv }` after the backend re-evaluates the file's onchain access conditions against your wallet. -Full recipe including `accessControlConditions`: [Tutorial 2](../labs-api/example-workflow.md#tutorial-2-upload-an-encrypted-file). +Full recipe including `accessControlConditions`: [Tutorial 2](tutorial-2-encrypted-upload.md). ## Rules that break runs when ignored @@ -158,13 +158,13 @@ Full recipe including `accessControlConditions`: [Tutorial 2](../labs-api/exampl 4. In-band `error.details` is a JSON string; thrown `errorInfo.details` is an object. They differ. 5. Filter mint receipt logs to the **LabNFT** address before decoding `OclIdentityCreated`. 6. Send the presigned `PUT` with the returned headers unchanged, and the raw bytes as the body. -7. Writing into a lab you do not own needs a **Contributor** role on it — see [Tutorial 3](../labs-api/example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app). After a role grant, an indexer lag of a few seconds can still return `UNAUTHORIZED`; retry with backoff. +7. Writing into a lab you do not own needs a **Contributor** role on it — see [Tutorial 3](tutorial-3-agent-access.md). After a role grant, an indexer lag of a few seconds can still return `UNAUTHORIZED`; retry with backoff. 8. Production has introspection off and a depth limit of 10. Generate types against staging. ## Related * [Getting Started](README.md) — prerequisites, costs, the lane you should be in -* [Tutorials](../labs-api/example-workflow.md) — the same flow with responses and failure handling +* [Tutorials](README.md) — the same flow with responses and failure handling * [Labs API](../labs-api/README.md) — full operation reference * [Molecule Skill](../../ai-tooling/molecule-skill.md) — the same workflow as MCP tool calls * [x402 Gateway](../x402-gateway.md) — pay per call, no service token diff --git a/api-reference/getting-started/tutorial-1-public-upload.md b/api-reference/getting-started/tutorial-1-public-upload.md new file mode 100644 index 0000000..8eea9fc --- /dev/null +++ b/api-reference/getting-started/tutorial-1-public-upload.md @@ -0,0 +1,580 @@ +--- +description: >- + The default path: self-issue a token, mint a LabNFT, register the lab, upload + a public file, and verify it landed. +icon: file-arrow-up +--- + +# Tutorial 1: Create a lab and upload a public file + +The default path, and the one to run first. Five steps: get a token, mint the LabNFT, register the lab, upload the file, verify. A public file is stored as-is — no key management, no access conditions. + +> **Want the file to be confidential instead?** Steps 1–3 are identical; branch at Step 4 into [Tutorial 2](tutorial-2-encrypted-upload.md). + +{% hint style="info" %} +**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](README.md#shared-setup), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. +{% endhint %} + +## Step 1: Get a service token + +Prove control of the wallet instead of waiting on a manually issued token — the self-service path for agents, bots and CI/CD. Fetch the deterministic sign-in message, sign it as a plain wallet message (EIP-191 `personal_sign` — **not** typed data), then exchange the signature for a token. Full reference: [Service Tokens](../labs-api/service-tokens.md#obtaining-a-token). + +```javascript +import { createPublicClient, createWalletClient, http } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +const SERVICE_NAME = "tutorial-agent"; + +const account = privateKeyToAccount(WALLET_PRIVATE_KEY); +// publicClient: read-only RPC calls (readContract, waitForTransactionReceipt). +const publicClient = createPublicClient({ chain: CHAIN, transport: http() }); +// walletClient: everything that needs the private key — signing and sending. +const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() }); + +const signInMessage = await graphql( + `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { + message + } + }`, + { walletAddress: account.address, serviceName: SERVICE_NAME }, +); + +// Sign the message VERBATIM — the backend recomposes and verifies the same +// string server-side, so re-wording or re-formatting it breaks verification. +const messageSignature = await walletClient.signMessage({ + message: signInMessage.getServiceSignInMessage.message, +}); + +const tokenResult = await graphql( + `mutation GenerateServiceToken( + $serviceName: String! + $walletAddress: String! + $messageSignature: String! + ) { + generateServiceToken( + serviceName: $serviceName + walletAddress: $walletAddress + messageSignature: $messageSignature + ) { + token + tokenId + expiresAt + message + error { code message requestId retryable details } + } + }`, + { serviceName: SERVICE_NAME, walletAddress: account.address, messageSignature }, +); +assertOk(tokenResult.generateServiceToken, "generateServiceToken"); +serviceToken = tokenResult.generateServiceToken.token; +``` + +**Expected response:** + +```json +{ + "data": { + "generateServiceToken": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…", + "tokenId": "b3f1c0de-…", + "expiresAt": "2027-02-23T10:31:07.000Z", + "message": "Service token generated successfully for tutorial-agent", + "error": null + } + } +} +``` + +**If it fails:** + +| `error.code` | What happened | Fix | +| ------------ | ------------- | --- | +| `UNAUTHENTICATED`, `reason: INVALID_SIGNATURE` | The signed bytes are not the message the backend recomposes | Sign the returned `message` string byte-for-byte. Use `personal_sign` / viem's `signMessage`, not `signTypedData` | +| `UNAUTHENTICATED`, `reason: WALLET_MISMATCH` | `walletAddress` isn't the signer | Pass the same address that signed | +| `VALIDATION_FAILED` | Bad `expiresIn` | Format is ``, unit one of `s m h d w M y`; between 1 hour and 2 years | +| HTTP `401` before GraphQL runs | Consumer credential missing or malformed | Check `Authorization` — no `Bearer` prefix. See [Authentication](../authentication.md) | + +`expiresIn` defaults to **`180d`** when omitted. The returned token is **wallet-bound, not lab-bound**: it carries this wallet's identity, and authorisation is resolved per request from that wallet's onchain role on whichever lab you name. + +## Step 2: Mint the LabNFT + +Mint onchain via `OnChainLabFactory.mintAndCreateAccount` and read `oclId` off the `OclIdentityCreated` event. Reuses `account` / `publicClient` / `walletClient` from Step 1 and `FACTORY_ADDRESS` / `LABNFT_ADDRESS` from the config block. Full detail — the fee call and how `oclId` is derived — is on [Lab Management](../labs-api/lab-management.md#mint-the-labnft). + +```javascript +import { parseAbi, parseEventLogs } from "viem"; + +const factoryAbi = parseAbi([ + "function mintAndCreateAccount(address to) external payable returns (address account, uint256 tokenId)", +]); +const labNftAbi = parseAbi([ + "function mintFeeWei() external view returns (uint256)", + "event OclIdentityCreated(address indexed account, bytes32 indexed oclId, uint256 indexed tokenId, bytes32 salt, uint256 canonicalChainId)", +]); + +// Read the fee live — it is 0 on both chains today, but never hardcode it. +const mintFeeWei = await publicClient.readContract({ + address: LABNFT_ADDRESS, + abi: labNftAbi, + functionName: "mintFeeWei", +}); + +const mintTxHash = await walletClient.writeContract({ + address: FACTORY_ADDRESS, + abi: factoryAbi, + functionName: "mintAndCreateAccount", + args: [account.address], + value: mintFeeWei, +}); +const mintReceipt = await publicClient.waitForTransactionReceipt({ hash: mintTxHash }); + +const [identity] = parseEventLogs({ + abi: labNftAbi, + eventName: "OclIdentityCreated", + logs: mintReceipt.logs.filter((l) => l.address.toLowerCase() === LABNFT_ADDRESS.toLowerCase()), +}); +const oclId = identity.args.oclId; +const labAccountAddress = identity.args.account; +``` + +**Expected result:** `mintReceipt.status === "success"`, and + +``` +oclId: 0x0101000000000000000000000000abc… (32-byte hex) +tokenId: 1274 +labAccountAddress: 0x… (the ERC-6551 Token Bound Account) +``` + +**If it fails:** + +* **Transaction reverts** — the wallet is unfunded, or `value` didn't match `mintFeeWei()`. Read the fee live and forward it; don't hardcode `0`. +* **`parseEventLogs` returns `[]`** — the logs weren't filtered to the LabNFT. `OclIdentityCreated` fires on the **LabNFT contract**, not the factory; the factory's own `AccountProvisioned` event does not carry `oclId` as a topic. + +## Step 3: Register the lab + +Register the Kamu-backed data room for the freshly-minted `oclId`. Full reference: [Create Lab](../labs-api/lab-management.md#create-lab). + +```javascript +const createLabResult = await graphql( + `mutation CreateLab($oclId: String!) { + createLab(input: { oclId: $oclId }) { + message + error { code message requestId retryable details } + lab { oclId shortname labAccountAddress labNftTokenId } + } + }`, + { oclId }, +); +assertOk(createLabResult.createLab, "createLab"); +``` + +**Expected response:** + +```json +{ + "data": { + "createLab": { + "message": "Lab created successfully", + "error": null, + "lab": { + "oclId": "0x0101000000000000000000000000abc…", + "shortname": null, + "labAccountAddress": "0x…", + "labNftTokenId": "1274" + } + } + } +} +``` + +`shortname` is derived server-side from the lab's name and is `null` until it has been derived — that is expected on a lab that has just been minted and not yet named. + +**If it fails:** + +| `error.code` | What happened | Fix | +| ------------ | ------------- | --- | +| `CONFLICT`, `reason: PROJECT_CONFLICT` | The lab is already registered | Treat as success and continue — this is what a re-run looks like | +| `NOT_FOUND` | The `oclId` isn't indexed yet | The indexer trails the mint by a few seconds. Retry with backoff | +| `UNAUTHENTICATED`, `reason: NO_AUTH` | No `X-Service-Token` and no Privy session | Step 1 didn't set `serviceToken` | +| `UPSTREAM_UNAVAILABLE` | A dependency is down (`reason: KAMU`) | `retryable: true` — retry with backoff | + +DID-linking for the new lab starts automatically in the background; [`getDidLinkStatus`](../labs-api/lab-management.md#get-did-link-status) reports its progress. You do not need to wait for it. + +## Step 4: Upload the file + +Three calls: get a presigned URL, `PUT` the bytes, finalise with metadata. Full reference: [Files](../labs-api/files.md). + +```javascript +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; + +const filePath = "./research-data.csv"; +const bytes = readFileSync(filePath); + +// 4a. Get a presigned URL +const initiateResult = await graphql( + `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { + initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { + uploadToken + uploadUrl + uploadUrlExpiry + method + headers { key value } + error { code message requestId retryable details } + } + }`, + { oclId, contentType: "text/csv", contentLength: bytes.length }, +); +assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); +const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile; + +// 4b. PUT the bytes with EXACTLY the returned headers +const uploadHeaders = {}; +headers.forEach((h) => (uploadHeaders[h.key] = h.value)); +const putResponse = await fetch(uploadUrl, { + method: method || "PUT", + headers: uploadHeaders, + body: bytes, +}); +if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status} ${putResponse.statusText}`); + +// 4c. Finalise +const finishResult = await graphql( + `mutation Finish( + $oclId: String! + $uploadToken: String! + $path: String! + $accessLevel: String! + $changeBy: String! + $description: String + $tags: [String!] + $categories: [String!] + $contentText: String + ) { + finishCreateOrUpdateFile( + oclId: $oclId + uploadToken: $uploadToken + path: $path + accessLevel: $accessLevel + changeBy: $changeBy + description: $description + tags: $tags + categories: $categories + contentText: $contentText + ) { + datasetId + contentHash + version + message + error { code message requestId retryable details } + } + }`, + { + oclId, + uploadToken, + path: basename(filePath), + accessLevel: "PUBLIC", // DataRoomAccessLevel: PUBLIC | HOLDERS | ADMIN + changeBy: account.address, + description: "Baseline assay results", + tags: ["preliminary"], + categories: ["raw-data"], + contentText: "assay,replicate,value", + }, +); +assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); +const { datasetId } = finishResult.finishCreateOrUpdateFile; +``` + +**Expected responses:** + +```json +{ + "data": { + "initiateCreateOrUpdateFile": { + "uploadToken": "eyJ…", + "uploadUrl": "https://…s3….amazonaws.com/…?X-Amz-Signature=…", + "uploadUrlExpiry": "2026-08-27T14:05:00.000Z", + "method": "PUT", + "headers": [{ "key": "Content-Type", "value": "text/csv" }], + "error": null + } + } +} +``` + +The `PUT` returns HTTP `200` with an empty body. Then: + +```json +{ + "data": { + "finishCreateOrUpdateFile": { + "datasetId": "did:odf:fed01…", + "contentHash": "f162…", + "version": 1, + "message": "…", + "error": null + } + } +} +``` + +`message` on this result is passed through from the storage layer, so its exact wording varies and is deliberately not shown here — it is **not part of the contract**. Assert on `error == null`, never on `message`. + +Keep `datasetId` — Tutorial 4 attaches it to an announcement. + +**If it fails:** + +| Symptom | Cause | Fix | +| ------- | ----- | --- | +| `initiate` → `UNAUTHORIZED` | The wallet behind the token has no write role on this lab | You must be Owner or Contributor. See [Tutorial 3](tutorial-3-agent-access.md) | +| `PUT` → `403` | URL expired (~15 min), or headers altered | Re-run `initiate`; send the returned `headers` verbatim | +| `PUT` → `400`/`411` | Body wasn't sent as raw bytes | Send the buffer, not a JSON wrapper. In curl: `--data-binary` | +| `finish` → `VALIDATION_FAILED`, `details.field: "path"` | `path` contains an underscore, or both `path` and `ref` were sent | Underscores are not allowed in `path`; use `path` for a new file **or** `ref` for a new version, never both | +| `finish` → `VALIDATION_FAILED`, `reason: INVALID_TAGS_OR_CATEGORIES` | Unknown tag or category | Valid values come from the public `fileCategoriesAndTags` query | +| `finish` → `VALIDATION_FAILED`, `reason: INVALID_ACCESS_LEVEL` | Bad `accessLevel` | One of `PUBLIC`, `HOLDERS`, `ADMIN` | + +## Step 5: Verify it worked + +Two checks. The first needs nothing but your consumer credential: + +```javascript +const verify = await graphql( + `query Verify($oclId: String!) { + labWithDataRoomAndFiles(oclId: $oclId) { + oclId + shortname + name + dataRoom { + id + files { path contentType accessLevel version createdBy downloadUrl } + } + } + }`, + { oclId }, +); + +const file = verify.labWithDataRoomAndFiles.dataRoom.files.find( + (f) => f.path.endsWith(basename(filePath)), +); +if (!file) throw new Error("File not found in the data room"); +console.log("Verified:", file.path, file.accessLevel, "v" + file.version); +``` + +Your file is in `dataRoom.files` with `accessLevel: "PUBLIC"` and `version: 1`. Because the file is public, `downloadUrl` is a fetchable presigned URL — `fetch` it and compare the bytes to what you uploaded for an end-to-end check. + +If `labWithDataRoomAndFiles` comes back `null`, the lab is not registered: Step 3 didn't complete. This is one of only two nullable queries on the Labs API, so a missing lab nulls the field instead of throwing. + +The second check is visual. Once `shortname` is populated, the lab has a page at `${LAB_APP_URL}/projects/` — `https://testnet.labs.molecule.xyz/projects/` on staging. + +## Complete script + +All five steps in one file, against staging. No pre-issued service token needed. + +```javascript +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; +import { + createPublicClient, + createWalletClient, + http, + parseAbi, + parseEventLogs, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; // production: `base` + +// ---- Staging (Base Sepolia) config — see "Running in Production" to swap ---- +const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql"; +const CHAIN = baseSepolia; +const FACTORY_ADDRESS = "0xd629FE2310b4309a212495F10A47f8436dcEfD90"; // OnChainLabFactory +const LABNFT_ADDRESS = "0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28"; // LabNFT (proxy) +const LAB_APP_URL = "https://testnet.labs.molecule.xyz"; +const SERVICE_NAME = "tutorial-agent"; + +const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; // mol__ — no "Bearer" prefix +const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY; + +let serviceToken; + +async function graphql(query, variables) { + const headers = { "Content-Type": "application/json", Authorization: CONSUMER_CREDENTIAL }; + if (serviceToken) headers["X-Service-Token"] = serviceToken; + const res = await fetch(GRAPHQL_URL, { + method: "POST", + headers, + body: JSON.stringify({ query, variables }), + }); + const { data, errors } = await res.json(); + if (errors) throw new Error(JSON.stringify(errors)); + return data; +} + +function assertOk(result, op) { + if (result.error) { + const { code, message, requestId } = result.error; + throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`); + } + return result; +} + +async function main() { + const filePath = process.argv[2]; + if (!filePath) throw new Error("Usage: node tutorial-1.js "); + + const account = privateKeyToAccount(WALLET_PRIVATE_KEY); + const publicClient = createPublicClient({ chain: CHAIN, transport: http() }); + const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() }); + + // ---- Step 1: service token ---- + const signInMessage = await graphql( + `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } + }`, + { walletAddress: account.address, serviceName: SERVICE_NAME }, + ); + const messageSignature = await walletClient.signMessage({ + message: signInMessage.getServiceSignInMessage.message, + }); + const tokenResult = await graphql( + `mutation GenerateServiceToken($serviceName: String!, $walletAddress: String!, $messageSignature: String!) { + generateServiceToken(serviceName: $serviceName, walletAddress: $walletAddress, messageSignature: $messageSignature) { + token + error { code message requestId retryable details } + } + }`, + { serviceName: SERVICE_NAME, walletAddress: account.address, messageSignature }, + ); + assertOk(tokenResult.generateServiceToken, "generateServiceToken"); + serviceToken = tokenResult.generateServiceToken.token; + console.log("1/5 Got service token"); + + // ---- Step 2: mint the LabNFT ---- + const factoryAbi = parseAbi([ + "function mintAndCreateAccount(address to) external payable returns (address account, uint256 tokenId)", + ]); + const labNftAbi = parseAbi([ + "function mintFeeWei() external view returns (uint256)", + "event OclIdentityCreated(address indexed account, bytes32 indexed oclId, uint256 indexed tokenId, bytes32 salt, uint256 canonicalChainId)", + ]); + + const mintFeeWei = await publicClient.readContract({ + address: LABNFT_ADDRESS, + abi: labNftAbi, + functionName: "mintFeeWei", + }); + const mintTxHash = await walletClient.writeContract({ + address: FACTORY_ADDRESS, + abi: factoryAbi, + functionName: "mintAndCreateAccount", + args: [account.address], + value: mintFeeWei, + }); + const mintReceipt = await publicClient.waitForTransactionReceipt({ hash: mintTxHash }); + const [identity] = parseEventLogs({ + abi: labNftAbi, + eventName: "OclIdentityCreated", + logs: mintReceipt.logs.filter((l) => l.address.toLowerCase() === LABNFT_ADDRESS.toLowerCase()), + }); + const oclId = identity.args.oclId; + console.log("2/5 Minted LabNFT — tx:", mintTxHash, "oclId:", oclId); + + // ---- Step 3: register the lab ---- + const createLabResult = await graphql( + `mutation CreateLab($oclId: String!) { + createLab(input: { oclId: $oclId }) { + message + error { code message requestId retryable details } + lab { shortname labAccountAddress labNftTokenId } + } + }`, + { oclId }, + ); + assertOk(createLabResult.createLab, "createLab"); + console.log("3/5 Lab registered — TBA:", createLabResult.createLab.lab.labAccountAddress); + + // ---- Step 4: upload the file ---- + const bytes = readFileSync(filePath); + const initiateResult = await graphql( + `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { + initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { + uploadToken uploadUrl method headers { key value } + error { code message requestId retryable details } + } + }`, + { oclId, contentType: "application/octet-stream", contentLength: bytes.length }, + ); + assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); + const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile; + + const uploadHeaders = {}; + headers.forEach((h) => (uploadHeaders[h.key] = h.value)); + const putResponse = await fetch(uploadUrl, { + method: method || "PUT", + headers: uploadHeaders, + body: bytes, + }); + if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status} ${putResponse.statusText}`); + + const finishResult = await graphql( + `mutation Finish($oclId: String!, $uploadToken: String!, $path: String!, $accessLevel: String!, $changeBy: String!) { + finishCreateOrUpdateFile(oclId: $oclId, uploadToken: $uploadToken, path: $path, accessLevel: $accessLevel, changeBy: $changeBy) { + datasetId version + error { code message requestId retryable details } + } + }`, + { + oclId, + uploadToken, + path: basename(filePath), + accessLevel: "PUBLIC", + changeBy: account.address, + }, + ); + assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); + const { datasetId } = finishResult.finishCreateOrUpdateFile; + console.log("4/5 Uploaded — datasetId:", datasetId); + + // ---- Step 5: verify ---- + const verify = await graphql( + `query Verify($oclId: String!) { + labWithDataRoomAndFiles(oclId: $oclId) { + shortname + dataRoom { files { path accessLevel version } } + } + }`, + { oclId }, + ); + const lab = verify.labWithDataRoomAndFiles; + if (!lab) throw new Error("Lab not found — createLab did not complete"); + const file = lab.dataRoom.files.find((f) => f.path.endsWith(basename(filePath))); + if (!file) throw new Error("File not found in the data room"); + console.log("5/5 Verified:", file.path, file.accessLevel, "v" + file.version); + if (lab.shortname) console.log("Lab page:", `${LAB_APP_URL}/projects/${lab.shortname}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +**Usage:** + +```bash +WALLET_PRIVATE_KEY="0x..." \ +CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" \ +node tutorial-1.js ./research-data.csv +``` + +*** + +## Next + +| | | +| --- | --- | +| Make the next file confidential | [Tutorial 2 — Upload an encrypted file](tutorial-2-encrypted-upload.md) | +| Let an agent write into this lab | [Tutorial 3 — Give your agent access](tutorial-3-agent-access.md) | +| Publish an update attaching the file | [Tutorial 4 — Announce the dataset](tutorial-4-announce.md) | +| Run it against mainnet | [Running in Production](README.md#running-in-production) | +| Per-operation reference | [Files](../labs-api/files.md) · [Lab Management](../labs-api/lab-management.md) | diff --git a/api-reference/getting-started/tutorial-2-encrypted-upload.md b/api-reference/getting-started/tutorial-2-encrypted-upload.md new file mode 100644 index 0000000..0c3f784 --- /dev/null +++ b/api-reference/getting-started/tutorial-2-encrypted-upload.md @@ -0,0 +1,648 @@ +--- +description: >- + Encrypt a file locally with AES-256-GCM, gate decryption on live onchain + roles, and verify with a full decrypt round trip. +icon: lock +--- + +# Tutorial 2: Upload an encrypted file + +Same lab, same three-call upload — but the bytes are AES-256-GCM encrypted locally before they leave your machine, and decryption is gated on live onchain state. Encryption is a first-class flow, not an appendix: reach for it whenever the file is confidential and access should follow the lab's roles. + +**Steps 1–3 are identical to [Tutorial 1](tutorial-1-public-upload.md)** — get a service token, mint, register. Pick up here with `oclId`, `labAccountAddress`, `account` and `serviceToken` already in hand (or with any lab you already hold a role on). + +Conceptually: the backend hands you a one-shot data encryption key (DEK) in two forms — plaintext, and wrapped by the key custodian. You encrypt with the plaintext copy, throw it away, and store the wrapped copy in the file's metadata alongside the conditions under which the custodian may unwrap it again. Full model: [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md). + +{% hint style="info" %} +**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](README.md#shared-setup), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. +{% endhint %} + +## Step 4a: Get a DEK + +```javascript +const dekResult = await graphql(` + mutation { + generateDataEncryptionKey { + plaintextDEK + encryptedDek + encryptionSystem + error { code message requestId retryable details } + } + } +`); +assertOk(dekResult.generateDataEncryptionKey, "generateDataEncryptionKey"); +const { plaintextDEK, encryptedDek, encryptionSystem } = dekResult.generateDataEncryptionKey; +``` + +**Expected response:** + +```json +{ + "data": { + "generateDataEncryptionKey": { + "plaintextDEK": "3q2+7wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "encryptedDek": "AQIDAHjR…", + "encryptionSystem": "kms", + "error": null + } + } +} +``` + +`encryptionSystem` is **backend-set** — echo it verbatim in Step 4d, never hardcode `"kms"`. Key custody is on a path to threshold cryptography, and echoing the value is what keeps your integration working across that change. + +Requires authentication (service token or Privy session), so Step 1 must have run. + +## Step 4b: Encrypt locally + +```javascript +import { webcrypto, randomBytes, createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; + +const filePath = "./confidential-results.csv"; +const plaintext = readFileSync(filePath); + +const cryptoKey = await webcrypto.subtle.importKey( + "raw", + Buffer.from(plaintextDEK, "base64"), + "AES-GCM", + false, // not extractable — the key cannot be read back out + ["encrypt"], +); +const iv = randomBytes(12); // 96-bit IV, fresh per file — never reuse one +const ciphertext = Buffer.from( + await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, cryptoKey, plaintext), +); +// Hash of the PLAINTEXT — this is what a reader checks after decrypting. +const contentHashHex = "sha256-" + createHash("sha256").update(plaintext).digest("hex"); +``` + +The plaintext DEK is now unreferenced; don't log it, don't persist it, don't send it anywhere. Web Crypto's `AES-GCM` appends the 16-byte authentication tag to the ciphertext, which is what the decrypt side expects. + +## Step 4c: Write the access conditions + +`accessControlConditions` is a JSON-stringified array of predicates the backend re-evaluates against live chain state every time someone asks to decrypt. Two recipes cover almost everything. + +**Owner only** — only the LabNFT owner (and authorised signers of its Token Bound Account, so a Safe's signers resolve through) can decrypt: + +```javascript +const ownerOnlyConditions = JSON.stringify([ + { + conditionType: "evmContract", + contractAddress: ACCESS_RESOLVER_ADDRESS, + chain: ACCESS_CONDITION_CHAIN, + functionName: "isAuthorizedSignerForTba", + functionParams: [":userAddress", labAccountAddress], + functionAbi: { + name: "isAuthorizedSignerForTba", + inputs: [ + { name: "signer", type: "address" }, + { name: "account", type: "address" }, + ], + outputs: [{ name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + returnValueTest: { key: "", comparator: "=", value: "true" }, + }, +]); +``` + +**Owner or Contributor or Viewer** — the recipe to use when the lab has a team, and the one Tutorial 3's agent needs. `hasRole` is hierarchical (`ROLE_VIEWER = 1`; a Contributor and the Owner both pass a Viewer check), and the explicit `isAuthorizedSignerForTba` branch keeps the Owner covered even if conditions are ever evaluated against a non-canonical chain: + +```javascript +const accessResolverAbi = { + isAuthorizedSignerForTba: { + name: "isAuthorizedSignerForTba", + inputs: [ + { name: "signer", type: "address" }, + { name: "account", type: "address" }, + ], + outputs: [{ name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + hasRole: { + name: "hasRole", + inputs: [ + { name: "oclId", type: "bytes32" }, + { name: "account", type: "address" }, + { name: "role", type: "uint8" }, + ], + outputs: [{ name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, +}; + +const teamConditions = JSON.stringify([ + { + conditionType: "evmContract", + contractAddress: ACCESS_RESOLVER_ADDRESS, + chain: ACCESS_CONDITION_CHAIN, + functionName: "isAuthorizedSignerForTba", + functionParams: [":userAddress", labAccountAddress], + functionAbi: accessResolverAbi.isAuthorizedSignerForTba, + returnValueTest: { key: "", comparator: "=", value: "true" }, + }, + { operator: "or" }, + { + conditionType: "evmContract", + contractAddress: ACCESS_RESOLVER_ADDRESS, + chain: ACCESS_CONDITION_CHAIN, + functionName: "hasRole", + functionParams: [oclId, ":userAddress", "1"], // "1" = ROLE_VIEWER; "2" = ROLE_CONTRIBUTOR and up + functionAbi: accessResolverAbi.hasRole, + returnValueTest: { key: "", comparator: "=", value: "true" }, + }, +]); +``` + +`:userAddress` is substituted with the authenticated caller's wallet at evaluation time. Pass `"2"` instead of `"1"` to exclude Viewers. Evaluation walks the array left to right and short-circuits (`or` stops at the first true); **any RPC error fails closed** and the DEK is not released. The full condition grammar, including `EvmBasicCondition`, is on [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md#worked-example-encrypt-for-owner-or-contributor-or-viewer). + +## Step 4d: Upload the ciphertext + +The same three calls as Tutorial 1, with the ciphertext in place of the raw file and `encryptionMetadata` attached on finish. + +```javascript +const initiateResult = await graphql( + `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { + initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { + uploadToken uploadUrl method headers { key value } + error { code message requestId retryable details } + } + }`, + // Content-length is the CIPHERTEXT length, and the type is opaque bytes. + { oclId, contentType: "application/octet-stream", contentLength: ciphertext.length }, +); +assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); +const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile; + +const uploadHeaders = {}; +headers.forEach((h) => (uploadHeaders[h.key] = h.value)); +const putResponse = await fetch(uploadUrl, { + method: method || "PUT", + headers: uploadHeaders, + body: ciphertext, +}); +if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status} ${putResponse.statusText}`); + +const finishResult = await graphql( + `mutation Finish( + $oclId: String! + $uploadToken: String! + $path: String! + $accessLevel: String! + $changeBy: String! + $encryptionMetadata: EncryptionMetadataInput + ) { + finishCreateOrUpdateFile( + oclId: $oclId + uploadToken: $uploadToken + path: $path + accessLevel: $accessLevel + changeBy: $changeBy + encryptionMetadata: $encryptionMetadata + ) { + datasetId + version + message + error { code message requestId retryable details } + } + }`, + { + oclId, + uploadToken, + path: basename(filePath), + accessLevel: "HOLDERS", // encrypted files use HOLDERS or ADMIN, not PUBLIC + changeBy: account.address, + encryptionMetadata: { + encryptionSystem, // echo verbatim — never hardcode + encryptedDek, + iv: iv.toString("base64"), + contentHash: contentHashHex, + accessControlConditions: teamConditions, + encryptedBy: account.address.toLowerCase(), + encryptedAt: new Date().toISOString(), + }, + }, +); +assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); +``` + +**If it fails:** + +| `error.code` | What happened | Fix | +| ------------ | ------------- | --- | +| `VALIDATION_FAILED`, `reason: INVALID_CONDITIONS` | `accessControlConditions` isn't a valid stringified condition array | It is a **JSON string**, not an object. Check `functionAbi` is complete and `returnValueTest` present | +| `VALIDATION_FAILED`, `reason: INVALID_ACCESS_LEVEL` | `PUBLIC` on an encrypted file | Use `HOLDERS` or `ADMIN` | +| `UNAUTHORIZED` on `generateDataEncryptionKey` | No write role on the lab | Owner or Contributor required | + +## Step 5: Verify by decrypting it + +The real test is a round trip: ask the backend to unwrap the DEK, decrypt, and compare hashes. `decryptDataKey` re-evaluates the file's conditions against **live** chain state and your token's wallet, so a success here proves the gate works. + +```javascript +const decryptResult = await graphql( + `mutation DecryptDataKey($oclId: String!, $filePath: String!) { + decryptDataKey(oclId: $oclId, filePath: $filePath) { + plaintextDEK + iv + error { code message requestId retryable details } + } + }`, + { oclId, filePath: basename(filePath) }, +); +assertOk(decryptResult.decryptDataKey, "decryptDataKey"); +const { plaintextDEK: unwrappedDEK, iv: returnedIv } = decryptResult.decryptDataKey; + +// Fetch the ciphertext back from the data room +const fileQuery = await graphql( + `query GetFile($oclId: String!, $path: String!) { + dataRoomFile(oclId: $oclId, path: $path) { + path + accessLevel + downloadUrl + encryptionMetadata { encryptionSystem contentHash encryptedBy encryptedAt } + } + }`, + { oclId, path: basename(filePath) }, +); +const downloaded = Buffer.from( + await (await fetch(fileQuery.dataRoomFile.downloadUrl)).arrayBuffer(), +); + +const decryptKey = await webcrypto.subtle.importKey( + "raw", + Buffer.from(unwrappedDEK, "base64"), + "AES-GCM", + false, + ["decrypt"], +); +const recovered = Buffer.from( + await webcrypto.subtle.decrypt( + { name: "AES-GCM", iv: Buffer.from(returnedIv, "base64") }, + decryptKey, + downloaded, + ), +); + +const recoveredHash = "sha256-" + createHash("sha256").update(recovered).digest("hex"); +if (recoveredHash !== fileQuery.dataRoomFile.encryptionMetadata.contentHash) { + throw new Error("Content hash mismatch after decryption"); +} +console.log("Round trip verified —", recovered.length, "bytes recovered"); +``` + +**Expected:** the hashes match and `recovered` equals your original file byte-for-byte. + +**If it fails:** + +| `error.code` | What happened | Fix | +| ------------ | ------------- | --- | +| `UNAUTHORIZED` | Your wallet does not satisfy the file's conditions | Check the role with the public `listLabMembers(oclId)` query. After a fresh grant, allow for indexer/chain lag and retry | +| `FAILED_PRECONDITION`, `reason: LEGACY_ENCRYPTION` | The file predates onchain-verified envelope encryption | Not decryptable through this mutation; use the original encryption client | +| `FAILED_PRECONDITION`, `reason: NOT_ENCRYPTED` / `MISSING_DEK` | The file has no `encryptionMetadata` | You're pointing at a public file | +| `UPSTREAM_UNAVAILABLE` | Condition evaluation could not reach the chain RPC | Fails closed by design. `retryable: true` | +| Web Crypto throws `OperationError` | Wrong IV, or ciphertext truncated | Use the `iv` **returned by `decryptDataKey`**, and pass the whole downloaded body including the trailing GCM tag | + +## Complete script + +Steps 1–3 are Tutorial 1's verbatim; this script carries them so it runs standalone. + +```javascript +#!/usr/bin/env node +import { webcrypto, randomBytes, createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; +import { + createPublicClient, + createWalletClient, + http, + parseAbi, + parseEventLogs, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; // production: `base` + +const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql"; +const CHAIN = baseSepolia; +const FACTORY_ADDRESS = "0xd629FE2310b4309a212495F10A47f8436dcEfD90"; +const LABNFT_ADDRESS = "0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28"; +const ACCESS_RESOLVER_ADDRESS = "0x5493F472602C87318EA5Eff753cDD593bf9bF559"; +const ACCESS_CONDITION_CHAIN = "baseSepolia"; +const SERVICE_NAME = "tutorial-agent"; + +const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; +const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY; +// Optional: reuse an existing lab instead of minting a new one. +const EXISTING_OCL_ID = process.env.OCL_ID; + +let serviceToken; + +async function graphql(query, variables) { + const headers = { "Content-Type": "application/json", Authorization: CONSUMER_CREDENTIAL }; + if (serviceToken) headers["X-Service-Token"] = serviceToken; + const res = await fetch(GRAPHQL_URL, { + method: "POST", + headers, + body: JSON.stringify({ query, variables }), + }); + const { data, errors } = await res.json(); + if (errors) throw new Error(JSON.stringify(errors)); + return data; +} + +function assertOk(result, op) { + if (result.error) { + const { code, message, requestId } = result.error; + throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`); + } + return result; +} + +const accessResolverAbi = { + isAuthorizedSignerForTba: { + name: "isAuthorizedSignerForTba", + inputs: [ + { name: "signer", type: "address" }, + { name: "account", type: "address" }, + ], + outputs: [{ name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + hasRole: { + name: "hasRole", + inputs: [ + { name: "oclId", type: "bytes32" }, + { name: "account", type: "address" }, + { name: "role", type: "uint8" }, + ], + outputs: [{ name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, +}; + +function buildTeamConditions(oclId, labAccountAddress) { + return JSON.stringify([ + { + conditionType: "evmContract", + contractAddress: ACCESS_RESOLVER_ADDRESS, + chain: ACCESS_CONDITION_CHAIN, + functionName: "isAuthorizedSignerForTba", + functionParams: [":userAddress", labAccountAddress], + functionAbi: accessResolverAbi.isAuthorizedSignerForTba, + returnValueTest: { key: "", comparator: "=", value: "true" }, + }, + { operator: "or" }, + { + conditionType: "evmContract", + contractAddress: ACCESS_RESOLVER_ADDRESS, + chain: ACCESS_CONDITION_CHAIN, + functionName: "hasRole", + functionParams: [oclId, ":userAddress", "1"], + functionAbi: accessResolverAbi.hasRole, + returnValueTest: { key: "", comparator: "=", value: "true" }, + }, + ]); +} + +async function main() { + const filePath = process.argv[2]; + if (!filePath) throw new Error("Usage: node tutorial-2.js "); + + const account = privateKeyToAccount(WALLET_PRIVATE_KEY); + const publicClient = createPublicClient({ chain: CHAIN, transport: http() }); + const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() }); + + // ---- Step 1: service token ---- + const signInMessage = await graphql( + `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } + }`, + { walletAddress: account.address, serviceName: SERVICE_NAME }, + ); + const messageSignature = await walletClient.signMessage({ + message: signInMessage.getServiceSignInMessage.message, + }); + const tokenResult = await graphql( + `mutation GenerateServiceToken($serviceName: String!, $walletAddress: String!, $messageSignature: String!) { + generateServiceToken(serviceName: $serviceName, walletAddress: $walletAddress, messageSignature: $messageSignature) { + token + error { code message requestId retryable details } + } + }`, + { serviceName: SERVICE_NAME, walletAddress: account.address, messageSignature }, + ); + assertOk(tokenResult.generateServiceToken, "generateServiceToken"); + serviceToken = tokenResult.generateServiceToken.token; + console.log("1/6 Got service token"); + + // ---- Steps 2 & 3: mint + register (skipped when OCL_ID is provided) ---- + let oclId = EXISTING_OCL_ID; + let labAccountAddress; + + if (oclId) { + const existing = await graphql( + `query($oclId: String!) { labWithDataRoomAndFiles(oclId: $oclId) { labAccountAddress } }`, + { oclId }, + ); + if (!existing.labWithDataRoomAndFiles) throw new Error(`Lab ${oclId} not found`); + labAccountAddress = existing.labWithDataRoomAndFiles.labAccountAddress; + console.log("2-3/6 Reusing lab", oclId); + } else { + const factoryAbi = parseAbi([ + "function mintAndCreateAccount(address to) external payable returns (address account, uint256 tokenId)", + ]); + const labNftAbi = parseAbi([ + "function mintFeeWei() external view returns (uint256)", + "event OclIdentityCreated(address indexed account, bytes32 indexed oclId, uint256 indexed tokenId, bytes32 salt, uint256 canonicalChainId)", + ]); + const mintFeeWei = await publicClient.readContract({ + address: LABNFT_ADDRESS, + abi: labNftAbi, + functionName: "mintFeeWei", + }); + const mintTxHash = await walletClient.writeContract({ + address: FACTORY_ADDRESS, + abi: factoryAbi, + functionName: "mintAndCreateAccount", + args: [account.address], + value: mintFeeWei, + }); + const mintReceipt = await publicClient.waitForTransactionReceipt({ hash: mintTxHash }); + const [identity] = parseEventLogs({ + abi: labNftAbi, + eventName: "OclIdentityCreated", + logs: mintReceipt.logs.filter((l) => l.address.toLowerCase() === LABNFT_ADDRESS.toLowerCase()), + }); + oclId = identity.args.oclId; + labAccountAddress = identity.args.account; + console.log("2/6 Minted LabNFT — oclId:", oclId); + + const createLabResult = await graphql( + `mutation CreateLab($oclId: String!) { + createLab(input: { oclId: $oclId }) { + error { code message requestId retryable details } + lab { labAccountAddress } + } + }`, + { oclId }, + ); + assertOk(createLabResult.createLab, "createLab"); + console.log("3/6 Lab registered"); + } + + // ---- Step 4a: DEK ---- + const dekResult = await graphql(` + mutation { + generateDataEncryptionKey { + plaintextDEK encryptedDek encryptionSystem + error { code message requestId retryable details } + } + } + `); + assertOk(dekResult.generateDataEncryptionKey, "generateDataEncryptionKey"); + const { plaintextDEK, encryptedDek, encryptionSystem } = dekResult.generateDataEncryptionKey; + + // ---- Step 4b: encrypt locally ---- + const plaintext = readFileSync(filePath); + const cryptoKey = await webcrypto.subtle.importKey( + "raw", + Buffer.from(plaintextDEK, "base64"), + "AES-GCM", + false, + ["encrypt"], + ); + const iv = randomBytes(12); + const ciphertext = Buffer.from( + await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, cryptoKey, plaintext), + ); + const contentHashHex = "sha256-" + createHash("sha256").update(plaintext).digest("hex"); + console.log("4/6 Encrypted locally —", plaintext.length, "→", ciphertext.length, "bytes"); + + // ---- Step 4c + 4d: conditions + upload ---- + const initiateResult = await graphql( + `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { + initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { + uploadToken uploadUrl method headers { key value } + error { code message requestId retryable details } + } + }`, + { oclId, contentType: "application/octet-stream", contentLength: ciphertext.length }, + ); + assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); + const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile; + + const uploadHeaders = {}; + headers.forEach((h) => (uploadHeaders[h.key] = h.value)); + const putResponse = await fetch(uploadUrl, { + method: method || "PUT", + headers: uploadHeaders, + body: ciphertext, + }); + if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status} ${putResponse.statusText}`); + + const finishResult = await graphql( + `mutation Finish($oclId: String!, $uploadToken: String!, $path: String!, $accessLevel: String!, $changeBy: String!, $encryptionMetadata: EncryptionMetadataInput) { + finishCreateOrUpdateFile(oclId: $oclId, uploadToken: $uploadToken, path: $path, accessLevel: $accessLevel, changeBy: $changeBy, encryptionMetadata: $encryptionMetadata) { + datasetId version + error { code message requestId retryable details } + } + }`, + { + oclId, + uploadToken, + path: basename(filePath), + accessLevel: "HOLDERS", + changeBy: account.address, + encryptionMetadata: { + encryptionSystem, + encryptedDek, + iv: iv.toString("base64"), + contentHash: contentHashHex, + accessControlConditions: buildTeamConditions(oclId, labAccountAddress), + encryptedBy: account.address.toLowerCase(), + encryptedAt: new Date().toISOString(), + }, + }, + ); + assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); + console.log("5/6 Uploaded — datasetId:", finishResult.finishCreateOrUpdateFile.datasetId); + + // ---- Step 5: verify by decrypting ---- + const decryptResult = await graphql( + `mutation DecryptDataKey($oclId: String!, $filePath: String!) { + decryptDataKey(oclId: $oclId, filePath: $filePath) { + plaintextDEK iv + error { code message requestId retryable details } + } + }`, + { oclId, filePath: basename(filePath) }, + ); + assertOk(decryptResult.decryptDataKey, "decryptDataKey"); + + const fileQuery = await graphql( + `query GetFile($oclId: String!, $path: String!) { + dataRoomFile(oclId: $oclId, path: $path) { + downloadUrl + encryptionMetadata { contentHash } + } + }`, + { oclId, path: basename(filePath) }, + ); + const downloaded = Buffer.from( + await (await fetch(fileQuery.dataRoomFile.downloadUrl)).arrayBuffer(), + ); + const decryptKey = await webcrypto.subtle.importKey( + "raw", + Buffer.from(decryptResult.decryptDataKey.plaintextDEK, "base64"), + "AES-GCM", + false, + ["decrypt"], + ); + const recovered = Buffer.from( + await webcrypto.subtle.decrypt( + { name: "AES-GCM", iv: Buffer.from(decryptResult.decryptDataKey.iv, "base64") }, + decryptKey, + downloaded, + ), + ); + const recoveredHash = "sha256-" + createHash("sha256").update(recovered).digest("hex"); + if (recoveredHash !== fileQuery.dataRoomFile.encryptionMetadata.contentHash) { + throw new Error("Content hash mismatch after decryption"); + } + console.log("6/6 Round trip verified —", recovered.length, "bytes recovered"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +**Usage:** + +```bash +WALLET_PRIVATE_KEY="0x..." \ +CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" \ +node tutorial-2.js ./confidential-results.csv + +# or against a lab you already have +OCL_ID="0x0101…" WALLET_PRIVATE_KEY="0x..." CONSUMER_CREDENTIAL="mol_…" \ +node tutorial-2.js ./confidential-results.csv +``` + +*** + +## Next + +| | | +| --- | --- | +| Let an agent decrypt and contribute too | [Tutorial 3 — Give your agent access](tutorial-3-agent-access.md) | +| Publish an update attaching the file | [Tutorial 4 — Announce the dataset](tutorial-4-announce.md) | +| Run it against mainnet | [Running in Production](README.md#running-in-production) | +| How conditions are evaluated, in depth | [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md) | diff --git a/api-reference/getting-started/tutorial-3-agent-access.md b/api-reference/getting-started/tutorial-3-agent-access.md new file mode 100644 index 0000000..d04ba6c --- /dev/null +++ b/api-reference/getting-started/tutorial-3-agent-access.md @@ -0,0 +1,417 @@ +--- +description: >- + A human owns the lab and never hands over a key: the agent gets its own + wallet, the human grants it Contributor, the agent issues its own token. +icon: robot +--- + +# Tutorial 3: Give your agent access to a lab you created in the app + +The most common real-world shape: a researcher created their Lab in the Labs app with an email address — no wallet, no code — and now wants an agent contributing to it. The agent gets its **own** identity rather than borrowing the human's; the human grants it a role; the agent authenticates itself from then on. + +{% hint style="info" %} +**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](README.md#shared-setup), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. +{% endhint %} + +**Who does what:** + +| # | Actor | Action | +| - | ----- | ------ | +| 1 | Agent | Generate a wallet and report its address | +| 2 | Human | Add that address to the lab as **Contributor**, flagged as an agent, with an expiry | +| 3 | Agent | Self-issue a service token by signing the sign-in message | +| 4 | Agent | Upload files and announce; the human sees the result in the app | + +The human never hands over a private key, a token, or their session. Revoking the agent is one onchain revoke, and it does not touch anything else. + +## Step 1: The agent reports its address + +With the plugin: run `wallet_address`. With viem: + +```javascript +import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; + +// Generate once, store it as the agent's own secret — never the human's key. +const agentPrivateKey = process.env.AGENT_PRIVATE_KEY ?? generatePrivateKey(); +const agentAccount = privateKeyToAccount(agentPrivateKey); +console.log("Agent wallet address:", agentAccount.address); +``` + +Give that address to the lab owner. The agent needs **no gas** for this tutorial — it never sends a transaction, only signs a message. (Persist `agentPrivateKey` if you generated it, or the next run is a different agent with no role.) + +## Step 2: The human grants Contributor + +In the Labs app, the lab owner adds the agent's address to the lab's members and grants it the **Contributor** role, setting: + +* **`isAgent = true`** — informational metadata that marks the member as an agent identity in the members list and UI. It does not change authorisation. +* **an expiry** — typically the agent's session lifetime. When it lapses the agent loses access until it is re-granted; a permanent grant is possible but not the default you want for an agent. + +Members can be invited by wallet address, ENS name or email, and the app sponsors the gas for the grant. Under the hood this is one onchain call on the `AccessResolver`, which the owner (or an existing Contributor, for Viewer grants) can also make directly: + +```solidity +function grantRole(bytes32 oclId, address account, uint8 role, uint64 expiry, bool isAgent) external; +// role: 2 = ROLE_CONTRIBUTOR, 1 = ROLE_VIEWER +``` + +Only the **Owner** may grant Contributor. Contributors can grant Viewer, but not Contributor. Full capability matrix: [Roles & Permissions](../../technical-deep-dive/roles-and-permissions.md). + +**Why Contributor and not Viewer:** a Viewer can decrypt and read but cannot write. Uploading files and posting announcements needs Contributor. + +Both parties can confirm the grant landed with a public query — no authentication beyond the consumer credential: + +```javascript +const members = await graphql( + `query ListLabMembers($oclId: String!) { + listLabMembers(oclId: $oclId) { + members { walletAddress role isAgent expiry grantedAt } + } + }`, + { oclId }, +); +const grant = members.listLabMembers.members.find( + (m) => m.walletAddress.toLowerCase() === agentAccount.address.toLowerCase(), +); +console.log("Agent role:", grant?.role, "expiry:", grant?.expiry ?? "permanent"); +``` + +Expect `role: "CONTRIBUTOR"` and `isAgent: true`. `expiry` is unix seconds as a decimal string, or `null` for a permanent grant. Expired grants are excluded from this list entirely, so a missing entry after a while means the grant lapsed. + +## Step 3: The agent self-issues a service token + +Identical to Tutorial 1 Step 1, signed by the **agent's** wallet: + +```javascript +const AGENT_SERVICE_NAME = "research-agent-1"; + +const signInMessage = await graphql( + `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } + }`, + { walletAddress: agentAccount.address, serviceName: AGENT_SERVICE_NAME }, +); + +const messageSignature = await agentAccount.signMessage({ + message: signInMessage.getServiceSignInMessage.message, +}); + +const tokenResult = await graphql( + `mutation GenerateServiceToken( + $serviceName: String! + $walletAddress: String! + $messageSignature: String! + $expiresIn: String + ) { + generateServiceToken( + serviceName: $serviceName + walletAddress: $walletAddress + messageSignature: $messageSignature + expiresIn: $expiresIn + ) { + token tokenId expiresAt + error { code message requestId retryable details } + } + }`, + { + serviceName: AGENT_SERVICE_NAME, + walletAddress: agentAccount.address, + messageSignature, + expiresIn: "30d", // match the role grant's expiry rather than taking the 180d default + }, +); +assertOk(tokenResult.generateServiceToken, "generateServiceToken"); +serviceToken = tokenResult.generateServiceToken.token; +``` + +Issuance is **not** gated on holding a role — any wallet can mint a token for itself. The role is what makes the token *useful*: authorisation is resolved per request from the token's wallet against the lab you name. So a token issued before the grant lands keeps working once it does; you do not need to re-issue it. + +## Step 4: The agent uploads and announces + +From here the agent is an ordinary caller. Run [Tutorial 1 Step 4](tutorial-1-public-upload.md#step-4-upload-the-file) with `changeBy: agentAccount.address`, or [Tutorial 2](tutorial-2-encrypted-upload.md) for a confidential file — the `hasRole` branch of the team conditions is exactly what lets the agent decrypt too. Then [Tutorial 4](tutorial-4-announce.md) to surface it on the lab's feed. + +Writes by a Contributor service token are gated per mutation, matching the Privy user path: `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createAnnouncement`, `deleteDataRoomFile`, `updateFileMetadata` and `moveEntry` all accept Contributor. A few surfaces remain **Owner-only** and an agent Contributor cannot reach them: `updateLabNftMetadata`, `generateLabImageUploadUrl` and the legal-agreement mutations. + +{% hint style="warning" %} +**Retry on `UNAUTHORIZED` right after the grant.** Role state reaches the API through an event indexer, so there is a short window after `grantRole` confirms onchain in which a write still returns `UNAUTHORIZED` (`reason: NOT_CONTRIBUTOR`). It is not a permissions problem and re-issuing the token will not help — wait and retry: + +```javascript +async function withRoleGrantRetry(fn, { attempts = 5, baseMs = 2000 } = {}) { + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err) { + const isAuthzLag = /UNAUTHORIZED/.test(String(err)); + if (!isAuthzLag || i === attempts - 1) throw err; + await new Promise((r) => setTimeout(r, baseMs * 2 ** i)); // 2s, 4s, 8s, 16s + } + } +} + +await withRoleGrantRetry(() => uploadFile(oclId, "./findings.csv")); +``` +{% endhint %} + +## Step 5: Verify from both sides + +**The agent** verifies as in Tutorial 1 Step 5 — the file is in `dataRoom.files` with `createdBy` set to the agent's address: + +```javascript +const verify = await graphql( + `query Verify($oclId: String!) { + labWithDataRoomAndFiles(oclId: $oclId) { + shortname + dataRoom { files { path accessLevel version createdBy } } + announcements { headline changeBy } + } + }`, + { oclId }, +); +``` + +**The human** verifies in the app: the file appears in the lab's data room and the announcement on its activity feed, both attributed to the agent's address, which the members list shows flagged as an agent. + +## Revoking the agent + +One onchain call, and the agent's writes stop: + +```solidity +function revokeRole(bytes32 oclId, address account) external; // Owner only, for a Contributor +``` + +Or let the grant's `expiry` lapse. Independently, the agent's token can be killed with `revokeServiceToken(tokenId)` — a token can only revoke or extend **its own** record, so one agent cannot interfere with another's. + +## Complete script + +The agent's half of the flow, standalone: report the wallet address, wait for the human's grant to appear, self-issue a token, upload, announce, verify. Steps 1 and 2 involve a human, so the script polls for the role rather than assuming it. + +Run it with the agent's own key and the `oclId` of the human's lab — the agent never sees the owner's key. + +```javascript +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; +import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; + +const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql"; +const LAB_APP_URL = "https://testnet.labs.molecule.xyz"; +const AGENT_SERVICE_NAME = "research-agent-1"; + +const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; +const OCL_ID = process.env.OCL_ID; // the human's lab +// Persist this, or every run is a different agent with no role. +const AGENT_PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY; + +let serviceToken; + +async function graphql(query, variables) { + const headers = { "Content-Type": "application/json", Authorization: CONSUMER_CREDENTIAL }; + if (serviceToken) headers["X-Service-Token"] = serviceToken; + const res = await fetch(GRAPHQL_URL, { + method: "POST", + headers, + body: JSON.stringify({ query, variables }), + }); + const { data, errors } = await res.json(); + if (errors) throw new Error(JSON.stringify(errors)); + return data; +} + +function assertOk(result, op) { + if (result.error) { + const { code, message, requestId } = result.error; + throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`); + } + return result; +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Writes can return UNAUTHORIZED for a few seconds after a role grant confirms +// onchain — the indexer trails the chain. Retry rather than re-issuing the token. +async function withRoleGrantRetry(fn, { attempts = 5, baseMs = 2000 } = {}) { + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err) { + if (!/UNAUTHORIZED/.test(String(err)) || i === attempts - 1) throw err; + await sleep(baseMs * 2 ** i); // 2s, 4s, 8s, 16s + } + } +} + +async function main() { + const filePath = process.argv[2]; + if (!filePath) throw new Error("Usage: node tutorial-3.js "); + if (!OCL_ID) throw new Error("Set OCL_ID to the lab the human owns"); + + // ---- Step 1: the agent's identity ---- + if (!AGENT_PRIVATE_KEY) { + console.log("No AGENT_PRIVATE_KEY set. Generated one for this run only:"); + console.log(" AGENT_PRIVATE_KEY=" + generatePrivateKey()); + throw new Error("Store that key, grant it Contributor, then re-run."); + } + const agentAccount = privateKeyToAccount(AGENT_PRIVATE_KEY); + console.log("1/6 Agent wallet:", agentAccount.address); + console.log(" Ask the lab owner to add it as Contributor (isAgent = true)."); + + // ---- Step 2: wait for the human's grant (public query, no auth needed) ---- + let grant; + for (let i = 0; i < 60; i++) { + const members = await graphql( + `query ListLabMembers($oclId: String!) { + listLabMembers(oclId: $oclId) { + members { walletAddress role isAgent expiry } + } + }`, + { oclId: OCL_ID }, + ); + grant = members.listLabMembers.members.find( + (m) => m.walletAddress.toLowerCase() === agentAccount.address.toLowerCase(), + ); + if (grant) break; + await sleep(5000); // poll for up to 5 minutes + } + if (!grant) throw new Error("No role grant found for the agent wallet — ask the owner to add it"); + if (grant.role === "VIEWER") throw new Error("Agent holds VIEWER; uploading needs CONTRIBUTOR"); + console.log("2/6 Role:", grant.role, "isAgent:", grant.isAgent, "expiry:", grant.expiry ?? "permanent"); + + // ---- Step 3: the agent self-issues a token ---- + const signInMessage = await graphql( + `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } + }`, + { walletAddress: agentAccount.address, serviceName: AGENT_SERVICE_NAME }, + ); + const messageSignature = await agentAccount.signMessage({ + message: signInMessage.getServiceSignInMessage.message, + }); + const tokenResult = await graphql( + `mutation GenerateServiceToken($serviceName: String!, $walletAddress: String!, $messageSignature: String!, $expiresIn: String) { + generateServiceToken(serviceName: $serviceName, walletAddress: $walletAddress, messageSignature: $messageSignature, expiresIn: $expiresIn) { + token expiresAt + error { code message requestId retryable details } + } + }`, + { + serviceName: AGENT_SERVICE_NAME, + walletAddress: agentAccount.address, + messageSignature, + expiresIn: "30d", // match the role grant rather than taking the 180d default + }, + ); + assertOk(tokenResult.generateServiceToken, "generateServiceToken"); + serviceToken = tokenResult.generateServiceToken.token; + console.log("3/6 Token issued, expires", tokenResult.generateServiceToken.expiresAt); + + // ---- Step 4: upload (public; see Tutorial 2 for the encrypted variant) ---- + const bytes = readFileSync(filePath); + const { datasetId } = await withRoleGrantRetry(async () => { + const initiateResult = await graphql( + `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { + initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { + uploadToken uploadUrl method headers { key value } + error { code message requestId retryable details } + } + }`, + { oclId: OCL_ID, contentType: "application/octet-stream", contentLength: bytes.length }, + ); + assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); + const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile; + + const uploadHeaders = {}; + headers.forEach((h) => (uploadHeaders[h.key] = h.value)); + const putResponse = await fetch(uploadUrl, { + method: method || "PUT", + headers: uploadHeaders, + body: bytes, + }); + if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status}`); + + const finishResult = await graphql( + `mutation Finish($oclId: String!, $uploadToken: String!, $path: String!, $accessLevel: String!, $changeBy: String!) { + finishCreateOrUpdateFile(oclId: $oclId, uploadToken: $uploadToken, path: $path, accessLevel: $accessLevel, changeBy: $changeBy) { + datasetId + error { code message requestId retryable details } + } + }`, + { + oclId: OCL_ID, + uploadToken, + path: basename(filePath), + accessLevel: "PUBLIC", + changeBy: agentAccount.address, + }, + ); + assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); + return finishResult.finishCreateOrUpdateFile; + }); + console.log("4/6 Uploaded — datasetId:", datasetId); + + // ---- Step 4b: announce it ---- + const announcement = await graphql( + `mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!, $attachments: [String!]) { + createAnnouncement(oclId: $oclId, headline: $headline, body: $body, attachments: $attachments) { + error { code message requestId retryable details } + } + }`, + { + oclId: OCL_ID, + headline: `Agent analysis: ${basename(filePath)}`, + body: "Written by an autonomous agent holding a Contributor role on this lab.", + attachments: [datasetId], + }, + ); + assertOk(announcement.createAnnouncement, "createAnnouncement"); + console.log("5/6 Announced"); + + // ---- Step 5: verify ---- + const verify = await graphql( + `query Verify($oclId: String!) { + labWithDataRoomAndFiles(oclId: $oclId) { + shortname + dataRoom { files { path accessLevel version createdBy } } + } + }`, + { oclId: OCL_ID }, + ); + const file = verify.labWithDataRoomAndFiles.dataRoom.files.find( + (f) => f.path.endsWith(basename(filePath)), + ); + if (!file) throw new Error("File not found in the data room"); + const attributed = + file.createdBy?.toLowerCase() === agentAccount.address.toLowerCase(); + console.log( + "6/6 Verified:", file.path, file.accessLevel, + attributed ? "— attributed to the agent" : `— createdBy: ${file.createdBy}`, + ); + if (verify.labWithDataRoomAndFiles.shortname) { + console.log("Human can see it at:", `${LAB_APP_URL}/projects/${verify.labWithDataRoomAndFiles.shortname}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +**Usage:** + +```bash +# First run — prints a generated agent key, then stops so the owner can grant the role +CONSUMER_CREDENTIAL="mol_…" OCL_ID="0x0101…" node tutorial-3.js ./findings.csv + +# Subsequent runs, once the owner has granted Contributor to that address +AGENT_PRIVATE_KEY="0x..." CONSUMER_CREDENTIAL="mol_…" OCL_ID="0x0101…" \ +node tutorial-3.js ./findings.csv +``` + +*** + +## Next + +| | | +| --- | --- | +| What the agent uploads | [Tutorial 1 — public file](tutorial-1-public-upload.md) · [Tutorial 2 — encrypted file](tutorial-2-encrypted-upload.md) | +| Have the agent announce its work | [Tutorial 4 — Announce the dataset](tutorial-4-announce.md) | +| The role model in full | [Roles & Permissions](../../technical-deep-dive/roles-and-permissions.md) | +| Let the agent run the whole workflow as tool calls | [Molecule Skill](../../ai-tooling/molecule-skill.md) | diff --git a/api-reference/getting-started/tutorial-4-announce.md b/api-reference/getting-started/tutorial-4-announce.md new file mode 100644 index 0000000..f51c0a6 --- /dev/null +++ b/api-reference/getting-started/tutorial-4-announce.md @@ -0,0 +1,239 @@ +--- +description: >- + Publish a lab update that attaches the dataset you uploaded, and read it back + off the activity feed. +icon: bullhorn +--- + +# Tutorial 4: Announce the dataset + +An announcement is the lab's public update stream: a headline, a body, and optionally the datasets it is about. Attaching the file makes the announcement the discoverable surface for it — announcements are indexed by `searchLabs` alongside files, and they appear on the lab's activity feed and public page. + +Requires Owner or Contributor (a Viewer cannot announce). Pick up with `oclId`, `serviceToken` and the `datasetId` returned by `finishCreateOrUpdateFile`. + +{% hint style="info" %} +**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](README.md#shared-setup), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. +{% endhint %} + +```javascript +const announcementResult = await graphql( + `mutation CreateAnnouncement( + $oclId: String! + $headline: String! + $body: String! + $attachments: [String!] + ) { + createAnnouncement(oclId: $oclId, headline: $headline, body: $body, attachments: $attachments) { + message + error { code message requestId retryable details } + } + }`, + { + oclId, + headline: "Baseline assay results published", + body: "First replicate set for the ApoB series. 240 samples, three conditions. Raw CSV attached.", + attachments: [datasetId], // the datasetId from finishCreateOrUpdateFile + }, +); +assertOk(announcementResult.createAnnouncement, "createAnnouncement"); +``` + +**Expected response:** + +```json +{ + "data": { + "createAnnouncement": { + "message": "…", + "error": null + } + } +} +``` + +`createAnnouncement` returns no announcement object, and its `message` is passed through from the storage layer rather than being a fixed string — so success is `error == null` and nothing else. Verify by reading the announcement back. + +**If it fails:** + +| `error.code` | What happened | Fix | +| ------------ | ------------- | --- | +| `UNAUTHORIZED` | Caller is a Viewer, or has no role | Contributor or Owner required. Fresh grant? See the [retry note](tutorial-3-agent-access.md#step-4-the-agent-uploads-and-announces) | +| `NOT_FOUND` | An `attachments` entry isn't a dataset in this lab | Pass the exact `datasetId` strings from `finishCreateOrUpdateFile`, from this lab | +| `VALIDATION_FAILED` | Empty `headline` or `body` | Both are required and non-empty | + +## Verify it worked + +```javascript +const feed = await graphql( + `query LabActivity($oclId: String!) { + labActivity(oclId: $oclId, page: 0, perPage: 10, filter: ANNOUNCEMENT) { + nodes { + __typename + ... on LabEventAnnouncement { + announcement { + id + headline + body + changeBy + eventTime + attachments { path contentType accessLevel } + } + } + } + pageInfo { totalPages currentPage } + } + }`, + { oclId }, +); +console.log(JSON.stringify(feed.labActivity.nodes[0], null, 2)); +``` + +Your announcement is the newest node, with `attachments` resolved to the full file objects — not just ids — and `changeBy` set to the wallet that posted it. It is also on the lab's public page at `${LAB_APP_URL}/projects/`. + +`labActivity` is a **public** query: anyone with a consumer credential can read the feed, which is the point of an announcement. + +## Complete script + +Announce and verify, standalone. Takes the `oclId` and the `datasetId` you got from `finishCreateOrUpdateFile` in Tutorial 1 or 2. + +```javascript +#!/usr/bin/env node +import { privateKeyToAccount } from "viem/accounts"; + +const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql"; +const LAB_APP_URL = "https://testnet.labs.molecule.xyz"; +const SERVICE_NAME = "tutorial-agent"; + +const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; +const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY; +const OCL_ID = process.env.OCL_ID; +// Optional: omit to announce without attaching anything. +const DATASET_ID = process.env.DATASET_ID; + +let serviceToken; + +async function graphql(query, variables) { + const headers = { "Content-Type": "application/json", Authorization: CONSUMER_CREDENTIAL }; + if (serviceToken) headers["X-Service-Token"] = serviceToken; + const res = await fetch(GRAPHQL_URL, { + method: "POST", + headers, + body: JSON.stringify({ query, variables }), + }); + const { data, errors } = await res.json(); + if (errors) throw new Error(JSON.stringify(errors)); + return data; +} + +function assertOk(result, op) { + if (result.error) { + const { code, message, requestId } = result.error; + throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`); + } + return result; +} + +async function main() { + const headline = process.argv[2]; + const body = process.argv[3]; + if (!headline || !body) throw new Error('Usage: node tutorial-4.js "" ""'); + if (!OCL_ID) throw new Error("Set OCL_ID"); + + const account = privateKeyToAccount(WALLET_PRIVATE_KEY); + + // ---- Service token (Tutorial 1 Step 1) ---- + const signInMessage = await graphql( + `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } + }`, + { walletAddress: account.address, serviceName: SERVICE_NAME }, + ); + const messageSignature = await account.signMessage({ + message: signInMessage.getServiceSignInMessage.message, + }); + const tokenResult = await graphql( + `mutation GenerateServiceToken($serviceName: String!, $walletAddress: String!, $messageSignature: String!) { + generateServiceToken(serviceName: $serviceName, walletAddress: $walletAddress, messageSignature: $messageSignature) { + token + error { code message requestId retryable details } + } + }`, + { serviceName: SERVICE_NAME, walletAddress: account.address, messageSignature }, + ); + assertOk(tokenResult.generateServiceToken, "generateServiceToken"); + serviceToken = tokenResult.generateServiceToken.token; + console.log("1/3 Got service token"); + + // ---- Announce ---- + const announcementResult = await graphql( + `mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!, $attachments: [String!]) { + createAnnouncement(oclId: $oclId, headline: $headline, body: $body, attachments: $attachments) { + message + error { code message requestId retryable details } + } + }`, + { + oclId: OCL_ID, + headline, + body, + attachments: DATASET_ID ? [DATASET_ID] : undefined, + }, + ); + assertOk(announcementResult.createAnnouncement, "createAnnouncement"); + console.log("2/3 Announced"); + + // ---- Verify off the activity feed ---- + const feed = await graphql( + `query LabActivity($oclId: String!) { + labActivity(oclId: $oclId, page: 0, perPage: 10, filter: ANNOUNCEMENT) { + nodes { + __typename + ... on LabEventAnnouncement { + announcement { + id headline changeBy eventTime + attachments { path contentType accessLevel } + } + } + } + } + labWithDataRoomAndFiles(oclId: $oclId) { shortname } + }`, + { oclId: OCL_ID }, + ); + const newest = feed.labActivity.nodes.find( + (n) => n.announcement?.headline === headline, + ); + if (!newest) throw new Error("Announcement not found on the activity feed"); + console.log( + "3/3 Verified:", newest.announcement.headline, + "—", newest.announcement.attachments.length, "attachment(s)", + "by", newest.announcement.changeBy, + ); + if (feed.labWithDataRoomAndFiles?.shortname) { + console.log("Lab page:", `${LAB_APP_URL}/projects/${feed.labWithDataRoomAndFiles.shortname}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +**Usage:** + +```bash +WALLET_PRIVATE_KEY="0x..." CONSUMER_CREDENTIAL="mol_…" \ +OCL_ID="0x0101…" DATASET_ID="did:odf:fed01…" \ +node tutorial-4.js "Baseline assay results published" "First replicate set for the ApoB series." +``` + +*** + +## Next + +| | | +| --- | --- | +| Upload another file first | [Tutorial 1 — public](tutorial-1-public-upload.md) · [Tutorial 2 — encrypted](tutorial-2-encrypted-upload.md) | +| Run it against mainnet | [Running in Production](README.md#running-in-production) | +| Search and feed queries | [Browse & Search](../labs-api/browse-and-search.md) | diff --git a/api-reference/labs-api/README.md b/api-reference/labs-api/README.md index 179eb51..00c7d91 100644 --- a/api-reference/labs-api/README.md +++ b/api-reference/labs-api/README.md @@ -21,7 +21,7 @@ The Labs API allows developers to interact with Molecule Labs datarooms without | | | | --- | --- | | **First time here** | [🚀 Getting Started](../getting-started/README.md) — prerequisites, costs, ten-minute quickstart | -| **You want runnable code** | [Tutorials](example-workflow.md) — public upload, encrypted upload, agent access, announce | +| **You want runnable code** | [Tutorial 1](../getting-started/tutorial-1-public-upload.md) (public upload) · [2](../getting-started/tutorial-2-encrypted-upload.md) (encrypted) · [3](../getting-started/tutorial-3-agent-access.md) (agent access) · [4](../getting-started/tutorial-4-announce.md) (announce) | | **You're an AI agent** | [Agent one-pager](../getting-started/for-agents.md), or drive this API through the [Molecule Skill](../../ai-tooling/molecule-skill.md) plugin | | **You want to pay per call** | [x402 Gateway](../x402-gateway.md) | @@ -31,7 +31,7 @@ The Labs API allows developers to interact with Molecule Labs datarooms without The Labs API uses consumer-credential authentication for reads and an additional Service Token for writes — which callers **issue for themselves** by signing a message with their wallet. Full details — public queries vs. protected mutations, obtaining and using credentials — are on the [Authentication](../authentication.md) page. -See also the functional sections: [Tutorials](example-workflow.md), [Lab Management](lab-management.md), [Files](files.md), [Browse & Search](browse-and-search.md), and [Service Tokens](service-tokens.md). +See also the functional sections: [Lab Management](lab-management.md), [Files](files.md), [Browse & Search](browse-and-search.md), and [Service Tokens](service-tokens.md). For runnable end-to-end walkthroughs, see the [tutorials](../getting-started/README.md) under Getting Started. --- @@ -148,7 +148,7 @@ A missing or malformed consumer credential is rejected before the GraphQL layer **`UNAUTHORIZED`** — the wallet behind the service token lacks the required role on the lab: - Check the wallet's role with the public `listLabMembers(oclId)` query. Content writes (uploads, metadata, announcements, moves, deletes) need **Contributor**; `createLab` and the LabNFT-metadata mutations need **Owner** -- Not the right role? The lab owner grants one onchain — see [Tutorial 3](example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app) +- Not the right role? The lab owner grants one onchain — see [Tutorial 3](../getting-started/tutorial-3-agent-access.md) - **Just granted the role?** Role state reaches the API through an event indexer, so a write can still return `UNAUTHORIZED` for a few seconds after the grant confirms onchain. Retry with backoff; re-issuing the token does not help **Upload to presigned URL fails:** @@ -218,7 +218,7 @@ The legacy `*V2` operations and the pre-OCL naming have been **removed**. The cu If you encounter any issues or have questions about the Labs API: 1. Check this documentation and the [troubleshooting section](#troubleshooting) -2. Run the [Tutorials](example-workflow.md) against staging — each step lists its expected response and failure modes +2. Run the [Tutorials](../getting-started/README.md) against staging — each step lists its expected response and failure modes 3. Join our [Discord community](https://t.co/L0VEiy4Bjk) for support, quoting the `requestId` from the failing response --- diff --git a/api-reference/labs-api/example-workflow.md b/api-reference/labs-api/example-workflow.md index e0f142f..8226986 100644 --- a/api-reference/labs-api/example-workflow.md +++ b/api-reference/labs-api/example-workflow.md @@ -1,1567 +1,20 @@ -# Tutorials +--- +description: This page moved. The walkthrough is now four tutorials under Getting Started. +hidden: true +icon: signs-post +--- -Four step-by-step tutorials, each ending in something you can verify. They share one config block and two helper functions ([Shared setup](#shared-setup)) and are written against **staging** (Base Sepolia, testnet funds) end to end — see [Running in Production](#running-in-production) for the values to swap. +# Example Workflow (moved) -| Tutorial | What you end up with | Start from | -| -------- | -------------------- | ---------- | -| [**1 — Create a lab and upload a public file**](#tutorial-1-create-a-lab-and-upload-a-public-file) | A lab you own, with a readable file in its data room | Nothing but a credential and a funded wallet | -| [**2 — Upload an encrypted file**](#tutorial-2-upload-an-encrypted-file) | A confidential file only authorised wallets can decrypt | Tutorial 1, or any lab you have a role on | -| [**3 — Give your agent access to a lab you created in the app**](#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app) | An agent wallet writing into a lab a human owns | A lab created in the Labs app | -| [**4 — Announce the dataset**](#tutorial-4-announce-the-dataset) | A public update on the lab's activity feed, attaching your file | Tutorial 1 or 2 | +This page has been replaced by four step-by-step tutorials under **[🚀 Getting Started](../getting-started/README.md)**, each one runnable end to end and each ending in a check that it worked. -New here? Read [Getting Started](../getting-started/README.md) first — it covers the two prerequisites, what things cost, and which of the four lanes you should be in. If you are an agent, the [one-pager](../getting-started/for-agents.md) is the same flow with no prose. +| | | +| --- | --- | +| [**Tutorial 1: Create a lab and upload a public file**](../getting-started/tutorial-1-public-upload.md) | The default path — self-issue a token, mint the LabNFT, register the lab, upload, verify. **Start here.** | +| [**Tutorial 2: Upload an encrypted file**](../getting-started/tutorial-2-encrypted-upload.md) | Local AES-256-GCM, onchain access conditions, verified with a decrypt round trip | +| [**Tutorial 3: Give your agent access to a lab you created in the app**](../getting-started/tutorial-3-agent-access.md) | The human owns the lab and never hands over a key | +| [**Tutorial 4: Announce the dataset**](../getting-started/tutorial-4-announce.md) | Publish an update that attaches the file you uploaded | -## Prerequisites +The two things all four share also live on the Getting Started page: the [shared setup block](../getting-started/README.md#shared-setup) and the [staging → production swap table](../getting-started/README.md#running-in-production). -* A **consumer credential** — `mol__`. See [Getting Started](../getting-started/README.md#1-a-mol-consumer-credential-the-one-manual-step) for the request template. **No pre-issued Service Token needed** — every tutorial mints its own from a wallet signature. -* A funded EOA on **Base Sepolia** — testnet ETH from a [Base Sepolia faucet](https://docs.base.org/base-chain/tools/network-faucets). `mintFeeWei()` reads **0** on both Base Sepolia and Base mainnet (verified 2026-08-27), so minting costs gas only — but the tutorials read the live value and forward it, so a future fee needs no code change. -* Node 18+ and `viem` (`npm install viem`). `fetch` and `node:crypto` are built in. - -Tutorial 3 additionally needs a lab created in the Labs app by a human, and Tutorial 2's verification step needs the wallet to satisfy the file's own access conditions. - -*** - -## Shared setup - -Every environment-specific value lives in this one block; swapping to production is a matter of replacing it with the table in [Running in Production](#running-in-production). - -```javascript -import { baseSepolia } from "viem/chains"; // production: `base` - -// ---- Staging (Base Sepolia) config — see "Running in Production" to swap ---- -const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql"; -const CHAIN = baseSepolia; -const FACTORY_ADDRESS = "0xd629FE2310b4309a212495F10A47f8436dcEfD90"; // OnChainLabFactory -const LABNFT_ADDRESS = "0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28"; // LabNFT (proxy) -const ACCESS_RESOLVER_ADDRESS = "0x5493F472602C87318EA5Eff753cDD593bf9bF559"; // AccessResolver -const ACCESS_CONDITION_CHAIN = "baseSepolia"; // the `chain` string inside accessControlConditions -const LAB_APP_URL = "https://testnet.labs.molecule.xyz"; // production: https://labs.molecule.xyz - -const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; // mol__ — no "Bearer" prefix -const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY; - -// Set once Step 1 exchanges a wallet signature for a token. Every call after -// that automatically starts sending it; public queries (like Step 1's own -// sign-in-message lookup) work fine without it. -let serviceToken; - -async function graphql(query, variables) { - // Authorization is always required. X-Service-Token is added once we have - // one — omit it entirely rather than sending an empty header. - const headers = { "Content-Type": "application/json", Authorization: CONSUMER_CREDENTIAL }; - if (serviceToken) headers["X-Service-Token"] = serviceToken; - - const res = await fetch(GRAPHQL_URL, { - method: "POST", - headers, - body: JSON.stringify({ query, variables }), - }); - const { data, errors } = await res.json(); - // Queries report failure here: a top-level errors[] entry whose errorType is - // the catalogue code. Mutations report expected failures in-band instead (see - // assertOk); a top-level entry on a mutation means a transport/infrastructure - // failure or an invalid request document. - if (errors) throw new Error(JSON.stringify(errors)); - return data; -} - -// Mutations report failure in-band: `error` is null on success. Throw on a -// non-null `error` so a failed step stops the workflow with the catalogue -// `code` and the `requestId` to quote in a bug report. -function assertOk(result, op) { - if (result.error) { - // `details` is a JSON-encoded string; JSON.parse(result.error.details ?? "{}").reason - // carries the specific cause when there is one. - const { code, message, requestId } = result.error; - throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`); - } - return result; -} -``` - -*** - -## Tutorial 1: Create a lab and upload a public file - -The default path, and the one to run first. Five steps: get a token, mint the LabNFT, register the lab, upload the file, verify. A public file is stored as-is — no key management, no access conditions. - -> **Want the file to be confidential instead?** Steps 1–3 are identical; branch at Step 4 into [Tutorial 2](#tutorial-2-upload-an-encrypted-file). - -### Step 1: Get a service token - -Prove control of the wallet instead of waiting on a manually issued token — the self-service path for agents, bots and CI/CD. Fetch the deterministic sign-in message, sign it as a plain wallet message (EIP-191 `personal_sign` — **not** typed data), then exchange the signature for a token. Full reference: [Service Tokens](service-tokens.md#obtaining-a-token). - -```javascript -import { createPublicClient, createWalletClient, http } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; - -const SERVICE_NAME = "tutorial-agent"; - -const account = privateKeyToAccount(WALLET_PRIVATE_KEY); -// publicClient: read-only RPC calls (readContract, waitForTransactionReceipt). -const publicClient = createPublicClient({ chain: CHAIN, transport: http() }); -// walletClient: everything that needs the private key — signing and sending. -const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() }); - -const signInMessage = await graphql( - `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { - getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { - message - } - }`, - { walletAddress: account.address, serviceName: SERVICE_NAME }, -); - -// Sign the message VERBATIM — the backend recomposes and verifies the same -// string server-side, so re-wording or re-formatting it breaks verification. -const messageSignature = await walletClient.signMessage({ - message: signInMessage.getServiceSignInMessage.message, -}); - -const tokenResult = await graphql( - `mutation GenerateServiceToken( - $serviceName: String! - $walletAddress: String! - $messageSignature: String! - ) { - generateServiceToken( - serviceName: $serviceName - walletAddress: $walletAddress - messageSignature: $messageSignature - ) { - token - tokenId - expiresAt - message - error { code message requestId retryable details } - } - }`, - { serviceName: SERVICE_NAME, walletAddress: account.address, messageSignature }, -); -assertOk(tokenResult.generateServiceToken, "generateServiceToken"); -serviceToken = tokenResult.generateServiceToken.token; -``` - -**Expected response:** - -```json -{ - "data": { - "generateServiceToken": { - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…", - "tokenId": "b3f1c0de-…", - "expiresAt": "2027-02-23T10:31:07.000Z", - "message": "Service token generated successfully for tutorial-agent", - "error": null - } - } -} -``` - -**If it fails:** - -| `error.code` | What happened | Fix | -| ------------ | ------------- | --- | -| `UNAUTHENTICATED`, `reason: INVALID_SIGNATURE` | The signed bytes are not the message the backend recomposes | Sign the returned `message` string byte-for-byte. Use `personal_sign` / viem's `signMessage`, not `signTypedData` | -| `UNAUTHENTICATED`, `reason: WALLET_MISMATCH` | `walletAddress` isn't the signer | Pass the same address that signed | -| `VALIDATION_FAILED` | Bad `expiresIn` | Format is ``, unit one of `s m h d w M y`; between 1 hour and 2 years | -| HTTP `401` before GraphQL runs | Consumer credential missing or malformed | Check `Authorization` — no `Bearer` prefix. See [Authentication](../authentication.md) | - -`expiresIn` defaults to **`180d`** when omitted. The returned token is **wallet-bound, not lab-bound**: it carries this wallet's identity, and authorisation is resolved per request from that wallet's onchain role on whichever lab you name. - -### Step 2: Mint the LabNFT - -Mint onchain via `OnChainLabFactory.mintAndCreateAccount` and read `oclId` off the `OclIdentityCreated` event. Reuses `account` / `publicClient` / `walletClient` from Step 1 and `FACTORY_ADDRESS` / `LABNFT_ADDRESS` from the config block. Full detail — the fee call and how `oclId` is derived — is on [Lab Management](lab-management.md#mint-the-labnft). - -```javascript -import { parseAbi, parseEventLogs } from "viem"; - -const factoryAbi = parseAbi([ - "function mintAndCreateAccount(address to) external payable returns (address account, uint256 tokenId)", -]); -const labNftAbi = parseAbi([ - "function mintFeeWei() external view returns (uint256)", - "event OclIdentityCreated(address indexed account, bytes32 indexed oclId, uint256 indexed tokenId, bytes32 salt, uint256 canonicalChainId)", -]); - -// Read the fee live — it is 0 on both chains today, but never hardcode it. -const mintFeeWei = await publicClient.readContract({ - address: LABNFT_ADDRESS, - abi: labNftAbi, - functionName: "mintFeeWei", -}); - -const mintTxHash = await walletClient.writeContract({ - address: FACTORY_ADDRESS, - abi: factoryAbi, - functionName: "mintAndCreateAccount", - args: [account.address], - value: mintFeeWei, -}); -const mintReceipt = await publicClient.waitForTransactionReceipt({ hash: mintTxHash }); - -const [identity] = parseEventLogs({ - abi: labNftAbi, - eventName: "OclIdentityCreated", - logs: mintReceipt.logs.filter((l) => l.address.toLowerCase() === LABNFT_ADDRESS.toLowerCase()), -}); -const oclId = identity.args.oclId; -const labAccountAddress = identity.args.account; -``` - -**Expected result:** `mintReceipt.status === "success"`, and - -``` -oclId: 0x0101000000000000000000000000abc… (32-byte hex) -tokenId: 1274 -labAccountAddress: 0x… (the ERC-6551 Token Bound Account) -``` - -**If it fails:** - -* **Transaction reverts** — the wallet is unfunded, or `value` didn't match `mintFeeWei()`. Read the fee live and forward it; don't hardcode `0`. -* **`parseEventLogs` returns `[]`** — the logs weren't filtered to the LabNFT. `OclIdentityCreated` fires on the **LabNFT contract**, not the factory; the factory's own `AccountProvisioned` event does not carry `oclId` as a topic. - -### Step 3: Register the lab - -Register the Kamu-backed data room for the freshly-minted `oclId`. Full reference: [Create Lab](lab-management.md#create-lab). - -```javascript -const createLabResult = await graphql( - `mutation CreateLab($oclId: String!) { - createLab(input: { oclId: $oclId }) { - message - error { code message requestId retryable details } - lab { oclId shortname labAccountAddress labNftTokenId } - } - }`, - { oclId }, -); -assertOk(createLabResult.createLab, "createLab"); -``` - -**Expected response:** - -```json -{ - "data": { - "createLab": { - "message": "Lab created successfully", - "error": null, - "lab": { - "oclId": "0x0101000000000000000000000000abc…", - "shortname": null, - "labAccountAddress": "0x…", - "labNftTokenId": "1274" - } - } - } -} -``` - -`shortname` is derived server-side from the lab's name and is `null` until it has been derived — that is expected on a lab that has just been minted and not yet named. - -**If it fails:** - -| `error.code` | What happened | Fix | -| ------------ | ------------- | --- | -| `CONFLICT`, `reason: PROJECT_CONFLICT` | The lab is already registered | Treat as success and continue — this is what a re-run looks like | -| `NOT_FOUND` | The `oclId` isn't indexed yet | The indexer trails the mint by a few seconds. Retry with backoff | -| `UNAUTHENTICATED`, `reason: NO_AUTH` | No `X-Service-Token` and no Privy session | Step 1 didn't set `serviceToken` | -| `UPSTREAM_UNAVAILABLE` | A dependency is down (`reason: KAMU`) | `retryable: true` — retry with backoff | - -DID-linking for the new lab starts automatically in the background; [`getDidLinkStatus`](lab-management.md#get-did-link-status) reports its progress. You do not need to wait for it. - -### Step 4: Upload the file - -Three calls: get a presigned URL, `PUT` the bytes, finalise with metadata. Full reference: [Files](files.md). - -```javascript -import { readFileSync } from "node:fs"; -import { basename } from "node:path"; - -const filePath = "./research-data.csv"; -const bytes = readFileSync(filePath); - -// 4a. Get a presigned URL -const initiateResult = await graphql( - `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { - initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { - uploadToken - uploadUrl - uploadUrlExpiry - method - headers { key value } - error { code message requestId retryable details } - } - }`, - { oclId, contentType: "text/csv", contentLength: bytes.length }, -); -assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); -const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile; - -// 4b. PUT the bytes with EXACTLY the returned headers -const uploadHeaders = {}; -headers.forEach((h) => (uploadHeaders[h.key] = h.value)); -const putResponse = await fetch(uploadUrl, { - method: method || "PUT", - headers: uploadHeaders, - body: bytes, -}); -if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status} ${putResponse.statusText}`); - -// 4c. Finalise -const finishResult = await graphql( - `mutation Finish( - $oclId: String! - $uploadToken: String! - $path: String! - $accessLevel: String! - $changeBy: String! - $description: String - $tags: [String!] - $categories: [String!] - $contentText: String - ) { - finishCreateOrUpdateFile( - oclId: $oclId - uploadToken: $uploadToken - path: $path - accessLevel: $accessLevel - changeBy: $changeBy - description: $description - tags: $tags - categories: $categories - contentText: $contentText - ) { - datasetId - contentHash - version - message - error { code message requestId retryable details } - } - }`, - { - oclId, - uploadToken, - path: basename(filePath), - accessLevel: "PUBLIC", // DataRoomAccessLevel: PUBLIC | HOLDERS | ADMIN - changeBy: account.address, - description: "Baseline assay results", - tags: ["preliminary"], - categories: ["raw-data"], - contentText: "assay,replicate,value", - }, -); -assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); -const { datasetId } = finishResult.finishCreateOrUpdateFile; -``` - -**Expected responses:** - -```json -{ - "data": { - "initiateCreateOrUpdateFile": { - "uploadToken": "eyJ…", - "uploadUrl": "https://…s3….amazonaws.com/…?X-Amz-Signature=…", - "uploadUrlExpiry": "2026-08-27T14:05:00.000Z", - "method": "PUT", - "headers": [{ "key": "Content-Type", "value": "text/csv" }], - "error": null - } - } -} -``` - -The `PUT` returns HTTP `200` with an empty body. Then: - -```json -{ - "data": { - "finishCreateOrUpdateFile": { - "datasetId": "did:odf:fed01…", - "contentHash": "f162…", - "version": 1, - "message": "…", - "error": null - } - } -} -``` - -`message` on this result is passed through from the storage layer, so its exact wording varies and is deliberately not shown here — it is **not part of the contract**. Assert on `error == null`, never on `message`. - -Keep `datasetId` — Tutorial 4 attaches it to an announcement. - -**If it fails:** - -| Symptom | Cause | Fix | -| ------- | ----- | --- | -| `initiate` → `UNAUTHORIZED` | The wallet behind the token has no write role on this lab | You must be Owner or Contributor. See [Tutorial 3](#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app) | -| `PUT` → `403` | URL expired (~15 min), or headers altered | Re-run `initiate`; send the returned `headers` verbatim | -| `PUT` → `400`/`411` | Body wasn't sent as raw bytes | Send the buffer, not a JSON wrapper. In curl: `--data-binary` | -| `finish` → `VALIDATION_FAILED`, `details.field: "path"` | `path` contains an underscore, or both `path` and `ref` were sent | Underscores are not allowed in `path`; use `path` for a new file **or** `ref` for a new version, never both | -| `finish` → `VALIDATION_FAILED`, `reason: INVALID_TAGS_OR_CATEGORIES` | Unknown tag or category | Valid values come from the public `fileCategoriesAndTags` query | -| `finish` → `VALIDATION_FAILED`, `reason: INVALID_ACCESS_LEVEL` | Bad `accessLevel` | One of `PUBLIC`, `HOLDERS`, `ADMIN` | - -### Step 5: Verify it worked - -Two checks. The first needs nothing but your consumer credential: - -```javascript -const verify = await graphql( - `query Verify($oclId: String!) { - labWithDataRoomAndFiles(oclId: $oclId) { - oclId - shortname - name - dataRoom { - id - files { path contentType accessLevel version createdBy downloadUrl } - } - } - }`, - { oclId }, -); - -const file = verify.labWithDataRoomAndFiles.dataRoom.files.find( - (f) => f.path.endsWith(basename(filePath)), -); -if (!file) throw new Error("File not found in the data room"); -console.log("Verified:", file.path, file.accessLevel, "v" + file.version); -``` - -Your file is in `dataRoom.files` with `accessLevel: "PUBLIC"` and `version: 1`. Because the file is public, `downloadUrl` is a fetchable presigned URL — `fetch` it and compare the bytes to what you uploaded for an end-to-end check. - -If `labWithDataRoomAndFiles` comes back `null`, the lab is not registered: Step 3 didn't complete. This is one of only two nullable queries on the Labs API, so a missing lab nulls the field instead of throwing. - -The second check is visual. Once `shortname` is populated, the lab has a page at `${LAB_APP_URL}/projects/` — `https://testnet.labs.molecule.xyz/projects/` on staging. - -### Tutorial 1: complete script - -All five steps in one file, against staging. No pre-issued service token needed. - -```javascript -#!/usr/bin/env node -import { readFileSync } from "node:fs"; -import { basename } from "node:path"; -import { - createPublicClient, - createWalletClient, - http, - parseAbi, - parseEventLogs, -} from "viem"; -import { privateKeyToAccount } from "viem/accounts"; -import { baseSepolia } from "viem/chains"; // production: `base` - -// ---- Staging (Base Sepolia) config — see "Running in Production" to swap ---- -const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql"; -const CHAIN = baseSepolia; -const FACTORY_ADDRESS = "0xd629FE2310b4309a212495F10A47f8436dcEfD90"; // OnChainLabFactory -const LABNFT_ADDRESS = "0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28"; // LabNFT (proxy) -const LAB_APP_URL = "https://testnet.labs.molecule.xyz"; -const SERVICE_NAME = "tutorial-agent"; - -const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; // mol__ — no "Bearer" prefix -const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY; - -let serviceToken; - -async function graphql(query, variables) { - const headers = { "Content-Type": "application/json", Authorization: CONSUMER_CREDENTIAL }; - if (serviceToken) headers["X-Service-Token"] = serviceToken; - const res = await fetch(GRAPHQL_URL, { - method: "POST", - headers, - body: JSON.stringify({ query, variables }), - }); - const { data, errors } = await res.json(); - if (errors) throw new Error(JSON.stringify(errors)); - return data; -} - -function assertOk(result, op) { - if (result.error) { - const { code, message, requestId } = result.error; - throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`); - } - return result; -} - -async function main() { - const filePath = process.argv[2]; - if (!filePath) throw new Error("Usage: node tutorial-1.js "); - - const account = privateKeyToAccount(WALLET_PRIVATE_KEY); - const publicClient = createPublicClient({ chain: CHAIN, transport: http() }); - const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() }); - - // ---- Step 1: service token ---- - const signInMessage = await graphql( - `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { - getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } - }`, - { walletAddress: account.address, serviceName: SERVICE_NAME }, - ); - const messageSignature = await walletClient.signMessage({ - message: signInMessage.getServiceSignInMessage.message, - }); - const tokenResult = await graphql( - `mutation GenerateServiceToken($serviceName: String!, $walletAddress: String!, $messageSignature: String!) { - generateServiceToken(serviceName: $serviceName, walletAddress: $walletAddress, messageSignature: $messageSignature) { - token - error { code message requestId retryable details } - } - }`, - { serviceName: SERVICE_NAME, walletAddress: account.address, messageSignature }, - ); - assertOk(tokenResult.generateServiceToken, "generateServiceToken"); - serviceToken = tokenResult.generateServiceToken.token; - console.log("1/5 Got service token"); - - // ---- Step 2: mint the LabNFT ---- - const factoryAbi = parseAbi([ - "function mintAndCreateAccount(address to) external payable returns (address account, uint256 tokenId)", - ]); - const labNftAbi = parseAbi([ - "function mintFeeWei() external view returns (uint256)", - "event OclIdentityCreated(address indexed account, bytes32 indexed oclId, uint256 indexed tokenId, bytes32 salt, uint256 canonicalChainId)", - ]); - - const mintFeeWei = await publicClient.readContract({ - address: LABNFT_ADDRESS, - abi: labNftAbi, - functionName: "mintFeeWei", - }); - const mintTxHash = await walletClient.writeContract({ - address: FACTORY_ADDRESS, - abi: factoryAbi, - functionName: "mintAndCreateAccount", - args: [account.address], - value: mintFeeWei, - }); - const mintReceipt = await publicClient.waitForTransactionReceipt({ hash: mintTxHash }); - const [identity] = parseEventLogs({ - abi: labNftAbi, - eventName: "OclIdentityCreated", - logs: mintReceipt.logs.filter((l) => l.address.toLowerCase() === LABNFT_ADDRESS.toLowerCase()), - }); - const oclId = identity.args.oclId; - console.log("2/5 Minted LabNFT — tx:", mintTxHash, "oclId:", oclId); - - // ---- Step 3: register the lab ---- - const createLabResult = await graphql( - `mutation CreateLab($oclId: String!) { - createLab(input: { oclId: $oclId }) { - message - error { code message requestId retryable details } - lab { shortname labAccountAddress labNftTokenId } - } - }`, - { oclId }, - ); - assertOk(createLabResult.createLab, "createLab"); - console.log("3/5 Lab registered — TBA:", createLabResult.createLab.lab.labAccountAddress); - - // ---- Step 4: upload the file ---- - const bytes = readFileSync(filePath); - const initiateResult = await graphql( - `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { - initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { - uploadToken uploadUrl method headers { key value } - error { code message requestId retryable details } - } - }`, - { oclId, contentType: "application/octet-stream", contentLength: bytes.length }, - ); - assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); - const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile; - - const uploadHeaders = {}; - headers.forEach((h) => (uploadHeaders[h.key] = h.value)); - const putResponse = await fetch(uploadUrl, { - method: method || "PUT", - headers: uploadHeaders, - body: bytes, - }); - if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status} ${putResponse.statusText}`); - - const finishResult = await graphql( - `mutation Finish($oclId: String!, $uploadToken: String!, $path: String!, $accessLevel: String!, $changeBy: String!) { - finishCreateOrUpdateFile(oclId: $oclId, uploadToken: $uploadToken, path: $path, accessLevel: $accessLevel, changeBy: $changeBy) { - datasetId version - error { code message requestId retryable details } - } - }`, - { - oclId, - uploadToken, - path: basename(filePath), - accessLevel: "PUBLIC", - changeBy: account.address, - }, - ); - assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); - const { datasetId } = finishResult.finishCreateOrUpdateFile; - console.log("4/5 Uploaded — datasetId:", datasetId); - - // ---- Step 5: verify ---- - const verify = await graphql( - `query Verify($oclId: String!) { - labWithDataRoomAndFiles(oclId: $oclId) { - shortname - dataRoom { files { path accessLevel version } } - } - }`, - { oclId }, - ); - const lab = verify.labWithDataRoomAndFiles; - if (!lab) throw new Error("Lab not found — createLab did not complete"); - const file = lab.dataRoom.files.find((f) => f.path.endsWith(basename(filePath))); - if (!file) throw new Error("File not found in the data room"); - console.log("5/5 Verified:", file.path, file.accessLevel, "v" + file.version); - if (lab.shortname) console.log("Lab page:", `${LAB_APP_URL}/projects/${lab.shortname}`); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); -``` - -**Usage:** - -```bash -WALLET_PRIVATE_KEY="0x..." \ -CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" \ -node tutorial-1.js ./research-data.csv -``` - -*** - -## Tutorial 2: Upload an encrypted file - -Same lab, same three-call upload — but the bytes are AES-256-GCM encrypted locally before they leave your machine, and decryption is gated on live onchain state. Encryption is a first-class flow, not an appendix: reach for it whenever the file is confidential and access should follow the lab's roles. - -**Steps 1–3 are identical to [Tutorial 1](#tutorial-1-create-a-lab-and-upload-a-public-file)** — get a service token, mint, register. Pick up here with `oclId`, `labAccountAddress`, `account` and `serviceToken` already in hand (or with any lab you already hold a role on). - -Conceptually: the backend hands you a one-shot data encryption key (DEK) in two forms — plaintext, and wrapped by the key custodian. You encrypt with the plaintext copy, throw it away, and store the wrapped copy in the file's metadata alongside the conditions under which the custodian may unwrap it again. Full model: [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md). - -### Step 4a: Get a DEK - -```javascript -const dekResult = await graphql(` - mutation { - generateDataEncryptionKey { - plaintextDEK - encryptedDek - encryptionSystem - error { code message requestId retryable details } - } - } -`); -assertOk(dekResult.generateDataEncryptionKey, "generateDataEncryptionKey"); -const { plaintextDEK, encryptedDek, encryptionSystem } = dekResult.generateDataEncryptionKey; -``` - -**Expected response:** - -```json -{ - "data": { - "generateDataEncryptionKey": { - "plaintextDEK": "3q2+7wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", - "encryptedDek": "AQIDAHjR…", - "encryptionSystem": "kms", - "error": null - } - } -} -``` - -`encryptionSystem` is **backend-set** — echo it verbatim in Step 4d, never hardcode `"kms"`. Key custody is on a path to threshold cryptography, and echoing the value is what keeps your integration working across that change. - -Requires authentication (service token or Privy session), so Step 1 must have run. - -### Step 4b: Encrypt locally - -```javascript -import { webcrypto, randomBytes, createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { basename } from "node:path"; - -const filePath = "./confidential-results.csv"; -const plaintext = readFileSync(filePath); - -const cryptoKey = await webcrypto.subtle.importKey( - "raw", - Buffer.from(plaintextDEK, "base64"), - "AES-GCM", - false, // not extractable — the key cannot be read back out - ["encrypt"], -); -const iv = randomBytes(12); // 96-bit IV, fresh per file — never reuse one -const ciphertext = Buffer.from( - await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, cryptoKey, plaintext), -); -// Hash of the PLAINTEXT — this is what a reader checks after decrypting. -const contentHashHex = "sha256-" + createHash("sha256").update(plaintext).digest("hex"); -``` - -The plaintext DEK is now unreferenced; don't log it, don't persist it, don't send it anywhere. Web Crypto's `AES-GCM` appends the 16-byte authentication tag to the ciphertext, which is what the decrypt side expects. - -### Step 4c: Write the access conditions - -`accessControlConditions` is a JSON-stringified array of predicates the backend re-evaluates against live chain state every time someone asks to decrypt. Two recipes cover almost everything. - -**Owner only** — only the LabNFT owner (and authorised signers of its Token Bound Account, so a Safe's signers resolve through) can decrypt: - -```javascript -const ownerOnlyConditions = JSON.stringify([ - { - conditionType: "evmContract", - contractAddress: ACCESS_RESOLVER_ADDRESS, - chain: ACCESS_CONDITION_CHAIN, - functionName: "isAuthorizedSignerForTba", - functionParams: [":userAddress", labAccountAddress], - functionAbi: { - name: "isAuthorizedSignerForTba", - inputs: [ - { name: "signer", type: "address" }, - { name: "account", type: "address" }, - ], - outputs: [{ name: "", type: "bool" }], - stateMutability: "view", - type: "function", - }, - returnValueTest: { key: "", comparator: "=", value: "true" }, - }, -]); -``` - -**Owner or Contributor or Viewer** — the recipe to use when the lab has a team, and the one Tutorial 3's agent needs. `hasRole` is hierarchical (`ROLE_VIEWER = 1`; a Contributor and the Owner both pass a Viewer check), and the explicit `isAuthorizedSignerForTba` branch keeps the Owner covered even if conditions are ever evaluated against a non-canonical chain: - -```javascript -const accessResolverAbi = { - isAuthorizedSignerForTba: { - name: "isAuthorizedSignerForTba", - inputs: [ - { name: "signer", type: "address" }, - { name: "account", type: "address" }, - ], - outputs: [{ name: "", type: "bool" }], - stateMutability: "view", - type: "function", - }, - hasRole: { - name: "hasRole", - inputs: [ - { name: "oclId", type: "bytes32" }, - { name: "account", type: "address" }, - { name: "role", type: "uint8" }, - ], - outputs: [{ name: "", type: "bool" }], - stateMutability: "view", - type: "function", - }, -}; - -const teamConditions = JSON.stringify([ - { - conditionType: "evmContract", - contractAddress: ACCESS_RESOLVER_ADDRESS, - chain: ACCESS_CONDITION_CHAIN, - functionName: "isAuthorizedSignerForTba", - functionParams: [":userAddress", labAccountAddress], - functionAbi: accessResolverAbi.isAuthorizedSignerForTba, - returnValueTest: { key: "", comparator: "=", value: "true" }, - }, - { operator: "or" }, - { - conditionType: "evmContract", - contractAddress: ACCESS_RESOLVER_ADDRESS, - chain: ACCESS_CONDITION_CHAIN, - functionName: "hasRole", - functionParams: [oclId, ":userAddress", "1"], // "1" = ROLE_VIEWER; "2" = ROLE_CONTRIBUTOR and up - functionAbi: accessResolverAbi.hasRole, - returnValueTest: { key: "", comparator: "=", value: "true" }, - }, -]); -``` - -`:userAddress` is substituted with the authenticated caller's wallet at evaluation time. Pass `"2"` instead of `"1"` to exclude Viewers. Evaluation walks the array left to right and short-circuits (`or` stops at the first true); **any RPC error fails closed** and the DEK is not released. The full condition grammar, including `EvmBasicCondition`, is on [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md#worked-example-encrypt-for-owner-or-contributor-or-viewer). - -### Step 4d: Upload the ciphertext - -The same three calls as Tutorial 1, with the ciphertext in place of the raw file and `encryptionMetadata` attached on finish. - -```javascript -const initiateResult = await graphql( - `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { - initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { - uploadToken uploadUrl method headers { key value } - error { code message requestId retryable details } - } - }`, - // Content-length is the CIPHERTEXT length, and the type is opaque bytes. - { oclId, contentType: "application/octet-stream", contentLength: ciphertext.length }, -); -assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); -const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile; - -const uploadHeaders = {}; -headers.forEach((h) => (uploadHeaders[h.key] = h.value)); -const putResponse = await fetch(uploadUrl, { - method: method || "PUT", - headers: uploadHeaders, - body: ciphertext, -}); -if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status} ${putResponse.statusText}`); - -const finishResult = await graphql( - `mutation Finish( - $oclId: String! - $uploadToken: String! - $path: String! - $accessLevel: String! - $changeBy: String! - $encryptionMetadata: EncryptionMetadataInput - ) { - finishCreateOrUpdateFile( - oclId: $oclId - uploadToken: $uploadToken - path: $path - accessLevel: $accessLevel - changeBy: $changeBy - encryptionMetadata: $encryptionMetadata - ) { - datasetId - version - message - error { code message requestId retryable details } - } - }`, - { - oclId, - uploadToken, - path: basename(filePath), - accessLevel: "HOLDERS", // encrypted files use HOLDERS or ADMIN, not PUBLIC - changeBy: account.address, - encryptionMetadata: { - encryptionSystem, // echo verbatim — never hardcode - encryptedDek, - iv: iv.toString("base64"), - contentHash: contentHashHex, - accessControlConditions: teamConditions, - encryptedBy: account.address.toLowerCase(), - encryptedAt: new Date().toISOString(), - }, - }, -); -assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); -``` - -**If it fails:** - -| `error.code` | What happened | Fix | -| ------------ | ------------- | --- | -| `VALIDATION_FAILED`, `reason: INVALID_CONDITIONS` | `accessControlConditions` isn't a valid stringified condition array | It is a **JSON string**, not an object. Check `functionAbi` is complete and `returnValueTest` present | -| `VALIDATION_FAILED`, `reason: INVALID_ACCESS_LEVEL` | `PUBLIC` on an encrypted file | Use `HOLDERS` or `ADMIN` | -| `UNAUTHORIZED` on `generateDataEncryptionKey` | No write role on the lab | Owner or Contributor required | - -### Step 5: Verify by decrypting it - -The real test is a round trip: ask the backend to unwrap the DEK, decrypt, and compare hashes. `decryptDataKey` re-evaluates the file's conditions against **live** chain state and your token's wallet, so a success here proves the gate works. - -```javascript -const decryptResult = await graphql( - `mutation DecryptDataKey($oclId: String!, $filePath: String!) { - decryptDataKey(oclId: $oclId, filePath: $filePath) { - plaintextDEK - iv - error { code message requestId retryable details } - } - }`, - { oclId, filePath: basename(filePath) }, -); -assertOk(decryptResult.decryptDataKey, "decryptDataKey"); -const { plaintextDEK: unwrappedDEK, iv: returnedIv } = decryptResult.decryptDataKey; - -// Fetch the ciphertext back from the data room -const fileQuery = await graphql( - `query GetFile($oclId: String!, $path: String!) { - dataRoomFile(oclId: $oclId, path: $path) { - path - accessLevel - downloadUrl - encryptionMetadata { encryptionSystem contentHash encryptedBy encryptedAt } - } - }`, - { oclId, path: basename(filePath) }, -); -const downloaded = Buffer.from( - await (await fetch(fileQuery.dataRoomFile.downloadUrl)).arrayBuffer(), -); - -const decryptKey = await webcrypto.subtle.importKey( - "raw", - Buffer.from(unwrappedDEK, "base64"), - "AES-GCM", - false, - ["decrypt"], -); -const recovered = Buffer.from( - await webcrypto.subtle.decrypt( - { name: "AES-GCM", iv: Buffer.from(returnedIv, "base64") }, - decryptKey, - downloaded, - ), -); - -const recoveredHash = "sha256-" + createHash("sha256").update(recovered).digest("hex"); -if (recoveredHash !== fileQuery.dataRoomFile.encryptionMetadata.contentHash) { - throw new Error("Content hash mismatch after decryption"); -} -console.log("Round trip verified —", recovered.length, "bytes recovered"); -``` - -**Expected:** the hashes match and `recovered` equals your original file byte-for-byte. - -**If it fails:** - -| `error.code` | What happened | Fix | -| ------------ | ------------- | --- | -| `UNAUTHORIZED` | Your wallet does not satisfy the file's conditions | Check the role with the public `listLabMembers(oclId)` query. After a fresh grant, allow for indexer/chain lag and retry | -| `FAILED_PRECONDITION`, `reason: LEGACY_ENCRYPTION` | The file predates onchain-verified envelope encryption | Not decryptable through this mutation; use the original encryption client | -| `FAILED_PRECONDITION`, `reason: NOT_ENCRYPTED` / `MISSING_DEK` | The file has no `encryptionMetadata` | You're pointing at a public file | -| `UPSTREAM_UNAVAILABLE` | Condition evaluation could not reach the chain RPC | Fails closed by design. `retryable: true` | -| Web Crypto throws `OperationError` | Wrong IV, or ciphertext truncated | Use the `iv` **returned by `decryptDataKey`**, and pass the whole downloaded body including the trailing GCM tag | - -### Tutorial 2: complete script - -Steps 1–3 are Tutorial 1's verbatim; this script carries them so it runs standalone. - -```javascript -#!/usr/bin/env node -import { webcrypto, randomBytes, createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { basename } from "node:path"; -import { - createPublicClient, - createWalletClient, - http, - parseAbi, - parseEventLogs, -} from "viem"; -import { privateKeyToAccount } from "viem/accounts"; -import { baseSepolia } from "viem/chains"; // production: `base` - -const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql"; -const CHAIN = baseSepolia; -const FACTORY_ADDRESS = "0xd629FE2310b4309a212495F10A47f8436dcEfD90"; -const LABNFT_ADDRESS = "0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28"; -const ACCESS_RESOLVER_ADDRESS = "0x5493F472602C87318EA5Eff753cDD593bf9bF559"; -const ACCESS_CONDITION_CHAIN = "baseSepolia"; -const SERVICE_NAME = "tutorial-agent"; - -const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; -const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY; -// Optional: reuse an existing lab instead of minting a new one. -const EXISTING_OCL_ID = process.env.OCL_ID; - -let serviceToken; - -async function graphql(query, variables) { - const headers = { "Content-Type": "application/json", Authorization: CONSUMER_CREDENTIAL }; - if (serviceToken) headers["X-Service-Token"] = serviceToken; - const res = await fetch(GRAPHQL_URL, { - method: "POST", - headers, - body: JSON.stringify({ query, variables }), - }); - const { data, errors } = await res.json(); - if (errors) throw new Error(JSON.stringify(errors)); - return data; -} - -function assertOk(result, op) { - if (result.error) { - const { code, message, requestId } = result.error; - throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`); - } - return result; -} - -const accessResolverAbi = { - isAuthorizedSignerForTba: { - name: "isAuthorizedSignerForTba", - inputs: [ - { name: "signer", type: "address" }, - { name: "account", type: "address" }, - ], - outputs: [{ name: "", type: "bool" }], - stateMutability: "view", - type: "function", - }, - hasRole: { - name: "hasRole", - inputs: [ - { name: "oclId", type: "bytes32" }, - { name: "account", type: "address" }, - { name: "role", type: "uint8" }, - ], - outputs: [{ name: "", type: "bool" }], - stateMutability: "view", - type: "function", - }, -}; - -function buildTeamConditions(oclId, labAccountAddress) { - return JSON.stringify([ - { - conditionType: "evmContract", - contractAddress: ACCESS_RESOLVER_ADDRESS, - chain: ACCESS_CONDITION_CHAIN, - functionName: "isAuthorizedSignerForTba", - functionParams: [":userAddress", labAccountAddress], - functionAbi: accessResolverAbi.isAuthorizedSignerForTba, - returnValueTest: { key: "", comparator: "=", value: "true" }, - }, - { operator: "or" }, - { - conditionType: "evmContract", - contractAddress: ACCESS_RESOLVER_ADDRESS, - chain: ACCESS_CONDITION_CHAIN, - functionName: "hasRole", - functionParams: [oclId, ":userAddress", "1"], - functionAbi: accessResolverAbi.hasRole, - returnValueTest: { key: "", comparator: "=", value: "true" }, - }, - ]); -} - -async function main() { - const filePath = process.argv[2]; - if (!filePath) throw new Error("Usage: node tutorial-2.js "); - - const account = privateKeyToAccount(WALLET_PRIVATE_KEY); - const publicClient = createPublicClient({ chain: CHAIN, transport: http() }); - const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() }); - - // ---- Step 1: service token ---- - const signInMessage = await graphql( - `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { - getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } - }`, - { walletAddress: account.address, serviceName: SERVICE_NAME }, - ); - const messageSignature = await walletClient.signMessage({ - message: signInMessage.getServiceSignInMessage.message, - }); - const tokenResult = await graphql( - `mutation GenerateServiceToken($serviceName: String!, $walletAddress: String!, $messageSignature: String!) { - generateServiceToken(serviceName: $serviceName, walletAddress: $walletAddress, messageSignature: $messageSignature) { - token - error { code message requestId retryable details } - } - }`, - { serviceName: SERVICE_NAME, walletAddress: account.address, messageSignature }, - ); - assertOk(tokenResult.generateServiceToken, "generateServiceToken"); - serviceToken = tokenResult.generateServiceToken.token; - console.log("1/6 Got service token"); - - // ---- Steps 2 & 3: mint + register (skipped when OCL_ID is provided) ---- - let oclId = EXISTING_OCL_ID; - let labAccountAddress; - - if (oclId) { - const existing = await graphql( - `query($oclId: String!) { labWithDataRoomAndFiles(oclId: $oclId) { labAccountAddress } }`, - { oclId }, - ); - if (!existing.labWithDataRoomAndFiles) throw new Error(`Lab ${oclId} not found`); - labAccountAddress = existing.labWithDataRoomAndFiles.labAccountAddress; - console.log("2-3/6 Reusing lab", oclId); - } else { - const factoryAbi = parseAbi([ - "function mintAndCreateAccount(address to) external payable returns (address account, uint256 tokenId)", - ]); - const labNftAbi = parseAbi([ - "function mintFeeWei() external view returns (uint256)", - "event OclIdentityCreated(address indexed account, bytes32 indexed oclId, uint256 indexed tokenId, bytes32 salt, uint256 canonicalChainId)", - ]); - const mintFeeWei = await publicClient.readContract({ - address: LABNFT_ADDRESS, - abi: labNftAbi, - functionName: "mintFeeWei", - }); - const mintTxHash = await walletClient.writeContract({ - address: FACTORY_ADDRESS, - abi: factoryAbi, - functionName: "mintAndCreateAccount", - args: [account.address], - value: mintFeeWei, - }); - const mintReceipt = await publicClient.waitForTransactionReceipt({ hash: mintTxHash }); - const [identity] = parseEventLogs({ - abi: labNftAbi, - eventName: "OclIdentityCreated", - logs: mintReceipt.logs.filter((l) => l.address.toLowerCase() === LABNFT_ADDRESS.toLowerCase()), - }); - oclId = identity.args.oclId; - labAccountAddress = identity.args.account; - console.log("2/6 Minted LabNFT — oclId:", oclId); - - const createLabResult = await graphql( - `mutation CreateLab($oclId: String!) { - createLab(input: { oclId: $oclId }) { - error { code message requestId retryable details } - lab { labAccountAddress } - } - }`, - { oclId }, - ); - assertOk(createLabResult.createLab, "createLab"); - console.log("3/6 Lab registered"); - } - - // ---- Step 4a: DEK ---- - const dekResult = await graphql(` - mutation { - generateDataEncryptionKey { - plaintextDEK encryptedDek encryptionSystem - error { code message requestId retryable details } - } - } - `); - assertOk(dekResult.generateDataEncryptionKey, "generateDataEncryptionKey"); - const { plaintextDEK, encryptedDek, encryptionSystem } = dekResult.generateDataEncryptionKey; - - // ---- Step 4b: encrypt locally ---- - const plaintext = readFileSync(filePath); - const cryptoKey = await webcrypto.subtle.importKey( - "raw", - Buffer.from(plaintextDEK, "base64"), - "AES-GCM", - false, - ["encrypt"], - ); - const iv = randomBytes(12); - const ciphertext = Buffer.from( - await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, cryptoKey, plaintext), - ); - const contentHashHex = "sha256-" + createHash("sha256").update(plaintext).digest("hex"); - console.log("4/6 Encrypted locally —", plaintext.length, "→", ciphertext.length, "bytes"); - - // ---- Step 4c + 4d: conditions + upload ---- - const initiateResult = await graphql( - `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { - initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { - uploadToken uploadUrl method headers { key value } - error { code message requestId retryable details } - } - }`, - { oclId, contentType: "application/octet-stream", contentLength: ciphertext.length }, - ); - assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); - const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile; - - const uploadHeaders = {}; - headers.forEach((h) => (uploadHeaders[h.key] = h.value)); - const putResponse = await fetch(uploadUrl, { - method: method || "PUT", - headers: uploadHeaders, - body: ciphertext, - }); - if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status} ${putResponse.statusText}`); - - const finishResult = await graphql( - `mutation Finish($oclId: String!, $uploadToken: String!, $path: String!, $accessLevel: String!, $changeBy: String!, $encryptionMetadata: EncryptionMetadataInput) { - finishCreateOrUpdateFile(oclId: $oclId, uploadToken: $uploadToken, path: $path, accessLevel: $accessLevel, changeBy: $changeBy, encryptionMetadata: $encryptionMetadata) { - datasetId version - error { code message requestId retryable details } - } - }`, - { - oclId, - uploadToken, - path: basename(filePath), - accessLevel: "HOLDERS", - changeBy: account.address, - encryptionMetadata: { - encryptionSystem, - encryptedDek, - iv: iv.toString("base64"), - contentHash: contentHashHex, - accessControlConditions: buildTeamConditions(oclId, labAccountAddress), - encryptedBy: account.address.toLowerCase(), - encryptedAt: new Date().toISOString(), - }, - }, - ); - assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); - console.log("5/6 Uploaded — datasetId:", finishResult.finishCreateOrUpdateFile.datasetId); - - // ---- Step 5: verify by decrypting ---- - const decryptResult = await graphql( - `mutation DecryptDataKey($oclId: String!, $filePath: String!) { - decryptDataKey(oclId: $oclId, filePath: $filePath) { - plaintextDEK iv - error { code message requestId retryable details } - } - }`, - { oclId, filePath: basename(filePath) }, - ); - assertOk(decryptResult.decryptDataKey, "decryptDataKey"); - - const fileQuery = await graphql( - `query GetFile($oclId: String!, $path: String!) { - dataRoomFile(oclId: $oclId, path: $path) { - downloadUrl - encryptionMetadata { contentHash } - } - }`, - { oclId, path: basename(filePath) }, - ); - const downloaded = Buffer.from( - await (await fetch(fileQuery.dataRoomFile.downloadUrl)).arrayBuffer(), - ); - const decryptKey = await webcrypto.subtle.importKey( - "raw", - Buffer.from(decryptResult.decryptDataKey.plaintextDEK, "base64"), - "AES-GCM", - false, - ["decrypt"], - ); - const recovered = Buffer.from( - await webcrypto.subtle.decrypt( - { name: "AES-GCM", iv: Buffer.from(decryptResult.decryptDataKey.iv, "base64") }, - decryptKey, - downloaded, - ), - ); - const recoveredHash = "sha256-" + createHash("sha256").update(recovered).digest("hex"); - if (recoveredHash !== fileQuery.dataRoomFile.encryptionMetadata.contentHash) { - throw new Error("Content hash mismatch after decryption"); - } - console.log("6/6 Round trip verified —", recovered.length, "bytes recovered"); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); -``` - -**Usage:** - -```bash -WALLET_PRIVATE_KEY="0x..." \ -CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" \ -node tutorial-2.js ./confidential-results.csv - -# or against a lab you already have -OCL_ID="0x0101…" WALLET_PRIVATE_KEY="0x..." CONSUMER_CREDENTIAL="mol_…" \ -node tutorial-2.js ./confidential-results.csv -``` - -*** - -## Tutorial 3: Give your agent access to a lab you created in the app - -The most common real-world shape: a researcher created their Lab in the Labs app with an email address — no wallet, no code — and now wants an agent contributing to it. The agent gets its **own** identity rather than borrowing the human's; the human grants it a role; the agent authenticates itself from then on. - -**Who does what:** - -| # | Actor | Action | -| - | ----- | ------ | -| 1 | Agent | Generate a wallet and report its address | -| 2 | Human | Add that address to the lab as **Contributor**, flagged as an agent, with an expiry | -| 3 | Agent | Self-issue a service token by signing the sign-in message | -| 4 | Agent | Upload files and announce; the human sees the result in the app | - -The human never hands over a private key, a token, or their session. Revoking the agent is one onchain revoke, and it does not touch anything else. - -### Step 1: The agent reports its address - -With the plugin: run `wallet_address`. With viem: - -```javascript -import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; - -// Generate once, store it as the agent's own secret — never the human's key. -const agentPrivateKey = process.env.AGENT_PRIVATE_KEY ?? generatePrivateKey(); -const agentAccount = privateKeyToAccount(agentPrivateKey); -console.log("Agent wallet address:", agentAccount.address); -``` - -Give that address to the lab owner. The agent needs **no gas** for this tutorial — it never sends a transaction, only signs a message. (Persist `agentPrivateKey` if you generated it, or the next run is a different agent with no role.) - -### Step 2: The human grants Contributor - -In the Labs app, the lab owner adds the agent's address to the lab's members and grants it the **Contributor** role, setting: - -* **`isAgent = true`** — informational metadata that marks the member as an agent identity in the members list and UI. It does not change authorisation. -* **an expiry** — typically the agent's session lifetime. When it lapses the agent loses access until it is re-granted; a permanent grant is possible but not the default you want for an agent. - -Members can be invited by wallet address, ENS name or email, and the app sponsors the gas for the grant. Under the hood this is one onchain call on the `AccessResolver`, which the owner (or an existing Contributor, for Viewer grants) can also make directly: - -```solidity -function grantRole(bytes32 oclId, address account, uint8 role, uint64 expiry, bool isAgent) external; -// role: 2 = ROLE_CONTRIBUTOR, 1 = ROLE_VIEWER -``` - -Only the **Owner** may grant Contributor. Contributors can grant Viewer, but not Contributor. Full capability matrix: [Roles & Permissions](../../technical-deep-dive/roles-and-permissions.md). - -**Why Contributor and not Viewer:** a Viewer can decrypt and read but cannot write. Uploading files and posting announcements needs Contributor. - -Both parties can confirm the grant landed with a public query — no authentication beyond the consumer credential: - -```javascript -const members = await graphql( - `query ListLabMembers($oclId: String!) { - listLabMembers(oclId: $oclId) { - members { walletAddress role isAgent expiry grantedAt } - } - }`, - { oclId }, -); -const grant = members.listLabMembers.members.find( - (m) => m.walletAddress.toLowerCase() === agentAccount.address.toLowerCase(), -); -console.log("Agent role:", grant?.role, "expiry:", grant?.expiry ?? "permanent"); -``` - -Expect `role: "CONTRIBUTOR"` and `isAgent: true`. `expiry` is unix seconds as a decimal string, or `null` for a permanent grant. Expired grants are excluded from this list entirely, so a missing entry after a while means the grant lapsed. - -### Step 3: The agent self-issues a service token - -Identical to Tutorial 1 Step 1, signed by the **agent's** wallet: - -```javascript -const AGENT_SERVICE_NAME = "research-agent-1"; - -const signInMessage = await graphql( - `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { - getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } - }`, - { walletAddress: agentAccount.address, serviceName: AGENT_SERVICE_NAME }, -); - -const messageSignature = await agentAccount.signMessage({ - message: signInMessage.getServiceSignInMessage.message, -}); - -const tokenResult = await graphql( - `mutation GenerateServiceToken( - $serviceName: String! - $walletAddress: String! - $messageSignature: String! - $expiresIn: String - ) { - generateServiceToken( - serviceName: $serviceName - walletAddress: $walletAddress - messageSignature: $messageSignature - expiresIn: $expiresIn - ) { - token tokenId expiresAt - error { code message requestId retryable details } - } - }`, - { - serviceName: AGENT_SERVICE_NAME, - walletAddress: agentAccount.address, - messageSignature, - expiresIn: "30d", // match the role grant's expiry rather than taking the 180d default - }, -); -assertOk(tokenResult.generateServiceToken, "generateServiceToken"); -serviceToken = tokenResult.generateServiceToken.token; -``` - -Issuance is **not** gated on holding a role — any wallet can mint a token for itself. The role is what makes the token *useful*: authorisation is resolved per request from the token's wallet against the lab you name. So a token issued before the grant lands keeps working once it does; you do not need to re-issue it. - -### Step 4: The agent uploads and announces - -From here the agent is an ordinary caller. Run [Tutorial 1 Step 4](#step-4-upload-the-file) with `changeBy: agentAccount.address`, or [Tutorial 2](#tutorial-2-upload-an-encrypted-file) for a confidential file — the `hasRole` branch of the team conditions is exactly what lets the agent decrypt too. Then [Tutorial 4](#tutorial-4-announce-the-dataset) to surface it on the lab's feed. - -Writes by a Contributor service token are gated per mutation, matching the Privy user path: `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createAnnouncement`, `deleteDataRoomFile`, `updateFileMetadata` and `moveEntry` all accept Contributor. A few surfaces remain **Owner-only** and an agent Contributor cannot reach them: `updateLabNftMetadata`, `generateLabImageUploadUrl` and the legal-agreement mutations. - -{% hint style="warning" %} -**Retry on `UNAUTHORIZED` right after the grant.** Role state reaches the API through an event indexer, so there is a short window after `grantRole` confirms onchain in which a write still returns `UNAUTHORIZED` (`reason: NOT_CONTRIBUTOR`). It is not a permissions problem and re-issuing the token will not help — wait and retry: - -```javascript -async function withRoleGrantRetry(fn, { attempts = 5, baseMs = 2000 } = {}) { - for (let i = 0; i < attempts; i++) { - try { - return await fn(); - } catch (err) { - const isAuthzLag = /UNAUTHORIZED/.test(String(err)); - if (!isAuthzLag || i === attempts - 1) throw err; - await new Promise((r) => setTimeout(r, baseMs * 2 ** i)); // 2s, 4s, 8s, 16s - } - } -} - -await withRoleGrantRetry(() => uploadFile(oclId, "./findings.csv")); -``` -{% endhint %} - -### Step 5: Verify from both sides - -**The agent** verifies as in Tutorial 1 Step 5 — the file is in `dataRoom.files` with `createdBy` set to the agent's address: - -```javascript -const verify = await graphql( - `query Verify($oclId: String!) { - labWithDataRoomAndFiles(oclId: $oclId) { - shortname - dataRoom { files { path accessLevel version createdBy } } - announcements { headline changeBy } - } - }`, - { oclId }, -); -``` - -**The human** verifies in the app: the file appears in the lab's data room and the announcement on its activity feed, both attributed to the agent's address, which the members list shows flagged as an agent. - -### Revoking the agent - -One onchain call, and the agent's writes stop: - -```solidity -function revokeRole(bytes32 oclId, address account) external; // Owner only, for a Contributor -``` - -Or let the grant's `expiry` lapse. Independently, the agent's token can be killed with `revokeServiceToken(tokenId)` — a token can only revoke or extend **its own** record, so one agent cannot interfere with another's. - -*** - -## Tutorial 4: Announce the dataset - -An announcement is the lab's public update stream: a headline, a body, and optionally the datasets it is about. Attaching the file makes the announcement the discoverable surface for it — announcements are indexed by `searchLabs` alongside files, and they appear on the lab's activity feed and public page. - -Requires Owner or Contributor (a Viewer cannot announce). Pick up with `oclId`, `serviceToken` and the `datasetId` returned by `finishCreateOrUpdateFile`. - -```javascript -const announcementResult = await graphql( - `mutation CreateAnnouncement( - $oclId: String! - $headline: String! - $body: String! - $attachments: [String!] - ) { - createAnnouncement(oclId: $oclId, headline: $headline, body: $body, attachments: $attachments) { - message - error { code message requestId retryable details } - } - }`, - { - oclId, - headline: "Baseline assay results published", - body: "First replicate set for the ApoB series. 240 samples, three conditions. Raw CSV attached.", - attachments: [datasetId], // the datasetId from finishCreateOrUpdateFile - }, -); -assertOk(announcementResult.createAnnouncement, "createAnnouncement"); -``` - -**Expected response:** - -```json -{ - "data": { - "createAnnouncement": { - "message": "…", - "error": null - } - } -} -``` - -`createAnnouncement` returns no announcement object, and its `message` is passed through from the storage layer rather than being a fixed string — so success is `error == null` and nothing else. Verify by reading the announcement back. - -**If it fails:** - -| `error.code` | What happened | Fix | -| ------------ | ------------- | --- | -| `UNAUTHORIZED` | Caller is a Viewer, or has no role | Contributor or Owner required. Fresh grant? See the [retry note](#step-4-the-agent-uploads-and-announces) | -| `NOT_FOUND` | An `attachments` entry isn't a dataset in this lab | Pass the exact `datasetId` strings from `finishCreateOrUpdateFile`, from this lab | -| `VALIDATION_FAILED` | Empty `headline` or `body` | Both are required and non-empty | - -### Verify it worked - -```javascript -const feed = await graphql( - `query LabActivity($oclId: String!) { - labActivity(oclId: $oclId, page: 0, perPage: 10, filter: ANNOUNCEMENT) { - nodes { - __typename - ... on LabEventAnnouncement { - announcement { - id - headline - body - changeBy - eventTime - attachments { path contentType accessLevel } - } - } - } - pageInfo { totalPages currentPage } - } - }`, - { oclId }, -); -console.log(JSON.stringify(feed.labActivity.nodes[0], null, 2)); -``` - -Your announcement is the newest node, with `attachments` resolved to the full file objects — not just ids — and `changeBy` set to the wallet that posted it. It is also on the lab's public page at `${LAB_APP_URL}/projects/`. - -`labActivity` is a **public** query: anyone with a consumer credential can read the feed, which is the point of an announcement. - -*** - -## Running in Production - -Everything above runs against staging (Base Sepolia, testnet funds). To run the same scripts against production, replace the values in the config block — nothing else changes, since every step reads from these constants: - -| Constant | Staging (these tutorials) | Production | -| -------- | ------------------------- | ---------- | -| `GRAPHQL_URL` | `https://staging.graphql.api.molecule.xyz/graphql` | `https://production.graphql.api.molecule.xyz/graphql` | -| `CHAIN` (viem import) | `baseSepolia` from `viem/chains` | `base` from `viem/chains` | -| `FACTORY_ADDRESS` | `0xd629FE2310b4309a212495F10A47f8436dcEfD90` | `0xECdF4f05384056507485C90aeAb0a83268760D6E` | -| `LABNFT_ADDRESS` | `0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28` | `0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92` | -| `ACCESS_RESOLVER_ADDRESS` | `0x5493F472602C87318EA5Eff753cDD593bf9bF559` | `0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B` | -| `ACCESS_CONDITION_CHAIN` | `"baseSepolia"` | `"base"` | -| `LAB_APP_URL` | `https://testnet.labs.molecule.xyz` | `https://labs.molecule.xyz` | - -```javascript -import { base } from "viem/chains"; // instead of baseSepolia - -const GRAPHQL_URL = "https://production.graphql.api.molecule.xyz/graphql"; -const CHAIN = base; -const FACTORY_ADDRESS = "0xECdF4f05384056507485C90aeAb0a83268760D6E"; -const LABNFT_ADDRESS = "0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92"; -const ACCESS_RESOLVER_ADDRESS = "0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B"; -const ACCESS_CONDITION_CHAIN = "base"; -const LAB_APP_URL = "https://labs.molecule.xyz"; -``` - -A few things follow automatically from that swap: - -* **Headers and the `graphql()` helper** are identical — `Authorization` (consumer credential, no `Bearer`) and the self-issued `X-Service-Token` work the same against both endpoints. Note that credentials are **per environment**: a staging `mol_` credential does not authenticate against production. -* **The `mintFeeWei()` read** already queries the live contract, so it picks up whatever fee production has configured with no code change. It reads `0` today on both chains. -* **The condition ABIs** are unchanged; only `contractAddress` and `chain` differ, and both come from the config block. - -What doesn't follow automatically, and is on you: - -* **Real funds.** Minting on `base` spends real ETH. Test on staging first. -* **Introspection is off in production** and query depth is capped at 10. Generate types against staging — see [Getting the schema](../getting-started/README.md#getting-the-schema). -* **`SERVICE_NAME`** should identify the real integration; it is echoed into the sign-in message and stored against the issued token. -* Full deployment list, including every other OCL contract on both chains: [Contracts reference](../../references/contracts/README.md). +If you arrived here from a bookmark or an old link, [Tutorial 1](../getting-started/tutorial-1-public-upload.md) is the direct replacement for what used to be on this page — minus the assignment-agreement step, which is [no longer a gate on anything](../changelog.md#assignment-agreement-is-no-longer-a-gate-and-is-out-of-the-api-docs). diff --git a/api-reference/labs-api/files.md b/api-reference/labs-api/files.md index 574413b..d40c48a 100644 --- a/api-reference/labs-api/files.md +++ b/api-reference/labs-api/files.md @@ -2,7 +2,7 @@ Working with files in a Lab dataroom: the three-step upload flow (initiate → upload → finish), plus announcements, metadata updates, deletion, storage limits, and client-side encryption. Creating the Lab itself is covered in [Lab Management](lab-management.md). -> **Looking for a runnable walkthrough?** This page is the per-operation reference. For a first upload with expected responses and failure handling at every step, use [Tutorial 1](example-workflow.md#tutorial-1-create-a-lab-and-upload-a-public-file) (public file) or [Tutorial 2](example-workflow.md#tutorial-2-upload-an-encrypted-file) (encrypted, with a decrypt round trip). +> **Looking for a runnable walkthrough?** This page is the per-operation reference. For a first upload with expected responses and failure handling at every step, use [Tutorial 1](../getting-started/tutorial-1-public-upload.md) (public file) or [Tutorial 2](../getting-started/tutorial-2-encrypted-upload.md) (encrypted, with a decrypt round trip). > **Note**: Every mutation on this page returns its failure in-band: the result carries `error: ApiError`, and success means `error` is `null`. Branch on `error.code` — never on `message` text — and quote `requestId` when reporting a problem. See [Error Handling](README.md#error-handling) for the `ApiError` shape, how to read `details`, and the list of error codes. @@ -708,7 +708,7 @@ Enhance file discoverability with optional metadata: ## Advanced: Encrypted File Upload -> **Step-by-step version:** [Tutorial 2 — Upload an encrypted file](example-workflow.md#tutorial-2-upload-an-encrypted-file), including both access-condition recipes (owner-only, and owner/contributor/viewer) and a decrypt round trip that verifies the gate actually works. +> **Step-by-step version:** [Tutorial 2 — Upload an encrypted file](../getting-started/tutorial-2-encrypted-upload.md), including both access-condition recipes (owner-only, and owner/contributor/viewer) and a decrypt round trip that verifies the gate actually works. For files requiring client-side encryption, obtain a data encryption key via the `generateDataEncryptionKey` mutation, encrypt locally, upload as normal, and include an `encryptionMetadata` object on `finishCreateOrUpdateFile`. The full end-to-end model — key wrapping, onchain access conditions, and condition-gated decryption — is documented on the [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md) page. diff --git a/api-reference/labs-api/lab-management.md b/api-reference/labs-api/lab-management.md index 61c7851..e11cbfd 100644 --- a/api-reference/labs-api/lab-management.md +++ b/api-reference/labs-api/lab-management.md @@ -6,7 +6,7 @@ Operations for creating and administering a Lab: creating the dataroom, managing ## Mint the LabNFT -Before `createLab` can attach a dataroom, an onchain lab (OCL) has to exist: a LabNFT minted to your wallet with its ERC-6551 account (Token Bound Account) deployed. This step is **onchain only** — there is no Labs API mutation for it. See [Lab Creation](../../technical-deep-dive/architecture.md#lab-creation) for the contract-level flow and [Molecule Labs](../../technical-deep-dive/onchain-lab.md) for what a Lab is. If you'd rather not touch contracts directly, the Molecule app does this for you — see [Creating a Lab](../../user-guides/scientists-researchers.md#creating-a-lab). For a runnable end-to-end version of the mint, see [Tutorial 1 Step 2](example-workflow.md#step-2-mint-the-labnft). +Before `createLab` can attach a dataroom, an onchain lab (OCL) has to exist: a LabNFT minted to your wallet with its ERC-6551 account (Token Bound Account) deployed. This step is **onchain only** — there is no Labs API mutation for it. See [Lab Creation](../../technical-deep-dive/architecture.md#lab-creation) for the contract-level flow and [Molecule Labs](../../technical-deep-dive/onchain-lab.md) for what a Lab is. If you'd rather not touch contracts directly, the Molecule app does this for you — see [Creating a Lab](../../user-guides/scientists-researchers.md#creating-a-lab). For a runnable end-to-end version of the mint, see [Tutorial 1 Step 2](../getting-started/tutorial-1-public-upload.md#step-2-mint-the-labnft). ### Contract Addresses @@ -302,7 +302,7 @@ Issue it yourself — no request, no waiting. Two calls with the owner wallet: 1. `getServiceSignInMessage(walletAddress, serviceName)` — public query, returns the message to sign. 2. Sign it verbatim (EIP-191 `personal_sign`), then `generateServiceToken(serviceName, walletAddress, messageSignature)` — returns the JWT for `X-Service-Token`. -Full parameters and bounds: [Service Tokens](service-tokens.md#obtaining-a-token). Runnable: [Tutorial 1 Step 1](example-workflow.md#step-1-get-a-service-token). +Full parameters and bounds: [Service Tokens](service-tokens.md#obtaining-a-token). Runnable: [Tutorial 1 Step 1](../getting-started/tutorial-1-public-upload.md#step-1-get-a-service-token). The only credential you have to request is the **consumer credential** — see [Getting Started](../getting-started/README.md#1-a-mol-consumer-credential-the-one-manual-step) for the request template. diff --git a/api-reference/labs-api/service-tokens.md b/api-reference/labs-api/service-tokens.md index 290fde1..dc40138 100644 --- a/api-reference/labs-api/service-tokens.md +++ b/api-reference/labs-api/service-tokens.md @@ -6,7 +6,7 @@ A service token is the credential that proves *which wallet* a write request act ## Obtaining a Token -Two calls, no human in the loop — the path for autonomous agents, bots and CI/CD pipelines that have no browser-based Privy session. Fetch the deterministic sign-in message, sign it with the service wallet, then exchange the signature for a token. For the runnable version, see [Tutorial 1 Step 1](example-workflow.md#step-1-get-a-service-token). +Two calls, no human in the loop — the path for autonomous agents, bots and CI/CD pipelines that have no browser-based Privy session. Fetch the deterministic sign-in message, sign it with the service wallet, then exchange the signature for a token. For the runnable version, see [Tutorial 1 Step 1](../getting-started/tutorial-1-public-upload.md#step-1-get-a-service-token). Issuance is **not** gated on holding a role on any lab: any wallet can mint a token for itself. The role is what makes the token useful. diff --git a/user-guides/developers-ai-agents.md b/user-guides/developers-ai-agents.md index 048751e..b6bffc0 100644 --- a/user-guides/developers-ai-agents.md +++ b/user-guides/developers-ai-agents.md @@ -57,7 +57,7 @@ The simplest agent integration is read-only: query a Lab's data room for files, A write-enabled agent goes further: it reads data, performs analysis, and writes results back as new files in the Lab's data room. This requires both a consumer credential and a service token. Two paths to a service token: -* **Long-lived service token** — the agent mints its own via the `generateServiceToken` mutation, by signing a message with its wallet (a Privy session works too). No provisioning request. The token is a JWT tied to that wallet; write authorization is resolved per request from the wallet's onchain role on the target Lab. Content writes need **Contributor**. The end-to-end version — agent wallet, human grants the role, agent self-issues and uploads — is [Tutorial 3](../api-reference/labs-api/example-workflow.md#tutorial-3-give-your-agent-access-to-a-lab-you-created-in-the-app). +* **Long-lived service token** — the agent mints its own via the `generateServiceToken` mutation, by signing a message with its wallet (a Privy session works too). No provisioning request. The token is a JWT tied to that wallet; write authorization is resolved per request from the wallet's onchain role on the target Lab. Content writes need **Contributor**. The end-to-end version — agent wallet, human grants the role, agent self-issues and uploads — is [Tutorial 3](../api-reference/getting-started/tutorial-3-agent-access.md). * **Pay-per-call via the** [**x402 Gateway**](../api-reference/x402-gateway.md) — for agents that serve external users, charge per request, or don't have pre-provisioned credentials. The gateway settles a USDC payment on Base per call and mints a short-lived (default 5-minute) service token scoped to one mutation. Available for `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createAnnouncement`, `createLab`, `generateDataEncryptionKey`, and `decryptDataKey`. The three-step upload flow (initiate, PUT, finalize) lets you write any file type with metadata including descriptions, tags, categories, and searchable content text. If the file should be confidential, first request a key via `generateDataEncryptionKey` — the backend returns a one-shot plaintext DEK (plus its encrypted form) you use to encrypt locally before upload, attaching the encryption metadata on finish. Every file the agent writes becomes a permanent, versioned record in the Lab's history. From 70b1c65259f7d7e6dfd136e23c3a7ac67ce4b3a5 Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Mon, 31 Aug 2026 15:26:58 +0100 Subject: [PATCH 03/16] docs(auth): correct the sign-in flow for the IP-3013 single-use nonce getServiceSignInMessage is no longer deterministic: the message embeds a server-issued single-use nonce and its expiry, so a signature over it can neither be replayed nor cached. Verified against staging. - authentication.md dropped the "deterministic message" claim and now states the single-use rule, the 10-minute window and last-write-wins per (wallet, serviceName). - service-tokens.md selects the new `expiresAt`, documents the field, and carries the failure table keyed on details.reason: NONCE_NOT_FOUND (never requested, or already consumed), NONCE_EXPIRED, INVALID_SIGNATURE (altered text, or superseded by a later fetch) and WALLET_MISMATCH. Superseded returns INVALID_SIGNATURE, not NONCE_NOT_FOUND -- the record still exists, it just holds a newer nonce. - changelog.md announces the change with a migration note, and fixes an IPNFT agreements snippet that omitted the required `id` argument. Confirmed on staging: window measured at 10.02 min, replay rejected, supersession rejected, re-worded message rejected, expiry rejected (9/9 checks). Co-Authored-By: Claude Opus 5 (1M context) --- api-reference/authentication.md | 4 +++- api-reference/changelog.md | 29 +++++++++++++++++------ api-reference/labs-api/service-tokens.md | 30 ++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/api-reference/authentication.md b/api-reference/authentication.md index 9f88354..54dd740 100644 --- a/api-reference/authentication.md +++ b/api-reference/authentication.md @@ -139,10 +139,12 @@ Both are scoped to the caller's **own** tokens: the token presented must own the Self-service, two calls, no human in the loop. Full reference with parameters and failure modes: [Service Tokens](labs-api/service-tokens.md#obtaining-a-token). Runnable: [Tutorial 1 Step 1](getting-started/tutorial-1-public-upload.md#step-1-get-a-service-token). -1. **`getServiceSignInMessage(walletAddress, serviceName)`** — a public query returning the deterministic message to sign. +1. **`getServiceSignInMessage(walletAddress, serviceName)`** — a public query returning the message to sign, plus the `expiresAt` of the nonce embedded in it. 2. **Sign it verbatim** with the wallet, as a plain personal message (EIP-191 `personal_sign` — **not** typed data). Re-wording or re-formatting the string breaks verification. 3. **`generateServiceToken(serviceName, walletAddress, messageSignature, expiresIn)`** — returns the JWT to send as `X-Service-Token`, plus a `tokenId` for lifecycle operations. +> **The sign-in message is single-use and short-lived — fetch a fresh one before every signing.** It embeds a server-issued nonce and an expiry, so it is **not** deterministic and a signature over it cannot be replayed or cached. The nonce is valid for **10 minutes**, is consumed by the first successful `generateServiceToken`, and there is one outstanding nonce per `(walletAddress, serviceName)` pair — fetching a new message supersedes the previous one. Never reconstruct the string client-side; sign exactly what the query returned. Failure reasons: [Obtaining a Token](labs-api/service-tokens.md#obtaining-a-token). + | `expiresIn` | Value | | ----------- | ----- | | Default when omitted | `180d` | diff --git a/api-reference/changelog.md b/api-reference/changelog.md index c948d5a..78bc506 100644 --- a/api-reference/changelog.md +++ b/api-reference/changelog.md @@ -10,6 +10,19 @@ This page tracks breaking changes, deprecations, and additions across the Molecu ## Authentication +### The service-token sign-in message is now single-use and expires + +`getServiceSignInMessage` used to return a deterministic string — a pure function of `(walletAddress, serviceName)` — which meant one captured signature could mint fresh tokens indefinitely. The message now embeds a **server-issued single-use nonce** and its expiry, and `generateServiceToken` verifies and consumes that nonce: + +- The message is **valid for 10 minutes** from issuance. The new `expiresAt` field on `getServiceSignInMessage` reports the deadline. +- The nonce is **consumed on first successful redemption**. Issuing a second token requires a fresh message and a fresh signature. +- There is **one outstanding nonce per `(walletAddress, serviceName)`**, last-write-wins — calling the query again invalidates an unredeemed message. +- Signatures over the older nonce-free message format no longer verify. + +Failures come back as `UNAUTHENTICATED` with `details.reason` one of `NONCE_NOT_FOUND` (never requested, or already consumed), `NONCE_EXPIRED`, `INVALID_SIGNATURE` (altered text, or a message superseded by a later call) or `WALLET_MISMATCH`. + +**Migration:** Fetch the message immediately before signing, and treat every one of those reasons as "request a new message and sign it again" rather than as a retryable call — re-submitting the same signature can never succeed. Callers that already ran `getServiceSignInMessage` → sign → `generateServiceToken` back to back need no change; callers that cached the message, cached a signature, or reconstructed the string client-side must stop doing so. See [Obtaining a Token](labs-api/service-tokens.md#obtaining-a-token). + ### Service tokens are self-issued, and scoped to their own lifecycle Two clarifications and one hardening, all now reflected across the API docs: @@ -396,13 +409,15 @@ All new queries support `limit`, `skip`, `sortBy`, `sortOrder`, and `filterBy` p Agreements on IP-NFTs are now a fully queryable relation with sub-field selection, filtering, sorting, and pagination: ```graphql -ipnft { - agreements(limit: 10, sortBy: type, sortOrder: asc) { - id - contentHash - mimeType - type - url +query GetAgreements($id: ID!) { + ipnft(id: $id) { + agreements(limit: 10, sortBy: type, sortOrder: asc) { + id + contentHash + mimeType + type + url + } } } ``` diff --git a/api-reference/labs-api/service-tokens.md b/api-reference/labs-api/service-tokens.md index dc40138..187a2fd 100644 --- a/api-reference/labs-api/service-tokens.md +++ b/api-reference/labs-api/service-tokens.md @@ -6,7 +6,7 @@ A service token is the credential that proves *which wallet* a write request act ## Obtaining a Token -Two calls, no human in the loop — the path for autonomous agents, bots and CI/CD pipelines that have no browser-based Privy session. Fetch the deterministic sign-in message, sign it with the service wallet, then exchange the signature for a token. For the runnable version, see [Tutorial 1 Step 1](../getting-started/tutorial-1-public-upload.md#step-1-get-a-service-token). +Two calls, no human in the loop — the path for autonomous agents, bots and CI/CD pipelines that have no browser-based Privy session. Fetch a fresh sign-in message, sign it with the service wallet, then exchange the signature for a token. For the runnable version, see [Tutorial 1 Step 1](../getting-started/tutorial-1-public-upload.md#step-1-get-a-service-token). Issuance is **not** gated on holding a role on any lab: any wallet can mint a token for itself. The role is what makes the token useful. @@ -19,6 +19,7 @@ query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { serviceName: $serviceName ) { message + expiresAt } } ``` @@ -30,9 +31,23 @@ query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { Public query — no authentication required. +| Field | Description | +| ----- | ----------- | +| `message` | The exact string to sign. Contains a server-issued single-use nonce and its expiry, so **it changes on every call** | +| `expiresAt` | ISO-8601 expiry of the embedded nonce. After this, the signature is rejected and a new message must be requested | + +{% hint style="warning" %} +**Single-use, and valid for 10 minutes.** The sign-in message is not deterministic — do not cache it, do not cache a signature over it, and never reconstruct the string client-side. Concretely: + +* The nonce is **consumed** by the first successful `generateServiceToken`. Issuing a second token means fetching a new message and signing again. +* There is **one outstanding nonce per `(walletAddress, serviceName)`**, last-write-wins: calling this query again invalidates the message you have not yet redeemed. +* The window is **10 minutes** from issuance (`expiresAt`). Sign and redeem promptly rather than fetching a message ahead of time. +* Signatures over the older, nonce-free message format no longer verify. +{% endhint %} + **Step 2 — Exchange the signature for a token (`generateServiceToken`):** -Sign the returned `message` **verbatim** with the service wallet, as a plain personal message (EIP-191 `personal_sign` — **not** typed data). The backend recomposes and verifies the same string server-side, so re-wording or re-formatting it fails with `UNAUTHENTICATED` / `reason: INVALID_SIGNATURE`. Then submit the signature: +Sign the returned `message` **verbatim** with the service wallet, as a plain personal message (EIP-191 `personal_sign` — **not** typed data). The backend recomposes the same string from the stored nonce record and verifies it server-side, so re-wording or re-formatting it fails with `UNAUTHENTICATED` / `reason: INVALID_SIGNATURE`. Then submit the signature: ```graphql mutation GenerateServiceToken( @@ -86,6 +101,17 @@ mutation GenerateServiceToken( Success ⇔ `error == null`. On failure `error` carries the catalogue `code` (e.g. `UNAUTHENTICATED` when the signature does not verify), `message` mirrors `error.message`, and `token`, `tokenId`, `expiresAt` and `createdAt` are `null` (`serviceName` may echo the name you sent) — guard for `null`, not for empty strings, and branch on `error`, never on the token fields. +**Failure modes on the signature path** — all `UNAUTHENTICATED`, distinguished by `details.reason` (`details` is a JSON-encoded string, so `JSON.parse(error.details ?? "{}").reason`): + +| `reason` | What happened | Fix | +| -------- | ------------- | --- | +| `NONCE_NOT_FOUND` | No nonce record at all — you never called `getServiceSignInMessage` for this wallet + service, or the nonce was already consumed by an earlier token | Fetch a fresh message and sign it again. Do not retry the same signature | +| `NONCE_EXPIRED` | The message is older than its 10-minute window | Fetch a fresh message and sign it again | +| `INVALID_SIGNATURE` | The signed bytes are not the string the backend recomposes. Either the message was altered (re-formatted, rebuilt client-side, typed-data signing, the retired nonce-free format) **or it was superseded** — a later `getServiceSignInMessage` call replaced the stored nonce, so an earlier message no longer matches | Sign the `message` from the most recent call, byte-for-byte, with `personal_sign` | +| `WALLET_MISMATCH` | `walletAddress` is not the address that produced the signature | Pass the signing address | + +None of these are retryable as-is: every one of them means "get a new message and sign that". A `VALIDATION_FAILED` here refers to `walletAddress` format or `expiresIn` bounds instead. + ## Extending Token Expiration You can extend your service token's expiration using the `extendServiceToken` mutation. **Scoped to your own tokens:** the token you present must own the `tokenId` you name. A `tokenId` belonging to another wallet returns the same `NOT_FOUND` as one that does not exist, so token existence cannot be probed. From 2308d2097c430bf6195d48a03fd2757b360d5c5d Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Mon, 31 Aug 2026 15:27:14 +0100 Subject: [PATCH 04/16] docs(getting-started): retry indexer lag on the first write after a mint Tutorial 1 failed outright on 1 of 5 runs against staging: initiateCreateOrUpdateFile returned NOT_FOUND ("Project 0x... does not exist") immediately after createLab had returned success. Cause is an asymmetry, not a transient: onchain_lab rows are written by the event indexer that ingests the mint, and createLab tolerates their absence via its ownerOf fallback (auth-service.ts:615) while the content writes read the indexed record directly (auth-service.ts:283-300). So createLab can succeed on a lab that is not yet writable. - withIndexerLagRetry now lives in the shared setup block, with both lag cases named: NOT_FOUND after a mint, UNAUTHORIZED after a role grant. - Tutorial 1 Step 4 wraps its first call in it, in the snippet and the complete script, and warns that Step 3 succeeding is no guarantee. - Tutorial 3's withRoleGrantRetry is renamed to the same helper with codes: ["UNAUTHORIZED", "NOT_FOUND"], so both tutorials teach one idiom. - for-agents.md gains the rule as #8. - Both upload tutorials document `UPSTREAM_UNAVAILABLE: "Path is occupied"` as NOT retryable despite the code -- a re-run against the same lab. Fix is a new `path`, or `ref` to add a version. - Tutorial 1/3 Step 1 also carry the nonce rules from the previous commit, since they share those regions. - Tutorial 3 no longer implies isAgent must be true; a grant with isAgent=false authorized an upload on staging exactly the same. Verified: helper unit-tested as published (5/5), Tutorial 1 three clean runs, full T1->T2->T4 chain on lab-1542, Tutorial 3 clean on testing-lab. Co-Authored-By: Claude Opus 5 (1M context) --- api-reference/getting-started/README.md | 26 ++++- api-reference/getting-started/for-agents.md | 9 +- .../tutorial-1-public-upload.md | 100 ++++++++++++------ .../tutorial-2-encrypted-upload.md | 6 +- .../tutorial-3-agent-access.md | 33 +++--- 5 files changed, 123 insertions(+), 51 deletions(-) diff --git a/api-reference/getting-started/README.md b/api-reference/getting-started/README.md index 8828b65..5dea054 100644 --- a/api-reference/getting-started/README.md +++ b/api-reference/getting-started/README.md @@ -164,8 +164,32 @@ function assertOk(result, op) { } return result; } + +// Onchain state — a mint, a role grant — reaches the API through an event +// indexer, so a write issued immediately after one can fail on state the chain +// already has. Retry with backoff; re-issuing the token never helps. +async function withIndexerLagRetry(fn, { codes = ["NOT_FOUND"], attempts = 5, baseMs = 2000 } = {}) { + const laggy = new RegExp(codes.join("|")); + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err) { + if (!laggy.test(String(err)) || i === attempts - 1) throw err; + await new Promise((r) => setTimeout(r, baseMs * 2 ** i)); // 2s, 4s, 8s, 16s + } + } +} ``` +{% hint style="warning" %} +**Two places the indexer trails, and both need that retry.** A successful response does not mean every downstream read is caught up yet: + +* **After minting**, the lab's first write can return `NOT_FOUND` — even though `createLab` just succeeded, because `createLab` falls back to an onchain ownership check while the file mutations read the indexed record. [Tutorial 1 Step 4](tutorial-1-public-upload.md#step-4-upload-the-file). +* **After a role grant**, a write can return `UNAUTHORIZED` until the grant is indexed. [Tutorial 3](tutorial-3-agent-access.md#step-4-the-agent-uploads-and-announces). + +Both clear within seconds. Both are the retry above, with `codes` set to the one you expect. +{% endhint %} + *** ## Ten-minute quickstart @@ -177,7 +201,7 @@ export CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" export WALLET_PRIVATE_KEY="0x…" # funded on Base Sepolia ``` -1. **Self-issue a service token** — `getServiceSignInMessage` → sign the message with your wallet (EIP-191 `personal_sign`) → `generateServiceToken`. No human in the loop. +1. **Self-issue a service token** — `getServiceSignInMessage` → sign the message with your wallet (EIP-191 `personal_sign`) → `generateServiceToken`. No human in the loop. Keep the three calls together: the message carries a single-use nonce valid for 10 minutes. 2. **Mint the LabNFT** — `OnChainLabFactory.mintAndCreateAccount(yourAddress)` with `value: mintFeeWei()`. Read `oclId` off the `OclIdentityCreated` event. 3. **Register the lab** — `createLab(input: { oclId })`. 4. **Upload a file** — `initiateCreateOrUpdateFile` → `PUT` the bytes to the returned presigned URL → `finishCreateOrUpdateFile` with `accessLevel: "PUBLIC"`. diff --git a/api-reference/getting-started/for-agents.md b/api-reference/getting-started/for-agents.md index 5c7414b..6e535bf 100644 --- a/api-reference/getting-started/for-agents.md +++ b/api-reference/getting-started/for-agents.md @@ -44,11 +44,11 @@ Public queries take `Authorization` alone. Sending `X-Service-Token` on a public ```graphql query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { - getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message expiresAt } } ``` -Sign `message` **verbatim** with the wallet as a plain personal message (EIP-191 `personal_sign`, **not** typed data). Then: +Sign `message` **verbatim** with the wallet as a plain personal message (EIP-191 `personal_sign`, **not** typed data). `message` embeds a **single-use nonce valid for 10 minutes** — fetch it immediately before signing, never cache it or the signature, never rebuild the string yourself, and fetch a fresh one for every token. Then: ```graphql mutation GenerateServiceToken($serviceName: String!, $walletAddress: String!, $messageSignature: String!, $expiresIn: String) { @@ -153,13 +153,14 @@ Full recipe including `accessControlConditions`: [Tutorial 2](tutorial-2-encrypt ## Rules that break runs when ignored 1. No `Bearer` in front of a `mol_` credential. -2. Sign the sign-in message **verbatim**; any reformatting fails verification. +2. Sign the sign-in message **verbatim**; any reformatting fails verification. It is single-use and expires after 10 minutes, so a cached message or signature returns `UNAUTHENTICATED` — `NONCE_NOT_FOUND` once redeemed, `NONCE_EXPIRED` past the window, `INVALID_SIGNATURE` if a later fetch superseded it. The fix is always a fresh `getServiceSignInMessage`, never a retry of the same signature. 3. Success on a mutation is `error == null` — never a truthy payload field, and never a `message` string. 4. In-band `error.details` is a JSON string; thrown `errorInfo.details` is an object. They differ. 5. Filter mint receipt logs to the **LabNFT** address before decoding `OclIdentityCreated`. 6. Send the presigned `PUT` with the returned headers unchanged, and the raw bytes as the body. 7. Writing into a lab you do not own needs a **Contributor** role on it — see [Tutorial 3](tutorial-3-agent-access.md). After a role grant, an indexer lag of a few seconds can still return `UNAUTHORIZED`; retry with backoff. -8. Production has introspection off and a depth limit of 10. Generate types against staging. +8. **A successful `createLab` does not mean the lab is writable yet.** Step 4's first call can return `NOT_FOUND` ("Project 0x… does not exist") for a few seconds, because `createLab` falls back to an onchain ownership check while the file mutations read the indexed record. Retry `NOT_FOUND` with backoff on the first write after a mint; do not re-run `createLab`, which then returns `CONFLICT`. +9. Production has introspection off and a depth limit of 10. Generate types against staging. ## Related diff --git a/api-reference/getting-started/tutorial-1-public-upload.md b/api-reference/getting-started/tutorial-1-public-upload.md index 8eea9fc..8f0b82d 100644 --- a/api-reference/getting-started/tutorial-1-public-upload.md +++ b/api-reference/getting-started/tutorial-1-public-upload.md @@ -17,7 +17,9 @@ The default path, and the one to run first. Five steps: get a token, mint the La ## Step 1: Get a service token -Prove control of the wallet instead of waiting on a manually issued token — the self-service path for agents, bots and CI/CD. Fetch the deterministic sign-in message, sign it as a plain wallet message (EIP-191 `personal_sign` — **not** typed data), then exchange the signature for a token. Full reference: [Service Tokens](../labs-api/service-tokens.md#obtaining-a-token). +Prove control of the wallet instead of waiting on a manually issued token — the self-service path for agents, bots and CI/CD. Fetch a sign-in message, sign it as a plain wallet message (EIP-191 `personal_sign` — **not** typed data), then exchange the signature for a token. Full reference: [Service Tokens](../labs-api/service-tokens.md#obtaining-a-token). + +The message carries a server-issued single-use nonce and is valid for **10 minutes**, so these two calls belong together: fetch, sign, redeem. Neither the message nor the signature can be cached or reused. ```javascript import { createPublicClient, createWalletClient, http } from "viem"; @@ -31,17 +33,22 @@ const publicClient = createPublicClient({ chain: CHAIN, transport: http() }); // walletClient: everything that needs the private key — signing and sending. const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() }); +// Fetch this immediately before signing: the message embeds a single-use +// nonce that expires 10 minutes after issuance, and requesting a new one +// invalidates any previous message for this wallet + service. const signInMessage = await graphql( `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message + expiresAt } }`, { walletAddress: account.address, serviceName: SERVICE_NAME }, ); -// Sign the message VERBATIM — the backend recomposes and verifies the same -// string server-side, so re-wording or re-formatting it breaks verification. +// Sign the message VERBATIM — the backend recomposes the same string from the +// stored nonce and verifies it, so re-wording, re-formatting or rebuilding it +// client-side breaks verification. const messageSignature = await walletClient.signMessage({ message: signInMessage.getServiceSignInMessage.message, }); @@ -90,7 +97,9 @@ serviceToken = tokenResult.generateServiceToken.token; | `error.code` | What happened | Fix | | ------------ | ------------- | --- | -| `UNAUTHENTICATED`, `reason: INVALID_SIGNATURE` | The signed bytes are not the message the backend recomposes | Sign the returned `message` string byte-for-byte. Use `personal_sign` / viem's `signMessage`, not `signTypedData` | +| `UNAUTHENTICATED`, `reason: INVALID_SIGNATURE` | The signed bytes are not the message the backend recomposes — altered text, or a message superseded by a later `getServiceSignInMessage` call | Sign the most recent `message` byte-for-byte. Use `personal_sign` / viem's `signMessage`, not `signTypedData` | +| `UNAUTHENTICATED`, `reason: NONCE_NOT_FOUND` | No nonce on file — never requested for this wallet + service, or already redeemed by an earlier token | Re-run the query and sign the new `message`. Retrying the same signature never works | +| `UNAUTHENTICATED`, `reason: NONCE_EXPIRED` | More than 10 minutes passed between fetching the message and redeeming it | Re-run the query and sign the new `message` | | `UNAUTHENTICATED`, `reason: WALLET_MISMATCH` | `walletAddress` isn't the signer | Pass the same address that signed | | `VALIDATION_FAILED` | Bad `expiresIn` | Format is ``, unit one of `s m h d w M y`; between 1 hour and 2 years | | HTTP `401` before GraphQL runs | Consumer credential missing or malformed | Check `Authorization` — no `Bearer` prefix. See [Authentication](../authentication.md) | @@ -204,6 +213,10 @@ DID-linking for the new lab starts automatically in the background; [`getDidLink Three calls: get a presigned URL, `PUT` the bytes, finalise with metadata. Full reference: [Files](../labs-api/files.md). +{% hint style="warning" %} +**This is the one step that can fail on a lab you just created.** `createLab` succeeding does not yet mean the lab is writable: it falls back to an onchain ownership check when the mint has not been indexed, while the file mutations read the indexed record and return `NOT_FOUND` until it lands. Wrap the first call in [`withIndexerLagRetry`](README.md#shared-setup) — without it this step fails outright on a fresh mint often enough to matter. +{% endhint %} + ```javascript import { readFileSync } from "node:fs"; import { basename } from "node:path"; @@ -211,22 +224,25 @@ import { basename } from "node:path"; const filePath = "./research-data.csv"; const bytes = readFileSync(filePath); -// 4a. Get a presigned URL -const initiateResult = await graphql( - `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { - initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { - uploadToken - uploadUrl - uploadUrlExpiry - method - headers { key value } - error { code message requestId retryable details } - } - }`, - { oclId, contentType: "text/csv", contentLength: bytes.length }, -); -assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); -const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile; +// 4a. Get a presigned URL. Retried: the mint may not be indexed yet, even +// though createLab already returned success. +const initiateResult = await withIndexerLagRetry(async () => { + const result = await graphql( + `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { + initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { + uploadToken + uploadUrl + uploadUrlExpiry + method + headers { key value } + error { code message requestId retryable details } + } + }`, + { oclId, contentType: "text/csv", contentLength: bytes.length }, + ); + return assertOk(result.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); +}); +const { uploadToken, uploadUrl, method, headers } = initiateResult; // 4b. PUT the bytes with EXACTLY the returned headers const uploadHeaders = {}; @@ -326,12 +342,14 @@ Keep `datasetId` — Tutorial 4 attaches it to an announcement. | Symptom | Cause | Fix | | ------- | ----- | --- | +| `initiate` → `NOT_FOUND`, "Project 0x… does not exist" | The mint is not indexed yet. `createLab` can succeed before this is true, so a successful Step 3 is no guarantee | Retry with backoff — `withIndexerLagRetry` above. It clears in seconds. Do **not** re-run `createLab`, which returns `CONFLICT` once registered | | `initiate` → `UNAUTHORIZED` | The wallet behind the token has no write role on this lab | You must be Owner or Contributor. See [Tutorial 3](tutorial-3-agent-access.md) | | `PUT` → `403` | URL expired (~15 min), or headers altered | Re-run `initiate`; send the returned `headers` verbatim | | `PUT` → `400`/`411` | Body wasn't sent as raw bytes | Send the buffer, not a JSON wrapper. In curl: `--data-binary` | | `finish` → `VALIDATION_FAILED`, `details.field: "path"` | `path` contains an underscore, or both `path` and `ref` were sent | Underscores are not allowed in `path`; use `path` for a new file **or** `ref` for a new version, never both | | `finish` → `VALIDATION_FAILED`, `reason: INVALID_TAGS_OR_CATEGORIES` | Unknown tag or category | Valid values come from the public `fileCategoriesAndTags` query | | `finish` → `VALIDATION_FAILED`, `reason: INVALID_ACCESS_LEVEL` | Bad `accessLevel` | One of `PUBLIC`, `HOLDERS`, `ADMIN` | +| `finish` → `UPSTREAM_UNAVAILABLE`, "Path is occupied" | A file already lives at that `path` — the usual cause is re-running this tutorial against the same lab | **Not retryable despite the code**: retrying sends the identical request and fails identically. Either pick a new `path`, or send `ref` (the previous `datasetId`) instead of `path` to add a version to the existing file | ## Step 5: Verify it worked @@ -418,6 +436,20 @@ function assertOk(result, op) { return result; } +// The mint reaches the API through an event indexer, so the lab's first write +// can return NOT_FOUND for a few seconds after createLab already succeeded. +async function withIndexerLagRetry(fn, { codes = ["NOT_FOUND"], attempts = 5, baseMs = 2000 } = {}) { + const laggy = new RegExp(codes.join("|")); + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err) { + if (!laggy.test(String(err)) || i === attempts - 1) throw err; + await new Promise((r) => setTimeout(r, baseMs * 2 ** i)); // 2s, 4s, 8s, 16s + } + } +} + async function main() { const filePath = process.argv[2]; if (!filePath) throw new Error("Usage: node tutorial-1.js "); @@ -427,9 +459,11 @@ async function main() { const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() }); // ---- Step 1: service token ---- + // Fetch → sign → redeem, back to back: the message holds a single-use nonce + // that expires 10 minutes after issuance. const signInMessage = await graphql( `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { - getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message expiresAt } }`, { walletAddress: account.address, serviceName: SERVICE_NAME }, ); @@ -495,17 +529,19 @@ async function main() { // ---- Step 4: upload the file ---- const bytes = readFileSync(filePath); - const initiateResult = await graphql( - `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { - initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { - uploadToken uploadUrl method headers { key value } - error { code message requestId retryable details } - } - }`, - { oclId, contentType: "application/octet-stream", contentLength: bytes.length }, - ); - assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); - const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile; + // Retried: createLab returning success does not mean the mint is indexed yet. + const { uploadToken, uploadUrl, method, headers } = await withIndexerLagRetry(async () => { + const result = await graphql( + `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { + initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { + uploadToken uploadUrl method headers { key value } + error { code message requestId retryable details } + } + }`, + { oclId, contentType: "application/octet-stream", contentLength: bytes.length }, + ); + return assertOk(result.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); + }); const uploadHeaders = {}; headers.forEach((h) => (uploadHeaders[h.key] = h.value)); diff --git a/api-reference/getting-started/tutorial-2-encrypted-upload.md b/api-reference/getting-started/tutorial-2-encrypted-upload.md index 0c3f784..be5ff5c 100644 --- a/api-reference/getting-started/tutorial-2-encrypted-upload.md +++ b/api-reference/getting-started/tutorial-2-encrypted-upload.md @@ -238,6 +238,8 @@ assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); | `VALIDATION_FAILED`, `reason: INVALID_CONDITIONS` | `accessControlConditions` isn't a valid stringified condition array | It is a **JSON string**, not an object. Check `functionAbi` is complete and `returnValueTest` present | | `VALIDATION_FAILED`, `reason: INVALID_ACCESS_LEVEL` | `PUBLIC` on an encrypted file | Use `HOLDERS` or `ADMIN` | | `UNAUTHORIZED` on `generateDataEncryptionKey` | No write role on the lab | Owner or Contributor required | +| `UPSTREAM_UNAVAILABLE`, "Path is occupied" on `finish` | A file already exists at that `path` — usually a re-run against the same lab | **Not retryable despite the code.** Pick a new `path`, or send `ref` (the previous `datasetId`) instead to add a version | +| `NOT_FOUND` on the first call after a mint | The mint is not indexed yet, even though `createLab` succeeded | Retry with [`withIndexerLagRetry`](README.md#shared-setup) — see [Tutorial 1 Step 4](tutorial-1-public-upload.md#step-4-upload-the-file) | ## Step 5: Verify by decrypting it @@ -419,9 +421,11 @@ async function main() { const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() }); // ---- Step 1: service token ---- + // Fetch → sign → redeem, back to back: the message holds a single-use nonce + // that expires 10 minutes after issuance. const signInMessage = await graphql( `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { - getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message expiresAt } }`, { walletAddress: account.address, serviceName: SERVICE_NAME }, ); diff --git a/api-reference/getting-started/tutorial-3-agent-access.md b/api-reference/getting-started/tutorial-3-agent-access.md index d04ba6c..09a19c6 100644 --- a/api-reference/getting-started/tutorial-3-agent-access.md +++ b/api-reference/getting-started/tutorial-3-agent-access.md @@ -74,18 +74,18 @@ const grant = members.listLabMembers.members.find( console.log("Agent role:", grant?.role, "expiry:", grant?.expiry ?? "permanent"); ``` -Expect `role: "CONTRIBUTOR"` and `isAgent: true`. `expiry` is unix seconds as a decimal string, or `null` for a permanent grant. Expired grants are excluded from this list entirely, so a missing entry after a while means the grant lapsed. +Expect `role: "CONTRIBUTOR"`. `isAgent` simply echoes the flag the owner set — `false` there changes nothing about what the agent may do, so do not treat it as a failed grant. `expiry` is unix seconds as a decimal string, or `null` for a permanent grant. Expired grants are excluded from this list entirely, so a missing entry after a while means the grant lapsed. ## Step 3: The agent self-issues a service token -Identical to Tutorial 1 Step 1, signed by the **agent's** wallet: +Identical to Tutorial 1 Step 1, signed by the **agent's** wallet. Fetch the message and redeem it in one go — it embeds a single-use nonce valid for 10 minutes, so an agent that waits for the human's role grant between fetching and signing will hit `UNAUTHENTICATED` / `reason: NONCE_EXPIRED`. Poll for the grant first (Step 2), then sign in: ```javascript const AGENT_SERVICE_NAME = "research-agent-1"; const signInMessage = await graphql( `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { - getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message expiresAt } }`, { walletAddress: agentAccount.address, serviceName: AGENT_SERVICE_NAME }, ); @@ -134,19 +134,20 @@ Writes by a Contributor service token are gated per mutation, matching the Privy **Retry on `UNAUTHORIZED` right after the grant.** Role state reaches the API through an event indexer, so there is a short window after `grantRole` confirms onchain in which a write still returns `UNAUTHORIZED` (`reason: NOT_CONTRIBUTOR`). It is not a permissions problem and re-issuing the token will not help — wait and retry: ```javascript -async function withRoleGrantRetry(fn, { attempts = 5, baseMs = 2000 } = {}) { +async function withIndexerLagRetry(fn, { codes = ["NOT_FOUND"], attempts = 5, baseMs = 2000 } = {}) { + const laggy = new RegExp(codes.join("|")); for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (err) { - const isAuthzLag = /UNAUTHORIZED/.test(String(err)); - if (!isAuthzLag || i === attempts - 1) throw err; + if (!laggy.test(String(err)) || i === attempts - 1) throw err; await new Promise((r) => setTimeout(r, baseMs * 2 ** i)); // 2s, 4s, 8s, 16s } } } -await withRoleGrantRetry(() => uploadFile(oclId, "./findings.csv")); +// Same helper as Tutorial 1, with the code this step expects. +await withIndexerLagRetry(() => uploadFile(oclId, "./findings.csv"), { codes: ["UNAUTHORIZED"] }); ``` {% endhint %} @@ -226,13 +227,15 @@ function assertOk(result, op) { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Writes can return UNAUTHORIZED for a few seconds after a role grant confirms -// onchain — the indexer trails the chain. Retry rather than re-issuing the token. -async function withRoleGrantRetry(fn, { attempts = 5, baseMs = 2000 } = {}) { +// onchain — the indexer trails the chain. Retry rather than re-issuing the +// token. Same helper as Tutorial 1, which retries NOT_FOUND after a mint. +async function withIndexerLagRetry(fn, { codes = ["NOT_FOUND"], attempts = 5, baseMs = 2000 } = {}) { + const laggy = new RegExp(codes.join("|")); for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (err) { - if (!/UNAUTHORIZED/.test(String(err)) || i === attempts - 1) throw err; + if (!laggy.test(String(err)) || i === attempts - 1) throw err; await sleep(baseMs * 2 ** i); // 2s, 4s, 8s, 16s } } @@ -275,9 +278,11 @@ async function main() { console.log("2/6 Role:", grant.role, "isAgent:", grant.isAgent, "expiry:", grant.expiry ?? "permanent"); // ---- Step 3: the agent self-issues a token ---- + // Only now, after the role poll above returned — the sign-in message holds a + // single-use nonce that expires 10 minutes after issuance. const signInMessage = await graphql( `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { - getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message expiresAt } }`, { walletAddress: agentAccount.address, serviceName: AGENT_SERVICE_NAME }, ); @@ -303,8 +308,10 @@ async function main() { console.log("3/6 Token issued, expires", tokenResult.generateServiceToken.expiresAt); // ---- Step 4: upload (public; see Tutorial 2 for the encrypted variant) ---- + // Retried on UNAUTHORIZED: the role grant may not be indexed yet. NOT_FOUND + // is included for the case where the lab itself was minted moments ago. const bytes = readFileSync(filePath); - const { datasetId } = await withRoleGrantRetry(async () => { + const { datasetId } = await withIndexerLagRetry(async () => { const initiateResult = await graphql( `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { @@ -343,7 +350,7 @@ async function main() { ); assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); return finishResult.finishCreateOrUpdateFile; - }); + }, { codes: ["UNAUTHORIZED", "NOT_FOUND"] }); console.log("4/6 Uploaded — datasetId:", datasetId); // ---- Step 4b: announce it ---- From 1e973bff517503ad6776f05734d28e8e0d18717a Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Mon, 31 Aug 2026 15:27:25 +0100 Subject: [PATCH 05/16] docs(api-reference): fix the Bearer contradictions and the remaining asks Follow-ups from the IP-3028 audit, each verified against staging. - browse-and-search.md: three public-endpoint callouts claimed the consumer credential goes in as `Authorization: Bearer`. It does not -- the same error already fixed in lab-management.md. - tokenization-api.md: the 401 row said to check `Authorization: Bearer`, contradicting the same page's own header section. Its two "contact the Molecule team" asks now point at the published Contracts reference for ABIs/addresses and at the single credential-request template for the rest. - ipnft-api-deprecated.md: the four-step credential ask collapses to that same template. - files.md: updateFileMetadata's parameter table omitted the required accessLevel, and `ref` now says explicitly that it is the datasetId, not the file path. - docs-sync.md: restore the api-reference/authentication.md row, dropped when the getting-started rows were added, and point service-tokens.md at the sign-in nonce constants and reason codes. Co-Authored-By: Claude Opus 5 (1M context) --- .github/prompts/docs-sync.md | 3 ++- api-reference/ipnft-api-deprecated.md | 7 +------ api-reference/labs-api/browse-and-search.md | 6 +++--- api-reference/labs-api/files.md | 3 ++- api-reference/tokenization-api.md | 11 ++++++----- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/.github/prompts/docs-sync.md b/.github/prompts/docs-sync.md index 672af3e..8e8a8a8 100644 --- a/.github/prompts/docs-sync.md +++ b/.github/prompts/docs-sync.md @@ -52,6 +52,7 @@ touches one of its source paths. | Docs page | Source paths in `desci-infra` | | -- | -- | | `api-reference/README.md` | `graphql/schemas/*.graphql` (surface inventory only), `lib/shared-api-stack.ts` | +| `api-reference/authentication.md` | `lambda/appsync-authorizer-lambda/**`, service-token resolvers in `lambda/appsync-resolver-labs-lambda/**` (`services/token-manager-service.ts`, `utils/service-auth-message.ts`), `docs/service-auth.md`. The header reference and the sign-in flow both live here: a change to the sign-in message, its nonce or its validity window touches this page **and** `labs-api/service-tokens.md` **and** the tutorials' Step 1 — fix all of them or none. | | `api-reference/getting-started/README.md` | The onboarding entry point. `lib/desci-api-app-sync/constructs/api-config.ts` (endpoints, introspection, depth limit), `lambda/x402-gateway-lambda/**` (prices/base URLs quoted in the cost table), `lambda/appsync-authorizer-lambda/**` (credential shape). **Contains verified live values** — `mintFeeWei()` readings, x402 prices, gateway URLs — each stamped with the date it was checked. Never update one of those from a diff alone: if the diff suggests a value changed, flag it in the PR body and let a human re-verify against the deployed environment. | | `api-reference/getting-started/for-agents.md` | Condensed mirror of Tutorial 1 plus the error contract. Sources: `graphql/schemas/ip-hubs.graphql`, `graphql/schemas/api-error.graphql`, `graphql/schemas/encryption.graphql`, `lambda/appsync-resolver-labs-lambda/**` (per-mutation role gates). **Must stay consistent with the tutorial pages** — a change to the flow touches this page and the relevant tutorial, or neither. | | `api-reference/getting-started/tutorial-1-public-upload.md` | Tutorial 1 — self-issue a token, mint, `createLab`, three-call public upload, verify. Sources: `graphql/schemas/ip-hubs.graphql`; `lambda/appsync-resolver-labs-lambda/**` for `generateServiceToken`, `createLab`, `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile` and their authorization gates; `services/token-manager-service.ts`; `lib/utils/token-expiration.ts` (the `expiresIn` default and bounds). Every code block is expected to be runnable — a signature change here is a real breakage, so correct it and say so in the PR body. Steps 1–3 are duplicated by reference in Tutorials 2 and 3: **fix all copies or none.** | @@ -64,7 +65,7 @@ touches one of its source paths. | `api-reference/labs-api/files.md` | file operations in `lambda/appsync-resolver-labs-lambda/**` (`initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata`, `moveEntry`), `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts` | | `api-reference/labs-api/browse-and-search.md` | `labs`, `searchLabs`, `labWithDataRoomAndFiles`, `dataRoomFile`, `activities`, `labActivity` resolvers; `graphql/schemas/onchain-activity.graphql` | | `api-reference/labs-api/legal-agreements.md` | `signLegalAgreement`, `legalAgreementTemplate`, `legalAgreementStatus` resolvers — **frozen and hidden** (`hidden: true`, out of `SUMMARY.md`) since IP-3028: the assignment agreement is no longer a gate on anything and the feature may be removed. Correct outright errors only; never expand it, and never re-introduce a `legalAgreement*` mention into `getting-started/**`, `authentication.md`, `labs-api/README.md` or `example-workflow.md` | -| `api-reference/labs-api/service-tokens.md` | `generateServiceToken`, `extendServiceToken`, `revokeServiceToken` resolvers in `lambda/appsync-resolver-labs-lambda/**` (`services/token-manager-service.ts`); `lambda/appsync-authorizer-lambda/**` | +| `api-reference/labs-api/service-tokens.md` | `generateServiceToken`, `extendServiceToken`, `revokeServiceToken` resolvers in `lambda/appsync-resolver-labs-lambda/**` (`services/token-manager-service.ts`); `utils/service-auth-message.ts` and the sign-in nonce constants (`SIGNIN_NONCE_VALIDITY_MS` — the documented 10-minute window) in `services/token-manager-service.ts`; `lambda/appsync-authorizer-lambda/**`. **The documented `details.reason` values (`NONCE_NOT_FOUND`, `NONCE_EXPIRED`, `INVALID_SIGNATURE`, `WALLET_MISMATCH`) come from the `generateServiceToken` resolver** — keep the table and the validity window in step with it | | `api-reference/tokenization-api.md` | `graphql/schemas/evm-tokenization.graphql`, `lambda/appsync-resolver-evm-tokenization/**`, `lib/evm-tokenization-service-stack.ts` | | `api-reference/x402-gateway.md` | `lambda/x402-gateway-lambda/**` | | `api-reference/ipnft-api-deprecated.md` | `lambda/desci-api-lambda/**` (legacy IPNFT resolvers), `lambda/desci-ipnfts-processor/**`, `lambda/ipnft-events-lambda/**` — **deprecated: correct errors, never expand** | diff --git a/api-reference/ipnft-api-deprecated.md b/api-reference/ipnft-api-deprecated.md index 6d3e3ef..7859f26 100644 --- a/api-reference/ipnft-api-deprecated.md +++ b/api-reference/ipnft-api-deprecated.md @@ -21,12 +21,7 @@ All IPNFT API requests require a consumer credential (see [Authentication](authe ### Obtaining a Consumer Credential -To request one: - -1. Join our [Discord community](https://t.co/L0VEiy4Bjk) -2. Contact the Molecule team -3. Provide your intended use case -4. You'll receive a consumer credential (`mol__`) +One credential covers every Molecule API, this one included. Request it on the [Molecule Discord](https://t.co/L0VEiy4Bjk) with the [request template](getting-started/README.md#1-a-mol-consumer-credential-the-one-manual-step) on Getting Started; what comes back is a single `mol__` string per environment. ### Using Your Consumer Credential diff --git a/api-reference/labs-api/browse-and-search.md b/api-reference/labs-api/browse-and-search.md index 4e30dc1..56c8cd9 100644 --- a/api-reference/labs-api/browse-and-search.md +++ b/api-reference/labs-api/browse-and-search.md @@ -10,7 +10,7 @@ Query operations for listing all labs and reading their activity feeds. To read Get all labs. This is a **public endpoint** - no authentication required. -> **🔓 Public Endpoint**: The `labs` query does not require authentication. You only need a consumer credential (`Authorization: Bearer`) - no Service Token is needed. +> **🔓 Public Endpoint**: The `labs` query does not require authentication. You only need a consumer credential — `Authorization: mol__`, with **no `Bearer` prefix** — and no Service Token. **GraphQL Query:** @@ -86,7 +86,7 @@ curl -X POST https://production.graphql.api.molecule.xyz/graphql \ Get activity timeline for a specific project including file events and announcements. This is a **public endpoint** - no authentication required. -> **🔓 Public Endpoint**: The `labActivity` query does not require authentication. You only need a consumer credential (`Authorization: Bearer`) - no Service Token is needed. +> **🔓 Public Endpoint**: The `labActivity` query does not require authentication. You only need a consumer credential — `Authorization: mol__`, with **no `Bearer` prefix** — and no Service Token. > **Filtering**: By default, returns all activity types (file events and announcements). Use the optional `filter` parameter (`ANNOUNCEMENT` or `FILE`) to retrieve only a specific type. @@ -210,7 +210,7 @@ curl -X POST https://production.graphql.api.molecule.xyz/graphql \ Get all activity across all projects. This is a **public endpoint** - no authentication required. -> **🔓 Public Endpoint**: The `activities` query does not require authentication. You only need a consumer credential (`Authorization: Bearer`) - no Service Token is needed. +> **🔓 Public Endpoint**: The `activities` query does not require authentication. You only need a consumer credential — `Authorization: mol__`, with **no `Bearer` prefix** — and no Service Token. > **Filtering**: By default, returns all activity types (file events and announcements). Use the optional `filter` parameter (`ANNOUNCEMENT` or `FILE`) to retrieve only a specific type. diff --git a/api-reference/labs-api/files.md b/api-reference/labs-api/files.md index d40c48a..25c63d3 100644 --- a/api-reference/labs-api/files.md +++ b/api-reference/labs-api/files.md @@ -538,7 +538,8 @@ mutation UpdateFileMetadata( | Parameter | Type | Required | Description | | ----------- | --------- | -------- | ------------------------------------------------------------- | | oclId | String | Yes | Canonical 32-byte oclId of the lab | -| ref | String | Yes | File reference (DID) from `finishCreateOrUpdateFile` response | +| ref | String | Yes | File reference (DID) from `finishCreateOrUpdateFile` response — the `datasetId`, **not** the file path | +| accessLevel | String | Yes | `PUBLIC`, `HOLDERS` or `ADMIN`. Required: this call replaces the metadata rather than patching it, so omitting it fails validation | | description | String | No | Updated file description | | tags | \[String] | No | Updated tags for categorization | | categories | \[String] | No | Updated categories for organization | diff --git a/api-reference/tokenization-api.md b/api-reference/tokenization-api.md index 1ae7117..a7b0a65 100644 --- a/api-reference/tokenization-api.md +++ b/api-reference/tokenization-api.md @@ -193,7 +193,7 @@ All mutations follow a consistent error response format: | Error Code | Description | Solution | | ------------------------- | -------------------------------------------- | -------------------------------------- | -| 401 Unauthorized | Missing or invalid consumer credential | Check `Authorization: Bearer` header | +| 401 Unauthorized | Missing or invalid consumer credential | Check the `Authorization` header — the `mol_` credential goes in directly, with no `Bearer` prefix | | 400 Bad Request | Invalid parameters or malformed JSON | Verify input data format | | `INVALID_INPUT` | Required fields missing or malformed | Verify the input object shape | @@ -223,7 +223,7 @@ For complete code examples including: * Error handling and retry logic * Safe multisig integration -**Contact the Molecule team** to receive the full **Technical Integration Guide**. +Contract addresses, ABIs and interfaces are published in the [Contracts reference](../references/contracts/README.md) — [`OclTokenizer`](../references/contracts/tokenizer.md), [`IPToken`](../references/contracts/ipt.md), [`AccessResolver`](../references/contracts/accessresolver.md) — so no request is needed for those. For the remaining narrative material (Safe multisig integration, worked retry logic), ask on the [Molecule Discord](https://t.co/L0VEiy4Bjk) using the [same request template](getting-started/README.md#1-a-mol-consumer-credential-the-one-manual-step), naming the Technical Integration Guide instead of a credential. ### Basic Example Structure @@ -284,9 +284,10 @@ console.log('Lab tokenized!', txHash); For assistance with the Tokenization API: -* **Technical Integration Guide**: Contact Molecule team to receive complete documentation -* **Discord**: Join our [community](https://t.co/L0VEiy4Bjk) for support -* **Smart Contracts**: See [contract addresses](../references/contracts/README.md) +* **Smart contracts**: addresses, ABIs and interfaces are published in the [Contracts reference](../references/contracts/README.md) — no request needed +* **Consumer credential**: the [request template](getting-started/README.md#1-a-mol-consumer-credential-the-one-manual-step) on Getting Started +* **Technical Integration Guide**: ask on Discord with that same template, naming the guide instead of a credential +* **Discord**: join our [community](https://t.co/L0VEiy4Bjk) for support *** From 6ce64d4d2e913ca06bc0e82051a7489012308149 Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Mon, 31 Aug 2026 15:49:44 +0100 Subject: [PATCH 06/16] docs: parse error.details tolerantly, and delete the stale IPNFT orphan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two decisions from the IP-3028 review. 1. `error.details` is doubly encoded in-band today (toGraphqlApiError stringifies into an AWSJSON field and AppSync serializes it again), so the documented single `JSON.parse(...).reason` returned `undefined` silently and any agent branching on `reason` took the wrong path. Rather than wait on the backend fix, the docs now teach a `parseDetails` helper that loops until the value is no longer a string: correct for a plain object (thrown queries), a single-encoded string, and today's double-encoded string alike, so it needs no change once the encoding is corrected. Applied to the shared setup block, all four tutorial scripts' assertOk, the Labs API error-handling reference, the agent one-pager, the changelog migration snippet, lab-management and legal-agreements. Verified 9/9 including against a live staging error. 2. Deleted `api-reference/IPNFT-api.md` — an orphan duplicate of `ipnft-api-deprecated.md`, never in SUMMARY.md, still teaching the retired `x-api-key` header, and holder of the last "contact the Molecule team" in api-reference/. Nothing linked to it. docs-sync.md records the removal. Also widens the indexer-lag retry budget on measured evidence: the earlier 5 attempts / ~30s was sized on a lag of seconds, but a mint on staging took over four minutes to index (probed: NOT_FOUND for 211s straight, writable later). The helper is now 12 attempts with backoff capped at 30s (~4 min) and logs each wait, and the prose no longer claims it "clears in seconds". Co-Authored-By: Claude Opus 5 (1M context) --- .github/prompts/docs-sync.md | 8 +- api-reference/IPNFT-api.md | 1409 ----------------- api-reference/changelog.md | 4 +- api-reference/getting-started/README.md | 34 +- api-reference/getting-started/for-agents.md | 13 +- .../tutorial-1-public-upload.md | 32 +- .../tutorial-2-encrypted-upload.md | 19 +- .../tutorial-3-agent-access.md | 39 +- .../getting-started/tutorial-4-announce.md | 19 +- api-reference/labs-api/README.md | 19 +- api-reference/labs-api/lab-management.md | 2 +- api-reference/labs-api/legal-agreements.md | 2 +- api-reference/labs-api/service-tokens.md | 2 +- 13 files changed, 162 insertions(+), 1440 deletions(-) delete mode 100644 api-reference/IPNFT-api.md diff --git a/.github/prompts/docs-sync.md b/.github/prompts/docs-sync.md index 8e8a8a8..ddc788a 100644 --- a/.github/prompts/docs-sync.md +++ b/.github/prompts/docs-sync.md @@ -91,9 +91,11 @@ touches one of its source paths. `introduction/**`, `user-guides/**`, `legal-framework/**`, `security/**` (narrative and legal pages, not driven by a backend diff), `technical-deep-dive/onchain-lab.md` and `technical-deep-dive/module-registry/**` (source: the `onchainlabs` / `ocltokenizer` contracts), -`technical-deep-dive/data/README.md` (section landing page, narrative only), and -`api-reference/IPNFT-api.md` — an orphan duplicate of `ipnft-api-deprecated.md` that is not in -`SUMMARY.md`; never edit it, and a human should decide whether to delete it. +and `technical-deep-dive/data/README.md` (section landing page, narrative only). + +The former `api-reference/IPNFT-api.md` — an orphan duplicate of `ipnft-api-deprecated.md`, never +in `SUMMARY.md`, still teaching the retired `x-api-key` header — was deleted under IP-3028. Do not +recreate it: `api-reference/ipnft-api-deprecated.md` is the only IPNFT page. ## What is not source of truth diff --git a/api-reference/IPNFT-api.md b/api-reference/IPNFT-api.md deleted file mode 100644 index 3fe36e2..0000000 --- a/api-reference/IPNFT-api.md +++ /dev/null @@ -1,1409 +0,0 @@ -# 📊 IPNFT API (Deprecated) - -## Overview - -The IPNFT API provides read-only access to query and browse intellectual property assets across the Molecule Protocol. Use these queries to build marketplaces, token screeners, portfolio trackers, and discovery interfaces for decentralized science projects. - -**Features:** -* Query IP-NFTs (Intellectual Property NFTs) and their project details -* Browse IP Tokens (IPTs) with market data -* Access trading metrics and liquidity information -* Query users, research leads, chains, and agreements -* Filter, sort, and paginate results -* Build data-driven applications - -*** - -## Authentication - -All IPNFT API requests require an API key. - -### Obtaining an API Key - -To request an API key: - -1. Join our [Discord community](https://t.co/L0VEiy4Bjk) -2. Contact the Molecule team -3. Provide your intended use case -4. You'll receive your API key - -### Using Your API Key - -Include the API key in all requests using the `x-api-key` header: - -```bash -x-api-key: YOUR_API_KEY -``` - -*** - -## API Endpoint - -``` -Production: https://production.graphql.api.molecule.xyz/graphql -Staging: https://staging.graphql.api.molecule.xyz/graphql -``` - -*** - -## Queries - -### List IP-NFTs - -Query and browse all IP-NFTs on the platform with filtering, sorting, and pagination. - -**GraphQL Query:** - -```graphql -query ListIPNFTs( - $limit: Int - $skip: Int - $sortBy: IPNFTSortBy - $sortOrder: SortOrder - $filterBy: IPNFTFilterBy -) { - ipnfts( - limit: $limit - skip: $skip - sortBy: $sortBy - sortOrder: $sortOrder - filterBy: $filterBy - ) { - id - createdAt - updatedAt - mintedAt - chainId - originalOwner - tokenUri - symbol - name - description - image - externalUrl - initialSymbol - organization - topic - trlValue - trlRationale - fundingAmountCurrency - fundingAmountValue - fundingAmountDecimals - fundingAmountCurrencyType - schemaVersion - owner { - id - address - } - researchLead { - name - email - } - agreements { - id - contentHash - mimeType - type - url - } - ipt { - id - symbol - totalIssued - } - } -} -``` - -**Parameters:** - -| Parameter | Type | Description | -| ---------- | ------------- | --------------------------------------------- | -| limit | Int | Maximum number of results (recommended: 20-50) | -| skip | Int | Number of results to skip (for pagination) | -| sortBy | IPNFTSortBy | Field to sort by (e.g., `createdAt`, `mintedAt`, `name`, `topic`, `fundingAmountValue`) | -| sortOrder | SortOrder | Sort direction: `asc` or `desc` | -| filterBy | IPNFTFilterBy | Filter criteria (owner, chainId, topic, etc.) | - -**Example Request (curl):** - -```bash -curl -X POST https://production.graphql.api.molecule.xyz/graphql \ - -H 'Content-Type: application/json' \ - -H 'x-api-key: YOUR_API_KEY' \ - -d '{ - "query": "query ListIPNFTs($limit: Int, $skip: Int, $sortBy: IPNFTSortBy, $sortOrder: SortOrder) { ipnfts(limit: $limit, skip: $skip, sortBy: $sortBy, sortOrder: $sortOrder) { id createdAt owner { address } name description image topic organization ipt { id symbol } } }", - "variables": { - "limit": 20, - "skip": 0, - "sortBy": "createdAt", - "sortOrder": "desc" - } - }' -``` - -**Response Example:** - -```json -{ - "data": { - "ipnfts": [ - { - "id": "37", - "createdAt": "2024-01-15T10:30:00.000Z", - "owner": { - "address": "0x1234567890123456789012345678901234567890" - }, - "name": "Novel Cancer Immunotherapy Research", - "description": "Groundbreaking CAR-T cell therapy development", - "image": "ipfs://QmXnnyufdzAWL...", - "topic": "Oncology", - "organization": "Research Institute", - "ipt": { - "id": "0xabcd...", - "symbol": "CART-IPT" - } - } - ] - } -} -``` - -*** - -### Get Single IP-NFT - -Retrieve detailed information about a specific IP-NFT by its ID. - -**GraphQL Query:** - -```graphql -query GetIPNFT($id: ID!) { - ipnft(id: $id) { - id - createdAt - updatedAt - mintedAt - chainId - originalOwner - tokenUri - symbol - name - description - image - externalUrl - initialSymbol - organization - topic - trlValue - trlRationale - fundingAmountCurrency - fundingAmountValue - fundingAmountDecimals - fundingAmountCurrencyType - schemaVersion - userId - owner { - id - address - } - researchLead { - id - name - email - } - agreements { - id - contentHash - mimeType - type - url - } - ipt { - id - l2TokenAddress - holderCount - symbol - name - decimals - totalIssued - circulatingSupply - } - } -} -``` - -**Example Request:** - -```bash -curl -X POST https://production.graphql.api.molecule.xyz/graphql \ - -H 'Content-Type: application/json' \ - -H 'x-api-key: YOUR_API_KEY' \ - -d '{ - "query": "query GetIPNFT($id: ID!) { ipnft(id: $id) { id name description topic symbol owner { address } ipt { symbol totalIssued } agreements { id type url } } }", - "variables": { - "id": "37" - } - }' -``` - -*** - -### List IP Tokens (IPTs) - -Query and browse all IP Tokens with their associated IP-NFTs and market data. - -**GraphQL Query:** - -```graphql -query ListIPTs( - $limit: Int - $skip: Int - $sortBy: IPTSortBy - $sortOrder: SortOrder - $filterBy: IPTFilterBy -) { - ipts( - limit: $limit - skip: $skip - sortBy: $sortBy - sortOrder: $sortOrder - filterBy: $filterBy - ) { - id - createdAt - updatedAt - mintedAt - l2TokenAddress - holderCount - symbol - name - decimals - totalIssued - circulatingSupply - agreementCid - agreementMimeType - image - links - capped - ipnftId - originalOwnerId - ipnft { - id - name - description - image - topic - organization - owner { - address - } - } - originalOwner { - id - address - } - markets { - id - name - chainId - pairAddress - liquidityUsd - tradingVolume24hr - usdPrice - usdPrice24hrPercentageChange - marketCapUsd - } - } -} -``` - -**Parameters:** - -| Parameter | Type | Description | -| ---------- | ----------- | ------------------------------------------------ | -| limit | Int | Maximum number of results | -| skip | Int | Number of results to skip (for pagination) | -| sortBy | IPTSortBy | Field to sort by (e.g., `createdAt`, `holderCount`, `name`, `symbol`) | -| sortOrder | SortOrder | Sort direction: `asc` or `desc` | -| filterBy | IPTFilterBy | Filter criteria (ipnftId, symbol, originalOwnerId, etc.) | - -**Example Request (curl):** - -```bash -curl -X POST https://production.graphql.api.molecule.xyz/graphql \ - -H 'Content-Type: application/json' \ - -H 'x-api-key: YOUR_API_KEY' \ - -d '{ - "query": "query ListIPTs($limit: Int, $sortBy: IPTSortBy, $sortOrder: SortOrder) { ipts(limit: $limit, sortBy: $sortBy, sortOrder: $sortOrder) { id symbol name totalIssued markets { usdPrice liquidityUsd tradingVolume24hr } ipnft { name topic } } }", - "variables": { - "limit": 20, - "sortBy": "createdAt", - "sortOrder": "desc" - } - }' -``` - -**Response Example:** - -```json -{ - "data": { - "ipts": [ - { - "id": "0xabcdef...", - "symbol": "CART-IPT", - "name": "Cancer Research IP Token", - "totalIssued": "1000000000000000000000000", - "markets": [ - { - "usdPrice": 0.45, - "liquidityUsd": 125000.50, - "tradingVolume24hr": 8500.25 - } - ], - "ipnft": { - "name": "Novel Cancer Immunotherapy Research", - "topic": "Oncology" - } - } - ] - } -} -``` - -*** - -### Get Single IP Token - -Retrieve detailed information about a specific IPT by its ID. - -**GraphQL Query:** - -```graphql -query GetIPT($id: ID!) { - ipt(id: $id) { - id - createdAt - updatedAt - mintedAt - l2TokenAddress - holderCount - symbol - name - decimals - totalIssued - circulatingSupply - agreementCid - agreementMimeType - image - links - capped - ipnft { - id - name - description - topic - } - originalOwner { - id - address - } - markets { - chainId - name - pairAddress - liquidityUsd - usdPrice - marketCapUsd - } - } -} -``` - -*** - -### Query Markets - -Access trading and market data for IP Tokens. - -**GraphQL Query:** - -```graphql -query ListMarkets( - $limit: Int - $skip: Int - $sortBy: MarketSortBy - $sortOrder: SortOrder - $filterBy: MarketFilterBy -) { - markets( - limit: $limit - skip: $skip - sortBy: $sortBy - sortOrder: $sortOrder - filterBy: $filterBy - ) { - id - createdAt - updatedAt - name - pairAddress - chainId - liquidityUsd - tradingVolume24hr - usdPrice - usdPrice24hrPercentageChange - marketCapUsd - inverted - iptId - token { - id - symbol - name - ipnft { - name - topic - } - } - chain { - name - chainId - logoUrl - } - } -} -``` - -**Example - Get Markets by Trading Volume:** - -```bash -curl -X POST https://production.graphql.api.molecule.xyz/graphql \ - -H 'Content-Type: application/json' \ - -H 'x-api-key: YOUR_API_KEY' \ - -d '{ - "query": "query ListMarkets($limit: Int, $sortBy: MarketSortBy, $sortOrder: SortOrder) { markets(limit: $limit, sortBy: $sortBy, sortOrder: $sortOrder) { name usdPrice liquidityUsd tradingVolume24hr token { symbol } chain { name logoUrl } } }", - "variables": { - "limit": 10, - "sortBy": "tradingVolume24hr", - "sortOrder": "desc" - } - }' -``` - -A single market can also be fetched by its id: - -```graphql -query GetMarket($id: ID!) { - market(id: $id) { - name - usdPrice - liquidityUsd - tradingVolume24hr - token { symbol } - } -} -``` - -*** - -### Query Users - -Query users and their associated IP-NFTs and IPTs. - -**GraphQL Query:** - -```graphql -query ListUsers( - $limit: Int - $skip: Int - $sortBy: UserSortBy - $sortOrder: SortOrder - $filterBy: UserFilterBy -) { - users( - limit: $limit - skip: $skip - sortBy: $sortBy - sortOrder: $sortOrder - filterBy: $filterBy - ) { - id - createdAt - updatedAt - address - ipnfts { - id - name - topic - } - ipts { - id - symbol - name - } - } -} -``` - -**Get Single User:** - -```graphql -query GetUser($id: ID!) { - user(id: $id) { - id - address - createdAt - updatedAt - ipnfts { - id - name - topic - organization - } - ipts { - id - symbol - name - totalIssued - } - } -} -``` - -*** - -### Query Research Leads - -Query research leads associated with IP-NFTs. - -**GraphQL Query:** - -```graphql -query ListResearchLeads( - $limit: Int - $skip: Int - $sortBy: ResearchLeadSortBy - $sortOrder: SortOrder - $filterBy: ResearchLeadFilterBy -) { - researchLeads( - limit: $limit - skip: $skip - sortBy: $sortBy - sortOrder: $sortOrder - filterBy: $filterBy - ) { - id - createdAt - updatedAt - name - email - ipnfts { - id - name - topic - } - } -} -``` - -**Get Single Research Lead:** - -```graphql -query GetResearchLead($id: ID!) { - researchLead(id: $id) { - id - name - email - createdAt - updatedAt - ipnfts { - id - name - topic - organization - } - } -} -``` - -*** - -### Query Chains - -Query blockchain networks where markets are deployed. - -**GraphQL Query:** - -```graphql -query ListChains( - $limit: Int - $skip: Int - $sortBy: ChainSortBy - $sortOrder: SortOrder - $filterBy: ChainFilterBy -) { - chains( - limit: $limit - skip: $skip - sortBy: $sortBy - sortOrder: $sortOrder - filterBy: $filterBy - ) { - id - createdAt - updatedAt - name - chainId - logoUrl - markets { - id - name - usdPrice - liquidityUsd - } - } -} -``` - -**Get Single Chain:** - -```graphql -query GetChain($id: ID!) { - chain(id: $id) { - id - name - chainId - logoUrl - createdAt - updatedAt - markets { - id - name - usdPrice - liquidityUsd - tradingVolume24hr - } - } -} -``` - -*** - -### Query Agreements - -Query legal agreements associated with IP-NFTs. - -**GraphQL Query:** - -```graphql -query ListAgreements( - $limit: Int - $skip: Int - $sortBy: AgreementSortBy - $sortOrder: SortOrder - $filterBy: AgreementFilterBy -) { - agreements( - limit: $limit - skip: $skip - sortBy: $sortBy - sortOrder: $sortOrder - filterBy: $filterBy - ) { - id - contentHash - mimeType - type - url - ipnftId - } -} -``` - -**Get Single Agreement:** - -```graphql -query GetAgreement($id: ID!) { - agreement(id: $id) { - id - contentHash - mimeType - type - url - ipnftId - } -} -``` - -*** - -## Common Patterns - -### Pagination - -Use `skip` and `limit` for pagination: - -```javascript -// Page 1 -{ "limit": 20, "skip": 0 } - -// Page 2 -{ "limit": 20, "skip": 20 } - -// Page 3 -{ "limit": 20, "skip": 40 } -``` - -### Sorting - -Sort results by any field: - -```javascript -{ - "sortBy": "createdAt", // or "mintedAt", "updatedAt", "name", "topic", etc. - "sortOrder": "desc" // or "asc" -} -``` - -### Filtering - -Filter results by specific criteria. The API supports both direct field filtering and nested relation filtering. - -#### Basic Filtering - -**Filter IP-NFTs by topic:** -```javascript -{ - "filterBy": { - "topic": "Oncology" - } -} -``` - -**Filter by owner (using user ID):** -```javascript -{ - "filterBy": { - "userId": "0x1234567890123456789012345678901234567890" - } -} -``` - -**Filter by chain:** -```javascript -{ - "filterBy": { - "chainId": 1 // Ethereum mainnet - } -} -``` - -**Filter IPTs by symbol:** -```javascript -{ - "filterBy": { - "symbol": "VITA" - } -} -``` - -**Filter IPTs by IPNFT:** -```javascript -{ - "filterBy": { - "ipnftId": "37" - } -} -``` - -#### Nested Relation Filtering - -The API supports filtering by nested relation properties for more flexible queries. - -**Filter IP-NFTs by owner address:** -```javascript -{ - "filterBy": { - "owner": { - "address": "0x1234567890123456789012345678901234567890" - } - } -} -``` - -**Filter IP-NFTs by owner ID:** -```javascript -{ - "filterBy": { - "owner": { - "id": "0x1234567890123456789012345678901234567890" - } - } -} -``` - -**Filter IP-NFTs by research lead:** -```javascript -{ - "filterBy": { - "researchLead": { - "email": "researcher@university.edu" - } - } -} -``` - -**Filter IP-NFTs by agreement properties:** -```javascript -{ - "filterBy": { - "agreements": { - "mimeType": "application/pdf" - } - } -} -``` - -**Filter IPTs by original owner:** -```javascript -{ - "filterBy": { - "originalOwner": { - "address": "0x1234567890123456789012345678901234567890" - } - } -} -``` - -**Filter IPTs by parent IPNFT properties:** -```javascript -{ - "filterBy": { - "ipnft": { - "topic": "Oncology" - } - } -} -``` - -**Filter markets by chain properties:** -```javascript -{ - "filterBy": { - "chain": { - "chainId": 1 // Ethereum mainnet - } - } -} -``` - -**Filter markets by token properties:** -```javascript -{ - "filterBy": { - "token": { - "symbol": "VITA-IPT" - } - } -} -``` - -**Filter IPTs by IPNFT owner (deeply nested):** -```javascript -{ - "filterBy": { - "ipnft": { - "owner": { - "address": "0x1234567890123456789012345678901234567890" - } - } - } -} -``` - -#### Combining Filters - -You can combine multiple filters in a single query. All filters are combined with AND logic - results must match all criteria. - -**Combine scalar and relation filters:** -```javascript -{ - "filterBy": { - "chainId": 1, - "owner": { - "address": "0x1234567890123456789012345678901234567890" - } - } -} -``` - -**Combine multiple field filters:** -```javascript -{ - "filterBy": { - "topic": "Oncology", - "organization": "University Lab", - "owner": { - "address": "0x1234567890123456789012345678901234567890" - } - } -} -``` - -**Combine nested relation filters (IPT query):** -```javascript -{ - "filterBy": { - "symbol": "VITA", - "ipnft": { - "owner": { - "address": "0x1234567890123456789012345678901234567890" - }, - "topic": "Longevity" - } - } -} -``` - -*** - -## Response Types - -### IPNFT Type - -```typescript -{ - id: String // Unique identifier — the onchain tokenId as a string (e.g. "37") - oclId: String // Linked lab oclId, null when the IP-NFT has no linked lab - createdAt: DateTime // Creation timestamp - updatedAt: DateTime // Last update timestamp - mintedAt: DateTime // Minting timestamp - chainId: Int // Blockchain network ID - originalOwner: String // Original minter address - tokenUri: String // Token metadata URI - symbol: String // Token symbol - name: String // Project name - description: String // Project description - image: String // IPFS image URL - externalUrl: String // External project URL - initialSymbol: String // Initial token symbol - organization: String // Organization name - topic: String // Research topic - trlValue: String // Technology readiness levels value - trlRationale: String // Technology readiness levels rationale - fundingAmountCurrency: String // Funding currency code - fundingAmountValue: String // Funding amount value - fundingAmountDecimals: Int // Funding currency decimals - fundingAmountCurrencyType: String // Currency type (e.g., "ERC20", "native") - schemaVersion: String // Metadata schema version - userId: String // Owner user ID - researchLeadId: String // Research lead ID - owner: { // Current owner - id: String - address: String - createdAt: DateTime - updatedAt: DateTime - } - researchLead: { // Research lead - id: String - name: String - email: String - } - agreements: [{ // Legal agreements - id: String - contentHash: String - mimeType: String - type: String - url: String - ipnftId: String - }] - ipt: { // Associated IP Token (if tokenized) - id: String - symbol: String - totalIssued: String - } -} -``` - -### IPT Type - -```typescript -{ - id: String // Unique identifier - createdAt: DateTime // Creation timestamp - updatedAt: DateTime // Last update timestamp - mintedAt: DateTime // Minting timestamp - l2TokenAddress: String // ERC-20 contract address - holderCount: Int // Number of token holders - symbol: String // Token symbol - name: String // Token name - decimals: Int // Token decimals - totalIssued: String // Total supply (wei format) - circulatingSupply: String // Circulating supply - agreementCid: String // IPFS CID of membership agreement - agreementMimeType: String // Agreement file MIME type - image: String // Token image URL - links: [String] // Related links - capped: Boolean // Whether token issuance is capped - ipnftId: String // Parent IP-NFT ID - originalOwnerId: String // Original owner user ID - ipnft: { // Parent IP-NFT - id: String - name: String - description: String - topic: String - } - originalOwner: { // Original token owner - id: String - address: String - } - markets: [{ // Trading markets - chainId: Int - pairAddress: String - liquidityUsd: Float - usdPrice: Float - tradingVolume24hr: Float - marketCapUsd: Float - }] -} -``` - -### Market Type - -```typescript -{ - id: String // Unique identifier - createdAt: DateTime // Creation timestamp - updatedAt: DateTime // Last update timestamp - name: String // Market name - pairAddress: String // DEX pair contract address - chainId: Int // Blockchain network ID - liquidityUsd: Float // Total liquidity in USD - tradingVolume24hr: Float // 24h trading volume in USD - usdPrice: Float // Current token price in USD - usdPrice24hrPercentageChange: Float // 24h price change % - marketCapUsd: Float // Market capitalization in USD - inverted: Boolean // Whether the pair is inverted - iptId: String // Associated IPT ID - token: { // Associated IPT - id: String - symbol: String - name: String - } - chain: { // Blockchain info - id: Int - name: String - chainId: Int - logoUrl: String - } -} -``` - -### User Type - -```typescript -{ - id: String // Unique identifier - createdAt: DateTime // Creation timestamp - updatedAt: DateTime // Last update timestamp - address: String // Wallet address - ipnfts: [IPNFT] // Owned IP-NFTs - ipts: [IPT] // Owned IPTs -} -``` - -### ResearchLead Type - -```typescript -{ - id: String // Unique identifier - createdAt: DateTime // Creation timestamp - updatedAt: DateTime // Last update timestamp - name: String // Research lead name - email: String // Research lead email - ipnfts: [IPNFT] // Associated IP-NFTs -} -``` - -### Chain Type - -```typescript -{ - id: Int // Unique identifier - createdAt: DateTime // Creation timestamp - updatedAt: DateTime // Last update timestamp - name: String // Chain name - chainId: Int // Blockchain network ID (e.g., 1 for Ethereum) - logoUrl: String // Chain logo URL - markets: [Market] // Markets on this chain -} -``` - -### Agreement Type - -```typescript -{ - id: String // Unique identifier - contentHash: String // Content hash - mimeType: String // File MIME type - type: String // Agreement type - url: String // Agreement URL - ipnftId: String // Parent IP-NFT ID -} -``` - -*** - -## Example Use Cases - -### Building a Marketplace UI - -```javascript -// Fetch recent IP-NFTs with full details -const response = await fetch('https://production.graphql.api.molecule.xyz/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': process.env.API_KEY, - }, - body: JSON.stringify({ - query: ` - query RecentIPNFTs { - ipnfts(limit: 20, sortBy: createdAt, sortOrder: desc) { - id - name - description - image - topic - organization - ipt { - id - symbol - } - } - } - `, - }), -}); - -const data = await response.json(); -// Display IP-NFTs in marketplace grid -``` - -### Token Screener / Price Tracker - -```javascript -// Get top IPTs by trading volume -const response = await fetch('https://production.graphql.api.molecule.xyz/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': process.env.API_KEY, - }, - body: JSON.stringify({ - query: ` - query TopIPTsByVolume { - ipts(limit: 10, sortBy: createdAt, sortOrder: desc) { - symbol - name - markets { - usdPrice - usdPrice24hrPercentageChange - tradingVolume24hr - liquidityUsd - marketCapUsd - } - } - } - `, - }), -}); - -const data = await response.json(); -// Display price table with 24h change indicators -``` - -### Portfolio Tracker - -```javascript -// Get all IP-NFTs owned by a specific wallet -const walletAddress = "0x1234567890123456789012345678901234567890"; - -const response = await fetch('https://production.graphql.api.molecule.xyz/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': process.env.API_KEY, - }, - body: JSON.stringify({ - query: ` - query UserPortfolio($filterBy: IPNFTFilterBy) { - ipnfts(filterBy: $filterBy) { - id - name - topic - ipt { - symbol - totalIssued - markets { - usdPrice - marketCapUsd - } - } - } - } - `, - variables: { - filterBy: { - owner: { - address: walletAddress - } - } - } - }), -}); - -const data = await response.json(); -// Calculate total portfolio value -``` - -*** - -## Advanced Filtering - -### Relation Filtering vs Direct Filtering - -The IPNFT API supports two approaches to filtering: - -1. **Direct Field Filtering**: Filter by the ID of a related entity -2. **Relation Filtering**: Filter by properties of related entities - -Both approaches work and can be used based on your needs. - -**Example - Finding IP-NFTs by Owner:** - -```javascript -// Approach 1: Direct field filtering (when you know the user ID) -{ - "filterBy": { - "userId": "0x1234..." - } -} - -// Approach 2: Relation filtering (when you want to filter by owner properties) -{ - "filterBy": { - "owner": { - "address": "0x1234..." - } - } -} -``` - -### Multi-Level Nested Filtering - -You can filter through multiple levels of relations: - -```javascript -// Find all IP Tokens whose parent IP-NFT is owned by a specific wallet -{ - "filterBy": { - "ipnft": { - "owner": { - "address": "0x1234567890123456789012345678901234567890" - } - } - } -} - -// Find markets for tokens with a specific symbol -{ - "filterBy": { - "token": { - "symbol": "VITA-IPT" - } - } -} -``` - -### Available Relation Filters - -| Query Type | Relation Field | Supported Filters | Example | -|------------|---------------|-------------------|---------| -| `ipnfts` | `owner` | `id`, `address` | `owner: { address: "0x..." }` | -| `ipnfts` | `researchLead` | `id`, `name`, `email` | `researchLead: { email: "..." }` | -| `ipnfts` | `agreements` | `id`, `contentHash`, `mimeType`, `type`, `url` | `agreements: { mimeType: "application/pdf" }` | -| `ipts` | `ipnft` | All IPNFT filter fields | `ipnft: { topic: "Oncology" }` | -| `ipts` | `originalOwner` | `id`, `address` | `originalOwner: { address: "0x..." }` | -| `markets` | `chain` | `id`, `chainId`, `name` | `chain: { chainId: 1 }` | -| `markets` | `token` | All IPT filter fields | `token: { symbol: "VITA" }` | - -### Filter Matching - -All filters use **exact equality matching** by default. For example: - -```javascript -{ - "filterBy": { - "topic": "Longevity" // Exact match only - } -} -``` - -*** - -## Error Handling - -### Common Errors - -| Status Code | Error | Description | -| ----------- | -------------------- | -------------------------------------------- | -| 401 | Unauthorized | Missing or invalid API key | -| 400 | Bad Request | Invalid query syntax or parameters | -| 500 | Internal Server Error| Server error - retry the request | - -Missing resources are **not** signalled with an HTTP 404. Single-item queries (`ipnft`, `ipt`, `user`, …) return HTTP 200 with a GraphQL error in the `errors[]` array carrying a machine-readable `code`: - -| GraphQL error `code` | Meaning | -| --------------------------- | -------------------------------------------------------------- | -| `NOT_FOUND` | Requested resource doesn't exist | -| `COMPLEXITY_LIMIT_EXCEEDED` | Query too complex — max depth **5**, max **100** selections | -| `INVALID_INPUT` | Malformed arguments | - -### Troubleshooting - -**401 Unauthorized Error:** -* Verify `x-api-key` header is included -* Check that your API key is valid and not expired -* Ensure no typos in the API key - -**Empty Results:** -* Check filter criteria - may be too restrictive -* Verify the chainId if filtering by chain -* Try removing filters to see all results - -**GraphQL Errors:** -* Check query syntax is valid -* Ensure field names match the schema -* Verify variable types match parameter types - -*** - -## Getting Support - -For questions or issues with the IPNFT API: - -* Join our [Discord community](https://t.co/L0VEiy4Bjk) -* Check the [API Overview](README.md) for authentication help - -*** - -## Recent Updates - -The breaking changes, migration notes, and newly added fields for this API have moved to the [API Changelog & Migration](changelog.md#ipnft-api-deprecated) page (February 2026 changes). - ---- - -_Last updated: February 2026_ diff --git a/api-reference/changelog.md b/api-reference/changelog.md index 78bc506..7829db1 100644 --- a/api-reference/changelog.md +++ b/api-reference/changelog.md @@ -116,7 +116,7 @@ type ApiError { } ``` -On an in-band mutation error, `details` arrives as a JSON-encoded string (AppSync `AWSJSON`), e.g. `"details": "{\"reason\":\"NOT_LAB_OWNER\"}"` — read it with `JSON.parse(error.details ?? "{}")`. On a thrown query error, `errorInfo.details` is a plain object. Ignore keys you do not recognise, and never match on `message` — its wording may change without notice. +On an in-band mutation error, `details` arrives as a JSON-encoded string (AppSync `AWSJSON`), e.g. `"details": "{\"reason\":\"NOT_LAB_OWNER\"}"`; on a thrown query error, `errorInfo.details` is a plain object. The in-band string is currently encoded twice, so read it with the tolerant [`parseDetails`](labs-api/README.md#error-handling) rather than a single `JSON.parse`, which returns another string and makes `.reason` silently `undefined`. Ignore keys you do not recognise, and never match on `message` — its wording may change without notice. #### Error codes @@ -170,7 +170,7 @@ Codes may be added over time, and each addition is announced on this page. Treat - handle(result.error?.code); - } + if (result.error) { -+ const { reason } = JSON.parse(result.error.details ?? "{}"); ++ const { reason } = parseDetails(result.error.details); // tolerant parse, see Error Handling + handle(result.error.code, reason); + } ``` diff --git a/api-reference/getting-started/README.md b/api-reference/getting-started/README.md index 5dea054..3523886 100644 --- a/api-reference/getting-started/README.md +++ b/api-reference/getting-started/README.md @@ -155,12 +155,29 @@ async function graphql(query, variables) { // Mutations report failure in-band: `error` is null on success. Throw on a // non-null `error` so a failed step stops the workflow with the catalogue // `code` and the `requestId` to quote in a bug report. +// `error.details` carries the specific cause under `reason`, but it arrives in +// more than one shape: a plain object on thrown query errors, a JSON string +// in-band, and currently a doubly-encoded JSON string in-band. Parse until it +// stops being a string, so one reader handles all three. +function parseDetails(details) { + let value = details; + for (let i = 0; i < 3 && typeof value === "string"; i++) { + try { + value = JSON.parse(value); + } catch { + break; + } + } + return value && typeof value === "object" ? value : {}; +} + function assertOk(result, op) { if (result.error) { - // `details` is a JSON-encoded string; JSON.parse(result.error.details ?? "{}").reason - // carries the specific cause when there is one. const { code, message, requestId } = result.error; - throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`); + const { reason } = parseDetails(result.error.details); + throw new Error( + `${op} failed: ${code}${reason ? `/${reason}` : ""}: ${message} (requestId ${requestId})`, + ); } return result; } @@ -168,14 +185,19 @@ function assertOk(result, op) { // Onchain state — a mint, a role grant — reaches the API through an event // indexer, so a write issued immediately after one can fail on state the chain // already has. Retry with backoff; re-issuing the token never helps. -async function withIndexerLagRetry(fn, { codes = ["NOT_FOUND"], attempts = 5, baseMs = 2000 } = {}) { +async function withIndexerLagRetry( + fn, + { codes = ["NOT_FOUND"], attempts = 12, baseMs = 2000, capMs = 30000 } = {}, +) { const laggy = new RegExp(codes.join("|")); for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (err) { if (!laggy.test(String(err)) || i === attempts - 1) throw err; - await new Promise((r) => setTimeout(r, baseMs * 2 ** i)); // 2s, 4s, 8s, 16s + const delay = Math.min(baseMs * 2 ** i, capMs); // 2s, 4s, 8s, 16s, then 30s + console.warn(`indexer not caught up (attempt ${i + 1}/${attempts}); retrying in ${delay / 1000}s`); + await new Promise((r) => setTimeout(r, delay)); } } } @@ -187,7 +209,7 @@ async function withIndexerLagRetry(fn, { codes = ["NOT_FOUND"], attempts = 5, ba * **After minting**, the lab's first write can return `NOT_FOUND` — even though `createLab` just succeeded, because `createLab` falls back to an onchain ownership check while the file mutations read the indexed record. [Tutorial 1 Step 4](tutorial-1-public-upload.md#step-4-upload-the-file). * **After a role grant**, a write can return `UNAUTHORIZED` until the grant is indexed. [Tutorial 3](tutorial-3-agent-access.md#step-4-the-agent-uploads-and-announces). -Both clear within seconds. Both are the retry above, with `codes` set to the one you expect. +Usually both clear within seconds — but a mint has taken **several minutes** to index on staging under backlog, so the budget above is deliberately generous (12 attempts, backoff capped at 30s, ~4 minutes total) and logs each wait. Use the retry above with `codes` set to the one you expect, and do not treat the first failure as fatal. {% endhint %} *** diff --git a/api-reference/getting-started/for-agents.md b/api-reference/getting-started/for-agents.md index 6e535bf..66d0aa2 100644 --- a/api-reference/getting-started/for-agents.md +++ b/api-reference/getting-started/for-agents.md @@ -36,7 +36,16 @@ Public queries take `Authorization` alone. Sending `X-Service-Token` on a public ## Error contract * **Queries throw.** Failure lands in top-level `errors[]`. Branch on `errors[i].errorType`. `errors[i].errorInfo` is `{ requestId, retryable, details }` and `details` is already an object. -* **Mutations return errors in-band.** Every `*Result` has `error: ApiError`. **Success ⇔ `error == null`.** Select `error { code message requestId retryable details }` on every mutation. In-band `details` is a **JSON-encoded string** — `JSON.parse(error.details ?? "{}")`. +* **Mutations return errors in-band.** Every `*Result` has `error: ApiError`. **Success ⇔ `error == null`.** Select `error { code message requestId retryable details }` on every mutation. +* **Parse `details` tolerantly.** It is an object on thrown query errors, a JSON string in-band, and currently a **doubly-encoded** JSON string in-band — one `JSON.parse` there returns a string, and `.reason` on it is silently `undefined`. Loop until it is not a string: + +```javascript +function parseDetails(d) { + let v = d; + for (let i = 0; i < 3 && typeof v === "string"; i++) { try { v = JSON.parse(v); } catch { break } } + return v && typeof v === "object" ? v : {}; +} +``` * Branch on `code`, never on `message`. Retry only when `retryable` is `true`, with exponential backoff. Quote `requestId` in any bug report. * Codes: `UNAUTHENTICATED`, `UNAUTHORIZED`, `NOT_FOUND`, `VALIDATION_FAILED`, `CONFLICT`, `FAILED_PRECONDITION`, `COMPLEXITY_LIMIT_EXCEEDED`, `RATE_LIMITED`*, `TIMEOUT`*, `UPSTREAM_UNAVAILABLE`*, `INTERNAL_ERROR`* (`*` = retryable). An unrecognised code: treat as non-retryable, preserve it, surface it. @@ -155,7 +164,7 @@ Full recipe including `accessControlConditions`: [Tutorial 2](tutorial-2-encrypt 1. No `Bearer` in front of a `mol_` credential. 2. Sign the sign-in message **verbatim**; any reformatting fails verification. It is single-use and expires after 10 minutes, so a cached message or signature returns `UNAUTHENTICATED` — `NONCE_NOT_FOUND` once redeemed, `NONCE_EXPIRED` past the window, `INVALID_SIGNATURE` if a later fetch superseded it. The fix is always a fresh `getServiceSignInMessage`, never a retry of the same signature. 3. Success on a mutation is `error == null` — never a truthy payload field, and never a `message` string. -4. In-band `error.details` is a JSON string; thrown `errorInfo.details` is an object. They differ. +4. Read `error.details` with the tolerant `parseDetails` above — never a bare `JSON.parse`. In-band it is a JSON string (currently doubly encoded); on thrown query errors it is already an object. 5. Filter mint receipt logs to the **LabNFT** address before decoding `OclIdentityCreated`. 6. Send the presigned `PUT` with the returned headers unchanged, and the raw bytes as the body. 7. Writing into a lab you do not own needs a **Contributor** role on it — see [Tutorial 3](tutorial-3-agent-access.md). After a role grant, an indexer lag of a few seconds can still return `UNAUTHORIZED`; retry with backoff. diff --git a/api-reference/getting-started/tutorial-1-public-upload.md b/api-reference/getting-started/tutorial-1-public-upload.md index 8f0b82d..9c898bb 100644 --- a/api-reference/getting-started/tutorial-1-public-upload.md +++ b/api-reference/getting-started/tutorial-1-public-upload.md @@ -214,7 +214,7 @@ DID-linking for the new lab starts automatically in the background; [`getDidLink Three calls: get a presigned URL, `PUT` the bytes, finalise with metadata. Full reference: [Files](../labs-api/files.md). {% hint style="warning" %} -**This is the one step that can fail on a lab you just created.** `createLab` succeeding does not yet mean the lab is writable: it falls back to an onchain ownership check when the mint has not been indexed, while the file mutations read the indexed record and return `NOT_FOUND` until it lands. Wrap the first call in [`withIndexerLagRetry`](README.md#shared-setup) — without it this step fails outright on a fresh mint often enough to matter. +**This is the one step that can fail on a lab you just created.** `createLab` succeeding does not yet mean the lab is writable: it falls back to an onchain ownership check when the mint has not been indexed, while the file mutations read the indexed record and return `NOT_FOUND` until it lands. Wrap the first call in [`withIndexerLagRetry`](README.md#shared-setup) — without it this step fails outright on a fresh mint often enough to matter. Measured on staging: usually indexed within seconds, but one mint took **over four minutes**, which is why the helper retries for that long rather than giving up after a few seconds. {% endhint %} ```javascript @@ -342,7 +342,7 @@ Keep `datasetId` — Tutorial 4 attaches it to an announcement. | Symptom | Cause | Fix | | ------- | ----- | --- | -| `initiate` → `NOT_FOUND`, "Project 0x… does not exist" | The mint is not indexed yet. `createLab` can succeed before this is true, so a successful Step 3 is no guarantee | Retry with backoff — `withIndexerLagRetry` above. It clears in seconds. Do **not** re-run `createLab`, which returns `CONFLICT` once registered | +| `initiate` → `NOT_FOUND`, "Project 0x… does not exist" | The mint is not indexed yet. `createLab` can succeed before this is true, so a successful Step 3 is no guarantee | Retry with backoff — `withIndexerLagRetry` above. Usually seconds; observed up to ~4 minutes under indexer backlog. Do **not** re-run `createLab`, which returns `CONFLICT` once registered | | `initiate` → `UNAUTHORIZED` | The wallet behind the token has no write role on this lab | You must be Owner or Contributor. See [Tutorial 3](tutorial-3-agent-access.md) | | `PUT` → `403` | URL expired (~15 min), or headers altered | Re-run `initiate`; send the returned `headers` verbatim | | `PUT` → `400`/`411` | Body wasn't sent as raw bytes | Send the buffer, not a JSON wrapper. In curl: `--data-binary` | @@ -428,24 +428,46 @@ async function graphql(query, variables) { return data; } +// `details` arrives as an object (thrown queries), a JSON string (in-band), or +// a doubly-encoded JSON string (in-band today) — parse until it is not a string. +function parseDetails(details) { + let value = details; + for (let i = 0; i < 3 && typeof value === "string"; i++) { + try { + value = JSON.parse(value); + } catch { + break; + } + } + return value && typeof value === "object" ? value : {}; +} + function assertOk(result, op) { if (result.error) { const { code, message, requestId } = result.error; - throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`); + const { reason } = parseDetails(result.error.details); + throw new Error( + `${op} failed: ${code}${reason ? `/${reason}` : ""}: ${message} (requestId ${requestId})`, + ); } return result; } // The mint reaches the API through an event indexer, so the lab's first write // can return NOT_FOUND for a few seconds after createLab already succeeded. -async function withIndexerLagRetry(fn, { codes = ["NOT_FOUND"], attempts = 5, baseMs = 2000 } = {}) { +async function withIndexerLagRetry( + fn, + { codes = ["NOT_FOUND"], attempts = 12, baseMs = 2000, capMs = 30000 } = {}, +) { const laggy = new RegExp(codes.join("|")); for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (err) { if (!laggy.test(String(err)) || i === attempts - 1) throw err; - await new Promise((r) => setTimeout(r, baseMs * 2 ** i)); // 2s, 4s, 8s, 16s + const delay = Math.min(baseMs * 2 ** i, capMs); // 2s, 4s, 8s, 16s, then 30s + console.warn(`indexer not caught up (attempt ${i + 1}/${attempts}); retrying in ${delay / 1000}s`); + await new Promise((r) => setTimeout(r, delay)); } } } diff --git a/api-reference/getting-started/tutorial-2-encrypted-upload.md b/api-reference/getting-started/tutorial-2-encrypted-upload.md index be5ff5c..b3171b0 100644 --- a/api-reference/getting-started/tutorial-2-encrypted-upload.md +++ b/api-reference/getting-started/tutorial-2-encrypted-upload.md @@ -356,10 +356,27 @@ async function graphql(query, variables) { return data; } +// `details` arrives as an object (thrown queries), a JSON string (in-band), or +// a doubly-encoded JSON string (in-band today) — parse until it is not a string. +function parseDetails(details) { + let value = details; + for (let i = 0; i < 3 && typeof value === "string"; i++) { + try { + value = JSON.parse(value); + } catch { + break; + } + } + return value && typeof value === "object" ? value : {}; +} + function assertOk(result, op) { if (result.error) { const { code, message, requestId } = result.error; - throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`); + const { reason } = parseDetails(result.error.details); + throw new Error( + `${op} failed: ${code}${reason ? `/${reason}` : ""}: ${message} (requestId ${requestId})`, + ); } return result; } diff --git a/api-reference/getting-started/tutorial-3-agent-access.md b/api-reference/getting-started/tutorial-3-agent-access.md index 09a19c6..48ce5c4 100644 --- a/api-reference/getting-started/tutorial-3-agent-access.md +++ b/api-reference/getting-started/tutorial-3-agent-access.md @@ -131,17 +131,22 @@ From here the agent is an ordinary caller. Run [Tutorial 1 Step 4](tutorial-1-pu Writes by a Contributor service token are gated per mutation, matching the Privy user path: `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createAnnouncement`, `deleteDataRoomFile`, `updateFileMetadata` and `moveEntry` all accept Contributor. A few surfaces remain **Owner-only** and an agent Contributor cannot reach them: `updateLabNftMetadata`, `generateLabImageUploadUrl` and the legal-agreement mutations. {% hint style="warning" %} -**Retry on `UNAUTHORIZED` right after the grant.** Role state reaches the API through an event indexer, so there is a short window after `grantRole` confirms onchain in which a write still returns `UNAUTHORIZED` (`reason: NOT_CONTRIBUTOR`). It is not a permissions problem and re-issuing the token will not help — wait and retry: +**Retry on `UNAUTHORIZED` right after the grant.** Role state reaches the API through an event indexer, so for a window after `grantRole` confirms onchain a write still returns `UNAUTHORIZED` (`reason: NOT_CONTRIBUTOR`). It is not a permissions problem and re-issuing the token will not help — wait and retry. Usually seconds, but the same indexer has taken minutes on staging, so retry generously: ```javascript -async function withIndexerLagRetry(fn, { codes = ["NOT_FOUND"], attempts = 5, baseMs = 2000 } = {}) { +async function withIndexerLagRetry( + fn, + { codes = ["NOT_FOUND"], attempts = 12, baseMs = 2000, capMs = 30000 } = {}, +) { const laggy = new RegExp(codes.join("|")); for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (err) { if (!laggy.test(String(err)) || i === attempts - 1) throw err; - await new Promise((r) => setTimeout(r, baseMs * 2 ** i)); // 2s, 4s, 8s, 16s + const delay = Math.min(baseMs * 2 ** i, capMs); // 2s, 4s, 8s, 16s, then 30s + console.warn(`indexer not caught up (attempt ${i + 1}/${attempts}); retrying in ${delay / 1000}s`); + await new Promise((r) => setTimeout(r, delay)); } } } @@ -216,10 +221,27 @@ async function graphql(query, variables) { return data; } +// `details` arrives as an object (thrown queries), a JSON string (in-band), or +// a doubly-encoded JSON string (in-band today) — parse until it is not a string. +function parseDetails(details) { + let value = details; + for (let i = 0; i < 3 && typeof value === "string"; i++) { + try { + value = JSON.parse(value); + } catch { + break; + } + } + return value && typeof value === "object" ? value : {}; +} + function assertOk(result, op) { if (result.error) { const { code, message, requestId } = result.error; - throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`); + const { reason } = parseDetails(result.error.details); + throw new Error( + `${op} failed: ${code}${reason ? `/${reason}` : ""}: ${message} (requestId ${requestId})`, + ); } return result; } @@ -229,14 +251,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Writes can return UNAUTHORIZED for a few seconds after a role grant confirms // onchain — the indexer trails the chain. Retry rather than re-issuing the // token. Same helper as Tutorial 1, which retries NOT_FOUND after a mint. -async function withIndexerLagRetry(fn, { codes = ["NOT_FOUND"], attempts = 5, baseMs = 2000 } = {}) { +async function withIndexerLagRetry( + fn, + { codes = ["NOT_FOUND"], attempts = 12, baseMs = 2000, capMs = 30000 } = {}, +) { const laggy = new RegExp(codes.join("|")); for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (err) { if (!laggy.test(String(err)) || i === attempts - 1) throw err; - await sleep(baseMs * 2 ** i); // 2s, 4s, 8s, 16s + const delay = Math.min(baseMs * 2 ** i, capMs); // 2s, 4s, 8s, 16s, then 30s + console.warn(`indexer not caught up (attempt ${i + 1}/${attempts}); retrying in ${delay / 1000}s`); + await sleep(delay); } } } diff --git a/api-reference/getting-started/tutorial-4-announce.md b/api-reference/getting-started/tutorial-4-announce.md index f51c0a6..9f75cdc 100644 --- a/api-reference/getting-started/tutorial-4-announce.md +++ b/api-reference/getting-started/tutorial-4-announce.md @@ -125,10 +125,27 @@ async function graphql(query, variables) { return data; } +// `details` arrives as an object (thrown queries), a JSON string (in-band), or +// a doubly-encoded JSON string (in-band today) — parse until it is not a string. +function parseDetails(details) { + let value = details; + for (let i = 0; i < 3 && typeof value === "string"; i++) { + try { + value = JSON.parse(value); + } catch { + break; + } + } + return value && typeof value === "object" ? value : {}; +} + function assertOk(result, op) { if (result.error) { const { code, message, requestId } = result.error; - throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`); + const { reason } = parseDetails(result.error.details); + throw new Error( + `${op} failed: ${code}${reason ? `/${reason}` : ""}: ${message} (requestId ${requestId})`, + ); } return result; } diff --git a/api-reference/labs-api/README.md b/api-reference/labs-api/README.md index 00c7d91..b2d0d0c 100644 --- a/api-reference/labs-api/README.md +++ b/api-reference/labs-api/README.md @@ -104,14 +104,29 @@ mutation InitiateFileUpload($oclId: String!, $contentType: String!, $contentLeng } ``` -In-band `details` is a JSON-encoded string — parse it with `JSON.parse(error.details ?? "{}")` (on thrown queries, `errorInfo.details` is already an object). Documented keys are `field` (the offending input field), `reason` (a more specific cause under the code, e.g. `PROJECT_NOT_FOUND` under `NOT_FOUND`), `hint` and `docs`; ignore unknown keys. `reason` values are diagnostic refinement and may be extended at any time — branch on `code` first. +`details` reaches you in more than one shape, so **read it through a tolerant parse rather than a single `JSON.parse`**: it is a JSON-encoded string on in-band mutation errors, a plain object on thrown query errors (`errorInfo.details`), and the in-band string is currently encoded twice — a single parse there returns another string, and `.reason` on it is silently `undefined`. Parsing until the value stops being a string reads all three correctly and needs no change when the encoding is corrected. + +Documented keys are `field` (the offending input field), `reason` (a more specific cause under the code, e.g. `PROJECT_NOT_FOUND` under `NOT_FOUND`), `hint` and `docs`; ignore unknown keys. `reason` values are diagnostic refinement and may be extended at any time — branch on `code` first. ```javascript +// Handles all three shapes: object, JSON string, doubly-encoded JSON string. +function parseDetails(details) { + let value = details; + for (let i = 0; i < 3 && typeof value === "string"; i++) { + try { + value = JSON.parse(value); + } catch { + break; + } + } + return value && typeof value === "object" ? value : {}; +} + const result = (await response.json()).data.initiateCreateOrUpdateFile; // `response` from your fetch() if (result.error) { const { code, message, requestId, retryable, details } = result.error; - const { reason } = JSON.parse(details ?? "{}"); + const { reason } = parseDetails(details); if (retryable) return retryWithBackoff(); // RATE_LIMITED, TIMEOUT, UPSTREAM_UNAVAILABLE, INTERNAL_ERROR throw new Error(`${code}${reason ? `/${reason}` : ""}: ${message} (requestId ${requestId})`); } diff --git a/api-reference/labs-api/lab-management.md b/api-reference/labs-api/lab-management.md index e11cbfd..d099b8b 100644 --- a/api-reference/labs-api/lab-management.md +++ b/api-reference/labs-api/lab-management.md @@ -216,7 +216,7 @@ curl -X POST https://production.graphql.api.molecule.xyz/graphql \ **Error Responses:** -`createLab` reports failures in-band: `error` is `null` on success and a full `ApiError` on failure. Branch on `error.code` (and, where documented, the `reason` key inside `error.details`, a JSON-encoded string) — never on message text. The top-level `message` mirrors `error.message` on failure. +`createLab` reports failures in-band: `error` is `null` on success and a full `ApiError` on failure. Branch on `error.code` (and, where documented, the `reason` key inside `error.details` — read via the tolerant [`parseDetails`](README.md#error-handling), since the in-band string is currently doubly encoded) — never on message text. The top-level `message` mirrors `error.message` on failure. **Not Authenticated (No Token):** diff --git a/api-reference/labs-api/legal-agreements.md b/api-reference/labs-api/legal-agreements.md index 713c0a2..f6b2f5c 100644 --- a/api-reference/labs-api/legal-agreements.md +++ b/api-reference/labs-api/legal-agreements.md @@ -242,7 +242,7 @@ mutation SignLegalAgreement($input: SignLegalAgreementInput!) { } ``` -Success ⇔ `error == null`. On failure `message` mirrors `error.message`; branch on `error.code` (and `details.reason` where documented — e.g. `CONFLICT` with reason `ALREADY_SIGNED` when the current template version is already signed, or `FAILED_PRECONDITION` with reason `TEMPLATE_EXPIRED`). `details` is a JSON-encoded string: `JSON.parse(error.details ?? "{}").reason`. +Success ⇔ `error == null`. On failure `message` mirrors `error.message`; branch on `error.code` (and `details.reason` where documented — e.g. `CONFLICT` with reason `ALREADY_SIGNED` when the current template version is already signed, or `FAILED_PRECONDITION` with reason `TEMPLATE_EXPIRED`). `details` is a JSON-encoded string, currently doubly encoded in-band: read it via the tolerant [`parseDetails`](README.md#error-handling). **`SignLegalAgreementInput` fields:** diff --git a/api-reference/labs-api/service-tokens.md b/api-reference/labs-api/service-tokens.md index 187a2fd..200aeb9 100644 --- a/api-reference/labs-api/service-tokens.md +++ b/api-reference/labs-api/service-tokens.md @@ -101,7 +101,7 @@ mutation GenerateServiceToken( Success ⇔ `error == null`. On failure `error` carries the catalogue `code` (e.g. `UNAUTHENTICATED` when the signature does not verify), `message` mirrors `error.message`, and `token`, `tokenId`, `expiresAt` and `createdAt` are `null` (`serviceName` may echo the name you sent) — guard for `null`, not for empty strings, and branch on `error`, never on the token fields. -**Failure modes on the signature path** — all `UNAUTHENTICATED`, distinguished by `details.reason` (`details` is a JSON-encoded string, so `JSON.parse(error.details ?? "{}").reason`): +**Failure modes on the signature path** — all `UNAUTHENTICATED`, distinguished by `details.reason`. Read it through the tolerant [`parseDetails`](README.md#error-handling), not a bare `JSON.parse` — the in-band string is currently doubly encoded: | `reason` | What happened | Fix | | -------- | ------------- | --- | From c6ea010a8e4a746612b227b6060fe2ae5611f066 Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Tue, 1 Sep 2026 09:28:02 +0100 Subject: [PATCH 07/16] docs(auth): compare the three wallets an integration juggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requested by Logan, for the PeptAI/Martin shape: an owner wallet, an agent wallet and the Lab's own OCL account are all in play at once, each documented in a different place and never side by side. A reader assembling the picture had to visit onchain-lab.md, roles-and-permissions.md, authentication.md and Tutorial 3. Adds one table to authentication.md — what each wallet is, who holds the key, how it gets its rights, how it authenticates, **which field its address goes in**, and what it cannot do — plus the two failure modes it prevents: - Passing the owner's address where `labAccountAddress` belongs in accessControlConditions. Evaluation fails closed, so the upload succeeds and then nobody can decrypt, with no error explaining why. - Expecting the agent to inherit the human's reach; its rights come from its own grant, which is why a few mutations stay Owner-only. Also documents that `oclId` is not a wallet address: it packs the version, namespace, LabNFT tokenId and the OCL account address, so its trailing 40 hex chars are `labAccountAddress`. Layout taken from `lambda/common/utils/ocl-id.ts` and verified by decoding three live staging labs (testing-lab/1526, lab-1533, lab-1546) — each decoded tokenId and address matches the values the API returns. That is also why a zeroed oclId is rejected with "embedded address is zero" rather than a not-found. Linked from Tutorial 3, Tutorial 2's conditions step, the agent one-pager (prose plus a new rule 9) and roles-and-permissions.md, so the scattered partial explanations become pointers to one canonical table. Not asserted: whether PeptAI's owner wallet is specifically a Privy embedded wallet. The table says "typically a Privy embedded wallet created at email sign-in, but any wallet that holds the LabNFT" — worth confirming with Martin. Co-Authored-By: Claude Opus 5 (1M context) --- api-reference/authentication.md | 30 +++++++++++++++++++ api-reference/getting-started/for-agents.md | 4 ++- .../tutorial-2-encrypted-upload.md | 2 +- .../tutorial-3-agent-access.md | 2 ++ technical-deep-dive/roles-and-permissions.md | 4 +++ 5 files changed, 40 insertions(+), 2 deletions(-) diff --git a/api-reference/authentication.md b/api-reference/authentication.md index 54dd740..2ecf0ba 100644 --- a/api-reference/authentication.md +++ b/api-reference/authentication.md @@ -170,6 +170,36 @@ This is why an agent can be handed access to a lab it does not own — the human > Because role state reaches the API through an event indexer, there is a short window after a role grant confirms onchain in which a write can still return `UNAUTHORIZED`. Retry with backoff; re-issuing the token does not help. +### The three wallets, side by side + +A working integration has up to three addresses in play at once, and they are not interchangeable. Sending the wrong one is the most common way a request fails for a reason the error message does not explain. + +| | **Owner wallet** | **Agent wallet** | **OCL account** (the Lab's own wallet) | +| -- | -- | -- | -- | +| What it is | The human's wallet — typically a Privy embedded wallet created at email sign-in, but any wallet that holds the LabNFT | An EOA belonging to the agent, generated by the agent and never shared | The Lab itself: an [ERC-6551 Token Bound Account](../technical-deep-dive/onchain-lab.md) permanently bound to the LabNFT | +| Who holds the private key | The human (Privy custodies the embedded case) | The agent, and only the agent | **Nobody.** It has no key of its own — its authority derives from whoever currently owns the LabNFT | +| How it gets its rights | Implicitly: holding the LabNFT makes it **Owner** | An explicit onchain grant of **Contributor** or **Viewer** from the Owner | It is the lab — rights are resolved *against* it, not held by it | +| How it authenticates | `Authorization: Bearer ` + `x-wallet-address` | Signs the sign-in message → its own [service token](labs-api/service-tokens.md#obtaining-a-token) in `X-Service-Token` | It never authenticates. It signs nothing and is issued no token | +| Where its address goes | `x-wallet-address` | `walletAddress` when issuing a token, `changeBy` on writes, and whatever `:userAddress` resolves to at condition-evaluation time | `labAccountAddress` — including the `account` argument of `isAuthorizedSignerForTba` in [access conditions](getting-started/tutorial-2-encrypted-upload.md#step-4c-write-the-access-conditions) | +| What it cannot do | — | Transfer the LabNFT, call `updateLabNftMetadata` or `generateLabImageUploadUrl` — those stay Owner-only | Act as a caller: never pass it as `walletAddress` or `changeBy` | + +Two failure modes this prevents: + +* **Passing the owner's address where the OCL account belongs** in `accessControlConditions`. Condition evaluation **fails closed**, so the file uploads fine and then nobody can decrypt it — with no error saying why. The `account` argument wants `labAccountAddress`; `:userAddress` is substituted with the caller's wallet automatically. +* **Expecting the agent to inherit the human's reach.** The agent authenticates as itself, so its permissions come from its own grant. That is the point — the human never hands over a key — and it is why a few Owner-only mutations stay out of reach. Walkthrough: [Tutorial 3](getting-started/tutorial-3-agent-access.md). + +#### `oclId` is not a wallet address + +`oclId` identifies a lab and looks like an address, but it is a 32-byte value that **packs the OCL account address inside it**, together with the LabNFT `tokenId`: + +``` +oclId 0x 01 01 000000000005f6 f923ca46329c8fcb2fcf8a03512f1483c52c63c5 + ^^ ^^ ^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + version namespace tokenId (1526) the OCL account address, verbatim +``` + +So the trailing 40 hex characters of an `oclId` are the lab's `labAccountAddress` — which is why a zeroed one is rejected with `VALIDATION_FAILED` / `"embedded address is zero"` rather than a not-found. Pass `oclId` wherever a lab is named; never pass it as a wallet address, and never truncate it to one. + ### Using Your Credentials **For all queries** (read-only operations): diff --git a/api-reference/getting-started/for-agents.md b/api-reference/getting-started/for-agents.md index 66d0aa2..312f8f4 100644 --- a/api-reference/getting-started/for-agents.md +++ b/api-reference/getting-started/for-agents.md @@ -169,10 +169,12 @@ Full recipe including `accessControlConditions`: [Tutorial 2](tutorial-2-encrypt 6. Send the presigned `PUT` with the returned headers unchanged, and the raw bytes as the body. 7. Writing into a lab you do not own needs a **Contributor** role on it — see [Tutorial 3](tutorial-3-agent-access.md). After a role grant, an indexer lag of a few seconds can still return `UNAUTHORIZED`; retry with backoff. 8. **A successful `createLab` does not mean the lab is writable yet.** Step 4's first call can return `NOT_FOUND` ("Project 0x… does not exist") for a few seconds, because `createLab` falls back to an onchain ownership check while the file mutations read the indexed record. Retry `NOT_FOUND` with backoff on the first write after a mint; do not re-run `createLab`, which then returns `CONFLICT`. -9. Production has introspection off and a depth limit of 10. Generate types against staging. +9. **Three addresses, not interchangeable**: your own wallet (`walletAddress`, `changeBy`), the human owner's (`x-wallet-address`, their path only), and the Lab's OCL account (`labAccountAddress`, and the `account` argument in access conditions). Putting an owner's address where `labAccountAddress` belongs uploads fine and then locks everyone out of the file, with no error saying why. `oclId` is none of them — it is a lab id whose trailing 40 hex chars happen to be the OCL account address. See [the three wallets](../authentication.md#the-three-wallets-side-by-side). +10. Production has introspection off and a depth limit of 10. Generate types against staging. ## Related +* [The three wallets](../authentication.md#the-three-wallets-side-by-side) — owner vs agent vs OCL account, and which field each address goes in * [Getting Started](README.md) — prerequisites, costs, the lane you should be in * [Tutorials](README.md) — the same flow with responses and failure handling * [Labs API](../labs-api/README.md) — full operation reference diff --git a/api-reference/getting-started/tutorial-2-encrypted-upload.md b/api-reference/getting-started/tutorial-2-encrypted-upload.md index b3171b0..76fe487 100644 --- a/api-reference/getting-started/tutorial-2-encrypted-upload.md +++ b/api-reference/getting-started/tutorial-2-encrypted-upload.md @@ -159,7 +159,7 @@ const teamConditions = JSON.stringify([ ]); ``` -`:userAddress` is substituted with the authenticated caller's wallet at evaluation time. Pass `"2"` instead of `"1"` to exclude Viewers. Evaluation walks the array left to right and short-circuits (`or` stops at the first true); **any RPC error fails closed** and the DEK is not released. The full condition grammar, including `EvmBasicCondition`, is on [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md#worked-example-encrypt-for-owner-or-contributor-or-viewer). +`labAccountAddress` here is the **Lab's own OCL account**, not the owner's wallet — passing the owner's address instead evaluates to false and silently locks everyone out of the file ([the three wallets](../authentication.md#the-three-wallets-side-by-side)). `:userAddress` is substituted with the authenticated caller's wallet at evaluation time. Pass `"2"` instead of `"1"` to exclude Viewers. Evaluation walks the array left to right and short-circuits (`or` stops at the first true); **any RPC error fails closed** and the DEK is not released. The full condition grammar, including `EvmBasicCondition`, is on [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md#worked-example-encrypt-for-owner-or-contributor-or-viewer). ## Step 4d: Upload the ciphertext diff --git a/api-reference/getting-started/tutorial-3-agent-access.md b/api-reference/getting-started/tutorial-3-agent-access.md index 48ce5c4..1866288 100644 --- a/api-reference/getting-started/tutorial-3-agent-access.md +++ b/api-reference/getting-started/tutorial-3-agent-access.md @@ -24,6 +24,8 @@ The most common real-world shape: a researcher created their Lab in the Labs app The human never hands over a private key, a token, or their session. Revoking the agent is one onchain revoke, and it does not touch anything else. +Three addresses are in play here — the human's owner wallet, the agent's own wallet, and the Lab's own OCL account — and they are not interchangeable. If you are unsure which goes in which field, read [the three wallets, side by side](../authentication.md#the-three-wallets-side-by-side) first. + ## Step 1: The agent reports its address With the plugin: run `wallet_address`. With viem: diff --git a/technical-deep-dive/roles-and-permissions.md b/technical-deep-dive/roles-and-permissions.md index 2f477f1..d17215d 100644 --- a/technical-deep-dive/roles-and-permissions.md +++ b/technical-deep-dive/roles-and-permissions.md @@ -7,6 +7,10 @@ icon: users-gear # Roles & Permissions +{% hint style="info" %} +Roles are held by **wallets**, and a Lab involves up to three of them — the owner's wallet, an agent's wallet, and the Lab's own OCL account. For which is which and where each address belongs in an API call, see [the three wallets, side by side](../api-reference/authentication.md#the-three-wallets-side-by-side). +{% endhint %} + ## Why Roles Exist A Lab's NFT holder is its sole ultimate controller — transferring the LabNFT transfers the entire project. In practice, most research projects need to delegate day-to-day data-room work (uploading files, posting announcements, decrypting confidential research) to collaborators and AI agents without surrendering ownership. From 7b5019789ea263dd8f7ced19b94de299a7b3b6d2 Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Tue, 1 Sep 2026 14:25:14 +0100 Subject: [PATCH 08/16] chore(docs): remove announcements from docs --- .github/prompts/docs-sync.md | 5 +-- SUMMARY.md | 1 - api-reference/getting-started/README.md | 10 ++--- api-reference/getting-started/for-agents.md | 29 +++++++------ .../tutorial-1-public-upload.md | 3 +- .../tutorial-2-encrypted-upload.md | 1 - .../tutorial-3-agent-access.md | 41 +++++-------------- .../getting-started/tutorial-4-announce.md | 13 ++++-- api-reference/labs-api/README.md | 2 +- api-reference/labs-api/example-workflow.md | 20 --------- 10 files changed, 42 insertions(+), 83 deletions(-) delete mode 100644 api-reference/labs-api/example-workflow.md diff --git a/.github/prompts/docs-sync.md b/.github/prompts/docs-sync.md index ddc788a..7636f66 100644 --- a/.github/prompts/docs-sync.md +++ b/.github/prompts/docs-sync.md @@ -58,13 +58,12 @@ touches one of its source paths. | `api-reference/getting-started/tutorial-1-public-upload.md` | Tutorial 1 — self-issue a token, mint, `createLab`, three-call public upload, verify. Sources: `graphql/schemas/ip-hubs.graphql`; `lambda/appsync-resolver-labs-lambda/**` for `generateServiceToken`, `createLab`, `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile` and their authorization gates; `services/token-manager-service.ts`; `lib/utils/token-expiration.ts` (the `expiresIn` default and bounds). Every code block is expected to be runnable — a signature change here is a real breakage, so correct it and say so in the PR body. Steps 1–3 are duplicated by reference in Tutorials 2 and 3: **fix all copies or none.** | | `api-reference/getting-started/tutorial-2-encrypted-upload.md` | Tutorial 2 — DEK, local AES-256-GCM, `accessControlConditions`, `encryptionMetadata`, `decryptDataKey` round trip. Sources: `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts`, the encryption resolvers and condition evaluator in `lambda/appsync-resolver-labs-lambda/**`, `lib/encryption-stack.ts`. `encryptionSystem` is backend-set and must stay "echo it verbatim" — never let a literal be hardcoded here. | | `api-reference/getting-started/tutorial-3-agent-access.md` | Tutorial 3 — a Contributor-role agent writing into a lab a human owns. Sources: the per-mutation service-token gates in `lambda/appsync-resolver-labs-lambda/**` (`authorizeServiceMember` = Contributor, `authorizeServiceAdmin` = Owner) and `services/auth-service.ts`. **If a content-write mutation moves between those two gates, this page's role claims and its Owner-only list are wrong** — that is the single highest-value thing to check here. Role grants themselves are onchain (`AccessResolver`), out of this repo. | -| `api-reference/getting-started/tutorial-4-announce.md` | Tutorial 4 — `createAnnouncement` with `attachments`, verified off `labActivity`. Sources: the `createAnnouncement` resolver and `labActivity` in `lambda/appsync-resolver-labs-lambda/**`, `graphql/schemas/ip-hubs.graphql`. | +| `api-reference/getting-started/tutorial-4-announce.md` | `createAnnouncement` with `attachments`, verified off `labActivity` — **frozen and hidden** (`hidden: true`, out of `SUMMARY.md`): announcements are no longer surfaced in the Molecule app, so this is a reference for existing integrations, not part of onboarding. Sources: the `createAnnouncement` resolver and `labActivity` in `lambda/appsync-resolver-labs-lambda/**`, `graphql/schemas/ip-hubs.graphql`. Correct outright errors only; never expand it, and never re-introduce an announcement step into `getting-started/README.md`, `for-agents.md` or `tutorial-1`–`3`. | | `api-reference/labs-api/README.md` | `graphql/schemas/ip-hubs.graphql`, `lambda/appsync-resolver-labs-lambda/**` | -| `api-reference/labs-api/example-workflow.md` | **A hidden signpost, not a content page.** The tutorials moved to `getting-started/tutorial-*.md`; this file only exists so the already-published `/api-reference/labs-api/example-workflow` URL keeps resolving. No source paths — never document API behaviour here. Edit it only to fix a link, and never re-add it to `SUMMARY.md`. | | `api-reference/labs-api/lab-management.md` | `createLab`, `updateLabNftMetadata`, `generateLabImageUploadUrl` in `lambda/appsync-resolver-labs-lambda/**`; `lambda/labnft-metadata-lambda/**`; `lambda/ocl-processor/**` | | `api-reference/labs-api/files.md` | file operations in `lambda/appsync-resolver-labs-lambda/**` (`initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata`, `moveEntry`), `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts` | | `api-reference/labs-api/browse-and-search.md` | `labs`, `searchLabs`, `labWithDataRoomAndFiles`, `dataRoomFile`, `activities`, `labActivity` resolvers; `graphql/schemas/onchain-activity.graphql` | -| `api-reference/labs-api/legal-agreements.md` | `signLegalAgreement`, `legalAgreementTemplate`, `legalAgreementStatus` resolvers — **frozen and hidden** (`hidden: true`, out of `SUMMARY.md`) since IP-3028: the assignment agreement is no longer a gate on anything and the feature may be removed. Correct outright errors only; never expand it, and never re-introduce a `legalAgreement*` mention into `getting-started/**`, `authentication.md`, `labs-api/README.md` or `example-workflow.md` | +| `api-reference/labs-api/legal-agreements.md` | `signLegalAgreement`, `legalAgreementTemplate`, `legalAgreementStatus` resolvers — **frozen and hidden** (`hidden: true`, out of `SUMMARY.md`) since IP-3028: the assignment agreement is no longer a gate on anything and the feature may be removed. Correct outright errors only; never expand it, and never re-introduce a `legalAgreement*` mention into `getting-started/**`, `authentication.md`, `labs-api/README.md` | | `api-reference/labs-api/service-tokens.md` | `generateServiceToken`, `extendServiceToken`, `revokeServiceToken` resolvers in `lambda/appsync-resolver-labs-lambda/**` (`services/token-manager-service.ts`); `utils/service-auth-message.ts` and the sign-in nonce constants (`SIGNIN_NONCE_VALIDITY_MS` — the documented 10-minute window) in `services/token-manager-service.ts`; `lambda/appsync-authorizer-lambda/**`. **The documented `details.reason` values (`NONCE_NOT_FOUND`, `NONCE_EXPIRED`, `INVALID_SIGNATURE`, `WALLET_MISMATCH`) come from the `generateServiceToken` resolver** — keep the table and the validity window in step with it | | `api-reference/tokenization-api.md` | `graphql/schemas/evm-tokenization.graphql`, `lambda/appsync-resolver-evm-tokenization/**`, `lib/evm-tokenization-service-stack.ts` | | `api-reference/x402-gateway.md` | `lambda/x402-gateway-lambda/**` | diff --git a/SUMMARY.md b/SUMMARY.md index b4b2d61..26cb2ed 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -37,7 +37,6 @@ * [Tutorial 1: Create a lab and upload a public file](api-reference/getting-started/tutorial-1-public-upload.md) * [Tutorial 2: Upload an encrypted file](api-reference/getting-started/tutorial-2-encrypted-upload.md) * [Tutorial 3: Give your agent access to a lab](api-reference/getting-started/tutorial-3-agent-access.md) - * [Tutorial 4: Announce the dataset](api-reference/getting-started/tutorial-4-announce.md) * [🤖 For Agents: One-Pager](api-reference/getting-started/for-agents.md) * [🔐 Authentication](api-reference/authentication.md) * [⚙️ Labs API](api-reference/labs-api/README.md) diff --git a/api-reference/getting-started/README.md b/api-reference/getting-started/README.md index 3523886..4368ed7 100644 --- a/api-reference/getting-started/README.md +++ b/api-reference/getting-started/README.md @@ -7,7 +7,7 @@ icon: rocket # 🚀 Getting Started -This is the entry point to the Molecule API. It helps you **pick a lane**, lists the **two prerequisites** you actually need, and hands you a **ten-minute quickstart** that ends with a lab you can see. The four tutorials underneath it take the same ground step by step. +This is the entry point to the Molecule API. It helps you **pick a lane**, lists the **two prerequisites** you actually need, and hands you a **ten-minute quickstart** that ends with a lab you can see. The three tutorials underneath it take the same ground step by step. Everything here runs against **staging** (Base Sepolia, testnet funds). Nothing spends real money. This page also holds the two things every tutorial shares: the [shared setup block](#shared-setup) and the [staging → production swap table](#running-in-production). @@ -16,7 +16,6 @@ Everything here runs against **staging** (Base Sepolia, testnet funds). Nothing | [**Tutorial 1**](tutorial-1-public-upload.md) | Create a lab and upload a public file — **start here** | | [**Tutorial 2**](tutorial-2-encrypted-upload.md) | Upload an encrypted file, verified with a decrypt round trip | | [**Tutorial 3**](tutorial-3-agent-access.md) | Give your agent access to a lab you created in the app | -| [**Tutorial 4**](tutorial-4-announce.md) | Announce the dataset | *** @@ -88,7 +87,7 @@ You do **not** need a pre-issued service token. Every tutorial below mints its o | Item | Cost | How we know | | ---- | ---- | ----------- | | **LabNFT mint** | Gas only. `mintFeeWei()` reads **0** on Base Sepolia **and** on Base mainnet (verified 2026-08-27 by `eth_call`) | Read it live yourself — the tutorials do, and send it as `value` | -| **`createLab`, uploads, announcements** (service-token lane) | Free | Consumer credential + self-issued service token | +| **`createLab`, uploads and other content writes** (service-token lane) | Free | Consumer credential + self-issued service token | | **The same mutations via x402** | Quoted per request in the `402` challenge — **$0.01 USDC** on both environments today | [Read the price off the challenge](../x402-gateway.md#reading-the-402-challenge); never hardcode it | | **Storage** | 5 GB per lab included | [Limits](../labs-api/files.md#storage-limits) | @@ -207,7 +206,7 @@ async function withIndexerLagRetry( **Two places the indexer trails, and both need that retry.** A successful response does not mean every downstream read is caught up yet: * **After minting**, the lab's first write can return `NOT_FOUND` — even though `createLab` just succeeded, because `createLab` falls back to an onchain ownership check while the file mutations read the indexed record. [Tutorial 1 Step 4](tutorial-1-public-upload.md#step-4-upload-the-file). -* **After a role grant**, a write can return `UNAUTHORIZED` until the grant is indexed. [Tutorial 3](tutorial-3-agent-access.md#step-4-the-agent-uploads-and-announces). +* **After a role grant**, a write can return `UNAUTHORIZED` until the grant is indexed. [Tutorial 3](tutorial-3-agent-access.md#step-4-the-agent-uploads). Usually both clear within seconds — but a mint has taken **several minutes** to index on staging under backlog, so the budget above is deliberately generous (12 attempts, backoff capped at 30s, ~4 minutes total) and logs each wait. Use the retry above with `codes` set to the one you expect, and do not treat the first failure as fatal. {% endhint %} @@ -269,7 +268,6 @@ The second is visual — once `shortname` is populated, the lab has a page: | Every step with expected responses and failure handling | [Tutorial 1 — public upload](tutorial-1-public-upload.md) | | Encrypt a file so only wallets with a role can read it | [Tutorial 2 — encrypted upload](tutorial-2-encrypted-upload.md) | | Let an agent write into a lab a human created in the app | [Tutorial 3 — agent access](tutorial-3-agent-access.md) | -| Publish an update that attaches the dataset | [Tutorial 4 — announce](tutorial-4-announce.md) | | Pay per call instead of holding a token | [x402 Gateway](../x402-gateway.md) | | Full operation reference | [Labs API](../labs-api/README.md) | | What every error code means | [Error handling](../labs-api/README.md#error-handling) | @@ -278,7 +276,7 @@ The second is visual — once `shortname` is populated, the lab has a page: ## Running in Production -All four tutorials run against staging (Base Sepolia, testnet funds). To run the same scripts against production, replace the values in the config block — nothing else changes, since every step reads from these constants: +All three tutorials run against staging (Base Sepolia, testnet funds). To run the same scripts against production, replace the values in the config block — nothing else changes, since every step reads from these constants: | Constant | Staging (these tutorials) | Production | | -------- | ------------------------- | ---------- | diff --git a/api-reference/getting-started/for-agents.md b/api-reference/getting-started/for-agents.md index 312f8f4..8c41450 100644 --- a/api-reference/getting-started/for-agents.md +++ b/api-reference/getting-started/for-agents.md @@ -9,7 +9,9 @@ icon: robot The complete default flow — self-issue a token, mint a lab, upload a public file, verify — with nothing else on the page. Copy it into a system prompt or a context file. For the same flow with expected responses, failure modes and the encrypted variant, use [the tutorials](README.md). -## Constants (staging / Base Sepolia) +## Constants + +Staging (Base Sepolia) — everything on this page runs against these: ``` GRAPHQL_URL https://staging.graphql.api.molecule.xyz/graphql @@ -21,7 +23,17 @@ ACCESS_CONDITION_CHAIN "baseSepolia" LAB_PAGE https://testnet.labs.molecule.xyz/projects/ ``` -Production: `https://production.graphql.api.molecule.xyz/graphql`, `base` (8453), factory `0xECdF4f05384056507485C90aeAb0a83268760D6E`, LabNFT `0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92`, AccessResolver `0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B`, condition chain `"base"`, lab page `https://labs.molecule.xyz/projects/`. +Production (Base) — swap these in, nothing else changes: + +``` +GRAPHQL_URL https://production.graphql.api.molecule.xyz/graphql +CHAIN base (8453) +ONCHAIN_LAB_FACTORY 0xECdF4f05384056507485C90aeAb0a83268760D6E +LABNFT 0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92 +ACCESS_RESOLVER 0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B +ACCESS_CONDITION_CHAIN "base" +LAB_PAGE https://labs.molecule.xyz/projects/ +``` ## Headers @@ -137,19 +149,6 @@ query Verify($oclId: String!) { Public query, `Authorization` only. Your `path` is in `dataRoom.files`. A `null` result means the lab is not registered — this query is nullable and does not throw for a missing lab. -## Optional — announce it - -```graphql -mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!, $attachments: [String!]) { - createAnnouncement(oclId: $oclId, headline: $headline, body: $body, attachments: $attachments) { - message - error { code message requestId retryable details } - } -} -``` - -`attachments` takes the `datasetId` values returned by `finishCreateOrUpdateFile`. - ## Encrypted files, in four lines 1. `generateDataEncryptionKey` → `{ plaintextDEK, encryptedDek, encryptionSystem }`. diff --git a/api-reference/getting-started/tutorial-1-public-upload.md b/api-reference/getting-started/tutorial-1-public-upload.md index 9c898bb..99dd06c 100644 --- a/api-reference/getting-started/tutorial-1-public-upload.md +++ b/api-reference/getting-started/tutorial-1-public-upload.md @@ -336,7 +336,7 @@ The `PUT` returns HTTP `200` with an empty body. Then: `message` on this result is passed through from the storage layer, so its exact wording varies and is deliberately not shown here — it is **not part of the contract**. Assert on `error == null`, never on `message`. -Keep `datasetId` — Tutorial 4 attaches it to an announcement. +Keep `datasetId` — it is the file's stable identifier for later reads and updates. **If it fails:** @@ -633,6 +633,5 @@ node tutorial-1.js ./research-data.csv | --- | --- | | Make the next file confidential | [Tutorial 2 — Upload an encrypted file](tutorial-2-encrypted-upload.md) | | Let an agent write into this lab | [Tutorial 3 — Give your agent access](tutorial-3-agent-access.md) | -| Publish an update attaching the file | [Tutorial 4 — Announce the dataset](tutorial-4-announce.md) | | Run it against mainnet | [Running in Production](README.md#running-in-production) | | Per-operation reference | [Files](../labs-api/files.md) · [Lab Management](../labs-api/lab-management.md) | diff --git a/api-reference/getting-started/tutorial-2-encrypted-upload.md b/api-reference/getting-started/tutorial-2-encrypted-upload.md index 76fe487..c842736 100644 --- a/api-reference/getting-started/tutorial-2-encrypted-upload.md +++ b/api-reference/getting-started/tutorial-2-encrypted-upload.md @@ -664,6 +664,5 @@ node tutorial-2.js ./confidential-results.csv | | | | --- | --- | | Let an agent decrypt and contribute too | [Tutorial 3 — Give your agent access](tutorial-3-agent-access.md) | -| Publish an update attaching the file | [Tutorial 4 — Announce the dataset](tutorial-4-announce.md) | | Run it against mainnet | [Running in Production](README.md#running-in-production) | | How conditions are evaluated, in depth | [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md) | diff --git a/api-reference/getting-started/tutorial-3-agent-access.md b/api-reference/getting-started/tutorial-3-agent-access.md index 1866288..1a4b17c 100644 --- a/api-reference/getting-started/tutorial-3-agent-access.md +++ b/api-reference/getting-started/tutorial-3-agent-access.md @@ -20,7 +20,7 @@ The most common real-world shape: a researcher created their Lab in the Labs app | 1 | Agent | Generate a wallet and report its address | | 2 | Human | Add that address to the lab as **Contributor**, flagged as an agent, with an expiry | | 3 | Agent | Self-issue a service token by signing the sign-in message | -| 4 | Agent | Upload files and announce; the human sees the result in the app | +| 4 | Agent | Upload files; the human sees the result in the app | The human never hands over a private key, a token, or their session. Revoking the agent is one onchain revoke, and it does not touch anything else. @@ -57,7 +57,7 @@ function grantRole(bytes32 oclId, address account, uint8 role, uint64 expiry, bo Only the **Owner** may grant Contributor. Contributors can grant Viewer, but not Contributor. Full capability matrix: [Roles & Permissions](../../technical-deep-dive/roles-and-permissions.md). -**Why Contributor and not Viewer:** a Viewer can decrypt and read but cannot write. Uploading files and posting announcements needs Contributor. +**Why Contributor and not Viewer:** a Viewer can decrypt and read but cannot write. Uploading files needs Contributor. Both parties can confirm the grant landed with a public query — no authentication beyond the consumer credential: @@ -126,9 +126,9 @@ serviceToken = tokenResult.generateServiceToken.token; Issuance is **not** gated on holding a role — any wallet can mint a token for itself. The role is what makes the token *useful*: authorisation is resolved per request from the token's wallet against the lab you name. So a token issued before the grant lands keeps working once it does; you do not need to re-issue it. -## Step 4: The agent uploads and announces +## Step 4: The agent uploads -From here the agent is an ordinary caller. Run [Tutorial 1 Step 4](tutorial-1-public-upload.md#step-4-upload-the-file) with `changeBy: agentAccount.address`, or [Tutorial 2](tutorial-2-encrypted-upload.md) for a confidential file — the `hasRole` branch of the team conditions is exactly what lets the agent decrypt too. Then [Tutorial 4](tutorial-4-announce.md) to surface it on the lab's feed. +From here the agent is an ordinary caller. Run [Tutorial 1 Step 4](tutorial-1-public-upload.md#step-4-upload-the-file) with `changeBy: agentAccount.address`, or [Tutorial 2](tutorial-2-encrypted-upload.md) for a confidential file — the `hasRole` branch of the team conditions is exactly what lets the agent decrypt too. Writes by a Contributor service token are gated per mutation, matching the Privy user path: `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createAnnouncement`, `deleteDataRoomFile`, `updateFileMetadata` and `moveEntry` all accept Contributor. A few surfaces remain **Owner-only** and an agent Contributor cannot reach them: `updateLabNftMetadata`, `generateLabImageUploadUrl` and the legal-agreement mutations. @@ -168,14 +168,13 @@ const verify = await graphql( labWithDataRoomAndFiles(oclId: $oclId) { shortname dataRoom { files { path accessLevel version createdBy } } - announcements { headline changeBy } } }`, { oclId }, ); ``` -**The human** verifies in the app: the file appears in the lab's data room and the announcement on its activity feed, both attributed to the agent's address, which the members list shows flagged as an agent. +**The human** verifies in the app: the file appears in the lab's data room, attributed to the agent's address, which the members list shows flagged as an agent. ## Revoking the agent @@ -189,7 +188,7 @@ Or let the grant's `expiry` lapse. Independently, the agent's token can be kille ## Complete script -The agent's half of the flow, standalone: report the wallet address, wait for the human's grant to appear, self-issue a token, upload, announce, verify. Steps 1 and 2 involve a human, so the script polls for the role rather than assuming it. +The agent's half of the flow, standalone: report the wallet address, wait for the human's grant to appear, self-issue a token, upload, verify. Steps 1 and 2 involve a human, so the script polls for the role rather than assuming it. Run it with the agent's own key and the `oclId` of the human's lab — the agent never sees the owner's key. @@ -282,7 +281,7 @@ async function main() { throw new Error("Store that key, grant it Contributor, then re-run."); } const agentAccount = privateKeyToAccount(AGENT_PRIVATE_KEY); - console.log("1/6 Agent wallet:", agentAccount.address); + console.log("1/5 Agent wallet:", agentAccount.address); console.log(" Ask the lab owner to add it as Contributor (isAgent = true)."); // ---- Step 2: wait for the human's grant (public query, no auth needed) ---- @@ -304,7 +303,7 @@ async function main() { } if (!grant) throw new Error("No role grant found for the agent wallet — ask the owner to add it"); if (grant.role === "VIEWER") throw new Error("Agent holds VIEWER; uploading needs CONTRIBUTOR"); - console.log("2/6 Role:", grant.role, "isAgent:", grant.isAgent, "expiry:", grant.expiry ?? "permanent"); + console.log("2/5 Role:", grant.role, "isAgent:", grant.isAgent, "expiry:", grant.expiry ?? "permanent"); // ---- Step 3: the agent self-issues a token ---- // Only now, after the role poll above returned — the sign-in message holds a @@ -334,7 +333,7 @@ async function main() { ); assertOk(tokenResult.generateServiceToken, "generateServiceToken"); serviceToken = tokenResult.generateServiceToken.token; - console.log("3/6 Token issued, expires", tokenResult.generateServiceToken.expiresAt); + console.log("3/5 Token issued, expires", tokenResult.generateServiceToken.expiresAt); // ---- Step 4: upload (public; see Tutorial 2 for the encrypted variant) ---- // Retried on UNAUTHORIZED: the role grant may not be indexed yet. NOT_FOUND @@ -380,24 +379,7 @@ async function main() { assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); return finishResult.finishCreateOrUpdateFile; }, { codes: ["UNAUTHORIZED", "NOT_FOUND"] }); - console.log("4/6 Uploaded — datasetId:", datasetId); - - // ---- Step 4b: announce it ---- - const announcement = await graphql( - `mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!, $attachments: [String!]) { - createAnnouncement(oclId: $oclId, headline: $headline, body: $body, attachments: $attachments) { - error { code message requestId retryable details } - } - }`, - { - oclId: OCL_ID, - headline: `Agent analysis: ${basename(filePath)}`, - body: "Written by an autonomous agent holding a Contributor role on this lab.", - attachments: [datasetId], - }, - ); - assertOk(announcement.createAnnouncement, "createAnnouncement"); - console.log("5/6 Announced"); + console.log("4/5 Uploaded — datasetId:", datasetId); // ---- Step 5: verify ---- const verify = await graphql( @@ -416,7 +398,7 @@ async function main() { const attributed = file.createdBy?.toLowerCase() === agentAccount.address.toLowerCase(); console.log( - "6/6 Verified:", file.path, file.accessLevel, + "5/5 Verified:", file.path, file.accessLevel, attributed ? "— attributed to the agent" : `— createdBy: ${file.createdBy}`, ); if (verify.labWithDataRoomAndFiles.shortname) { @@ -448,6 +430,5 @@ node tutorial-3.js ./findings.csv | | | | --- | --- | | What the agent uploads | [Tutorial 1 — public file](tutorial-1-public-upload.md) · [Tutorial 2 — encrypted file](tutorial-2-encrypted-upload.md) | -| Have the agent announce its work | [Tutorial 4 — Announce the dataset](tutorial-4-announce.md) | | The role model in full | [Roles & Permissions](../../technical-deep-dive/roles-and-permissions.md) | | Let the agent run the whole workflow as tool calls | [Molecule Skill](../../ai-tooling/molecule-skill.md) | diff --git a/api-reference/getting-started/tutorial-4-announce.md b/api-reference/getting-started/tutorial-4-announce.md index 9f75cdc..bd94d60 100644 --- a/api-reference/getting-started/tutorial-4-announce.md +++ b/api-reference/getting-started/tutorial-4-announce.md @@ -3,11 +3,16 @@ description: >- Publish a lab update that attaches the dataset you uploaded, and read it back off the activity feed. icon: bullhorn +hidden: true --- -# Tutorial 4: Announce the dataset +# Announce the dataset -An announcement is the lab's public update stream: a headline, a body, and optionally the datasets it is about. Attaching the file makes the announcement the discoverable surface for it — announcements are indexed by `searchLabs` alongside files, and they appear on the lab's activity feed and public page. +{% hint style="warning" %} +**Not part of onboarding.** Announcements are no longer surfaced in the Molecule app, so posting one is not a step we point new integrations at. `createAnnouncement` is still in the schema and this page is retained as a reference for integrations that already use it, deliberately out of the site navigation. Do not build a new integration around it — onboarding starts at [Getting Started](README.md), and the mutation itself is documented in [Files](../labs-api/files.md#create-announcement). +{% endhint %} + +An announcement is the lab's update stream: a headline, a body, and optionally the datasets it is about. Announcements are still indexed by `searchLabs` alongside files and still readable off `labActivity`. Requires Owner or Contributor (a Viewer cannot announce). Pick up with `oclId`, `serviceToken` and the `datasetId` returned by `finishCreateOrUpdateFile`. @@ -57,7 +62,7 @@ assertOk(announcementResult.createAnnouncement, "createAnnouncement"); | `error.code` | What happened | Fix | | ------------ | ------------- | --- | -| `UNAUTHORIZED` | Caller is a Viewer, or has no role | Contributor or Owner required. Fresh grant? See the [retry note](tutorial-3-agent-access.md#step-4-the-agent-uploads-and-announces) | +| `UNAUTHORIZED` | Caller is a Viewer, or has no role | Contributor or Owner required. Fresh grant? See the [retry note](tutorial-3-agent-access.md#step-4-the-agent-uploads) | | `NOT_FOUND` | An `attachments` entry isn't a dataset in this lab | Pass the exact `datasetId` strings from `finishCreateOrUpdateFile`, from this lab | | `VALIDATION_FAILED` | Empty `headline` or `body` | Both are required and non-empty | @@ -90,7 +95,7 @@ console.log(JSON.stringify(feed.labActivity.nodes[0], null, 2)); Your announcement is the newest node, with `attachments` resolved to the full file objects — not just ids — and `changeBy` set to the wallet that posted it. It is also on the lab's public page at `${LAB_APP_URL}/projects/`. -`labActivity` is a **public** query: anyone with a consumer credential can read the feed, which is the point of an announcement. +`labActivity` is a **public** query: anyone with a consumer credential can read the feed. ## Complete script diff --git a/api-reference/labs-api/README.md b/api-reference/labs-api/README.md index b2d0d0c..ff93ad4 100644 --- a/api-reference/labs-api/README.md +++ b/api-reference/labs-api/README.md @@ -21,7 +21,7 @@ The Labs API allows developers to interact with Molecule Labs datarooms without | | | | --- | --- | | **First time here** | [🚀 Getting Started](../getting-started/README.md) — prerequisites, costs, ten-minute quickstart | -| **You want runnable code** | [Tutorial 1](../getting-started/tutorial-1-public-upload.md) (public upload) · [2](../getting-started/tutorial-2-encrypted-upload.md) (encrypted) · [3](../getting-started/tutorial-3-agent-access.md) (agent access) · [4](../getting-started/tutorial-4-announce.md) (announce) | +| **You want runnable code** | [Tutorial 1](../getting-started/tutorial-1-public-upload.md) (public upload) · [2](../getting-started/tutorial-2-encrypted-upload.md) (encrypted) · [3](../getting-started/tutorial-3-agent-access.md) (agent access) | | **You're an AI agent** | [Agent one-pager](../getting-started/for-agents.md), or drive this API through the [Molecule Skill](../../ai-tooling/molecule-skill.md) plugin | | **You want to pay per call** | [x402 Gateway](../x402-gateway.md) | diff --git a/api-reference/labs-api/example-workflow.md b/api-reference/labs-api/example-workflow.md deleted file mode 100644 index 8226986..0000000 --- a/api-reference/labs-api/example-workflow.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -description: This page moved. The walkthrough is now four tutorials under Getting Started. -hidden: true -icon: signs-post ---- - -# Example Workflow (moved) - -This page has been replaced by four step-by-step tutorials under **[🚀 Getting Started](../getting-started/README.md)**, each one runnable end to end and each ending in a check that it worked. - -| | | -| --- | --- | -| [**Tutorial 1: Create a lab and upload a public file**](../getting-started/tutorial-1-public-upload.md) | The default path — self-issue a token, mint the LabNFT, register the lab, upload, verify. **Start here.** | -| [**Tutorial 2: Upload an encrypted file**](../getting-started/tutorial-2-encrypted-upload.md) | Local AES-256-GCM, onchain access conditions, verified with a decrypt round trip | -| [**Tutorial 3: Give your agent access to a lab you created in the app**](../getting-started/tutorial-3-agent-access.md) | The human owns the lab and never hands over a key | -| [**Tutorial 4: Announce the dataset**](../getting-started/tutorial-4-announce.md) | Publish an update that attaches the file you uploaded | - -The two things all four share also live on the Getting Started page: the [shared setup block](../getting-started/README.md#shared-setup) and the [staging → production swap table](../getting-started/README.md#running-in-production). - -If you arrived here from a bookmark or an old link, [Tutorial 1](../getting-started/tutorial-1-public-upload.md) is the direct replacement for what used to be on this page — minus the assignment-agreement step, which is [no longer a gate on anything](../changelog.md#assignment-agreement-is-no-longer-a-gate-and-is-out-of-the-api-docs). From 4e02a6d80687fdcfeffa578bf8f9cf80d6119951 Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Tue, 1 Sep 2026 16:01:43 +0100 Subject: [PATCH 09/16] docs: remove announcements from guidance surfaces Announcements are no longer surfaced in the Molecule app, so they are out of every onboarding, how-to and feature-description surface: - delete the Announce tutorial and the "Create Announcement" section in labs-api/files.md - drop the createAnnouncement x402 endpoint, and re-point the 402 challenge and request-format worked examples at createLab - drop the permission-matrix rows in authentication.md and roles-and-permissions.md, and the Contributor gate lists - strip the prose in the data/roles deep dives, the developer guide and the Molecule Skill page (Announce phase removed, steps renumbered) Kept deliberately: the LabEventAnnouncement / SearchLabsAnnouncementHit union variants and the ANNOUNCEMENT filter value, which callers still receive from labActivity and searchLabs regardless of what we document; the changelog and release-notes entries, which are shipped history; and the verbatim aura-orchestrator SKILL.md frontmatter quote, which is a reproduction of the plugin's own manifest. Co-Authored-By: Claude Opus 5 (1M context) --- .github/prompts/docs-sync.md | 1 - ai-tooling/molecule-skill.md | 13 +- api-reference/authentication.md | 3 +- .../tutorial-3-agent-access.md | 2 +- .../getting-started/tutorial-4-announce.md | 261 ------------------ api-reference/labs-api/README.md | 2 +- api-reference/labs-api/files.md | 62 +---- api-reference/x402-gateway.md | 25 +- .../data/data-api-and-integration.md | 16 +- technical-deep-dive/data/data-storage.md | 4 +- technical-deep-dive/roles-and-permissions.md | 5 +- user-guides/developers-ai-agents.md | 4 +- 12 files changed, 34 insertions(+), 364 deletions(-) delete mode 100644 api-reference/getting-started/tutorial-4-announce.md diff --git a/.github/prompts/docs-sync.md b/.github/prompts/docs-sync.md index 7636f66..2efeb4f 100644 --- a/.github/prompts/docs-sync.md +++ b/.github/prompts/docs-sync.md @@ -58,7 +58,6 @@ touches one of its source paths. | `api-reference/getting-started/tutorial-1-public-upload.md` | Tutorial 1 — self-issue a token, mint, `createLab`, three-call public upload, verify. Sources: `graphql/schemas/ip-hubs.graphql`; `lambda/appsync-resolver-labs-lambda/**` for `generateServiceToken`, `createLab`, `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile` and their authorization gates; `services/token-manager-service.ts`; `lib/utils/token-expiration.ts` (the `expiresIn` default and bounds). Every code block is expected to be runnable — a signature change here is a real breakage, so correct it and say so in the PR body. Steps 1–3 are duplicated by reference in Tutorials 2 and 3: **fix all copies or none.** | | `api-reference/getting-started/tutorial-2-encrypted-upload.md` | Tutorial 2 — DEK, local AES-256-GCM, `accessControlConditions`, `encryptionMetadata`, `decryptDataKey` round trip. Sources: `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts`, the encryption resolvers and condition evaluator in `lambda/appsync-resolver-labs-lambda/**`, `lib/encryption-stack.ts`. `encryptionSystem` is backend-set and must stay "echo it verbatim" — never let a literal be hardcoded here. | | `api-reference/getting-started/tutorial-3-agent-access.md` | Tutorial 3 — a Contributor-role agent writing into a lab a human owns. Sources: the per-mutation service-token gates in `lambda/appsync-resolver-labs-lambda/**` (`authorizeServiceMember` = Contributor, `authorizeServiceAdmin` = Owner) and `services/auth-service.ts`. **If a content-write mutation moves between those two gates, this page's role claims and its Owner-only list are wrong** — that is the single highest-value thing to check here. Role grants themselves are onchain (`AccessResolver`), out of this repo. | -| `api-reference/getting-started/tutorial-4-announce.md` | `createAnnouncement` with `attachments`, verified off `labActivity` — **frozen and hidden** (`hidden: true`, out of `SUMMARY.md`): announcements are no longer surfaced in the Molecule app, so this is a reference for existing integrations, not part of onboarding. Sources: the `createAnnouncement` resolver and `labActivity` in `lambda/appsync-resolver-labs-lambda/**`, `graphql/schemas/ip-hubs.graphql`. Correct outright errors only; never expand it, and never re-introduce an announcement step into `getting-started/README.md`, `for-agents.md` or `tutorial-1`–`3`. | | `api-reference/labs-api/README.md` | `graphql/schemas/ip-hubs.graphql`, `lambda/appsync-resolver-labs-lambda/**` | | `api-reference/labs-api/lab-management.md` | `createLab`, `updateLabNftMetadata`, `generateLabImageUploadUrl` in `lambda/appsync-resolver-labs-lambda/**`; `lambda/labnft-metadata-lambda/**`; `lambda/ocl-processor/**` | | `api-reference/labs-api/files.md` | file operations in `lambda/appsync-resolver-labs-lambda/**` (`initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata`, `moveEntry`), `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts` | diff --git a/ai-tooling/molecule-skill.md b/ai-tooling/molecule-skill.md index eb11823..2c26aa2 100644 --- a/ai-tooling/molecule-skill.md +++ b/ai-tooling/molecule-skill.md @@ -1,7 +1,7 @@ --- description: >- - An agent plugin that runs the full Lab workflow — create an Onchain Lab, - upload research data, and announce it — through AI coding agents + An agent plugin that runs the full Lab workflow — create an Onchain Lab and + upload research data — through AI coding agents icon: wand-magic-sparkles --- @@ -9,11 +9,11 @@ icon: wand-magic-sparkles ### Overview -The Molecule skill lets AI agents execute the complete Lab lifecycle end-to-end — create an Onchain Lab, upload research files (public or encrypted), publish announcements, and manage roles — without a browser and without hand-written API calls. +The Molecule skill lets AI agents execute the complete Lab lifecycle end-to-end — create an Onchain Lab, upload research files (public or encrypted), and manage roles — without a browser and without hand-written API calls. It ships as a cross-harness agent plugin with two parts: -* **The `aura-orchestrator` skill** (`SKILL.md`) — a step-by-step runbook the agent follows: resolve or create an Onchain Lab (LabNFT plus its token-bound account), register it, upload files to the data room, announce, and optionally grant roles or hand the Lab off to another owner. +* **The `aura-orchestrator` skill** (`SKILL.md`) — a step-by-step runbook the agent follows: resolve or create an Onchain Lab (LabNFT plus its token-bound account), register it, upload files to the data room, and optionally grant roles or hand the Lab off to another owner. * **The `molecule` MCP server** — a typed [Model Context Protocol](https://modelcontextprotocol.io) server that performs every network, onchain, and cryptographic operation as a single tool call. Paid mutations are settled automatically through the [x402 Gateway](../api-reference/x402-gateway.md). The skill format (`SKILL.md`) and MCP are open standards, so the same plugin works under Claude Code, OpenAI Codex, and any other MCP-capable agent harness. To obtain and install it, jump to [Getting the Plugin](molecule-skill.md#getting-the-plugin). @@ -32,8 +32,7 @@ The workflow is sequential — each phase consumes the previous phase's output: | 1 | Resolve or create the Lab | Reuse a Lab the wallet already owns, or mint a new LabNFT with its token-bound account | | 2 | Register the Lab | `createLab` mutation, paid via x402 | | 3 | Upload a file to the data room | Public (plaintext) or private (client-side encrypted, access-controlled) | -| 4 | Announce | `createAnnouncement` mutation attaching the uploaded dataset, paid via x402 | -| 5 | Grant roles / hand off | Optionally grant a co-owner role or transfer the LabNFT to another wallet | +| 4 | Grant roles / hand off | Optionally grant a co-owner role or transfer the LabNFT to another wallet | #### Public vs. Private Uploads @@ -97,7 +96,7 @@ Run **`config_doctor`** after setting these: it reports which environment profil | ------------------------------------------------------------ | -------------------------------------------------------------------------------------- | | `ENVIRONMENT` | Deployment profile: `staging` (Base Sepolia) or `production` (Base) | | `MOLECULE_LABS_URL` | Labs API GraphQL endpoint for the chosen environment — see [API Overview](../api-reference/README.md) for the URLs | -| `MOLECULE_CLIENT_URL` | Labs app base URL, used to build project links in announcements | +| `MOLECULE_CLIENT_URL` | Labs app base URL, used to build project links | | `X402_GATEWAY_URL` | x402 Gateway base URL (endpoint paths are documented on the [x402 Gateway](../api-reference/x402-gateway.md) page) | | `CHAIN_ID` | `84532` (Base Sepolia) or `8453` (Base), matching `ENVIRONMENT` | | `EVM_RPC_URL` | RPC endpoint for onchain reads and broadcasts (optional; falls back to a public node) | diff --git a/api-reference/authentication.md b/api-reference/authentication.md index 2ecf0ba..376c064 100644 --- a/api-reference/authentication.md +++ b/api-reference/authentication.md @@ -68,7 +68,7 @@ The Labs read surface is public — these queries need only a consumer credentia - `labActivity` - Get activity feed for a lab, (available filters: ANNOUNCEMENT | FILE) - `activities` - Get global activity feed, (available filters: ANNOUNCEMENT | FILE) - `dataRoomFile` - Get file by path -- `searchLabs` - Search across labs, files, and announcements +- `searchLabs` - Search across labs and files - `fileCategoriesAndTags` - List valid file categories and their tags - `getServiceSignInMessage` - Get the message a service signs to obtain a token - `getDidLinkStatus` - Get background DID-linking status for a lab @@ -111,7 +111,6 @@ Either way, the caller still has to be authorized for the target lab, and the ch | `updateFileMetadata` - Update file metadata | Contributor | | | `deleteDataRoomFile` - Delete a file | Contributor | | | `moveEntry` - Move a file or folder | Contributor | | -| `createAnnouncement` - Create an announcement | Contributor | 💳 also pay-per-call via [x402](x402-gateway.md) | | `updateLabNftMetadata` - Update LabNFT display metadata | **Owner only** | | | `generateLabImageUploadUrl` - Presigned URL for a LabNFT image | **Owner only** | | | `generateDataEncryptionKey` - Generate a standalone data encryption key | Authenticated, no role | Takes no `oclId`, so there is no lab to check against. 💳 also pay-per-call via [x402](x402-gateway.md) | diff --git a/api-reference/getting-started/tutorial-3-agent-access.md b/api-reference/getting-started/tutorial-3-agent-access.md index 1a4b17c..a207594 100644 --- a/api-reference/getting-started/tutorial-3-agent-access.md +++ b/api-reference/getting-started/tutorial-3-agent-access.md @@ -130,7 +130,7 @@ Issuance is **not** gated on holding a role — any wallet can mint a token for From here the agent is an ordinary caller. Run [Tutorial 1 Step 4](tutorial-1-public-upload.md#step-4-upload-the-file) with `changeBy: agentAccount.address`, or [Tutorial 2](tutorial-2-encrypted-upload.md) for a confidential file — the `hasRole` branch of the team conditions is exactly what lets the agent decrypt too. -Writes by a Contributor service token are gated per mutation, matching the Privy user path: `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createAnnouncement`, `deleteDataRoomFile`, `updateFileMetadata` and `moveEntry` all accept Contributor. A few surfaces remain **Owner-only** and an agent Contributor cannot reach them: `updateLabNftMetadata`, `generateLabImageUploadUrl` and the legal-agreement mutations. +Writes by a Contributor service token are gated per mutation, matching the Privy user path: `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata` and `moveEntry` all accept Contributor. A few surfaces remain **Owner-only** and an agent Contributor cannot reach them: `updateLabNftMetadata`, `generateLabImageUploadUrl` and the legal-agreement mutations. {% hint style="warning" %} **Retry on `UNAUTHORIZED` right after the grant.** Role state reaches the API through an event indexer, so for a window after `grantRole` confirms onchain a write still returns `UNAUTHORIZED` (`reason: NOT_CONTRIBUTOR`). It is not a permissions problem and re-issuing the token will not help — wait and retry. Usually seconds, but the same indexer has taken minutes on staging, so retry generously: diff --git a/api-reference/getting-started/tutorial-4-announce.md b/api-reference/getting-started/tutorial-4-announce.md deleted file mode 100644 index bd94d60..0000000 --- a/api-reference/getting-started/tutorial-4-announce.md +++ /dev/null @@ -1,261 +0,0 @@ ---- -description: >- - Publish a lab update that attaches the dataset you uploaded, and read it back - off the activity feed. -icon: bullhorn -hidden: true ---- - -# Announce the dataset - -{% hint style="warning" %} -**Not part of onboarding.** Announcements are no longer surfaced in the Molecule app, so posting one is not a step we point new integrations at. `createAnnouncement` is still in the schema and this page is retained as a reference for integrations that already use it, deliberately out of the site navigation. Do not build a new integration around it — onboarding starts at [Getting Started](README.md), and the mutation itself is documented in [Files](../labs-api/files.md#create-announcement). -{% endhint %} - -An announcement is the lab's update stream: a headline, a body, and optionally the datasets it is about. Announcements are still indexed by `searchLabs` alongside files and still readable off `labActivity`. - -Requires Owner or Contributor (a Viewer cannot announce). Pick up with `oclId`, `serviceToken` and the `datasetId` returned by `finishCreateOrUpdateFile`. - -{% hint style="info" %} -**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](README.md#shared-setup), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. -{% endhint %} - -```javascript -const announcementResult = await graphql( - `mutation CreateAnnouncement( - $oclId: String! - $headline: String! - $body: String! - $attachments: [String!] - ) { - createAnnouncement(oclId: $oclId, headline: $headline, body: $body, attachments: $attachments) { - message - error { code message requestId retryable details } - } - }`, - { - oclId, - headline: "Baseline assay results published", - body: "First replicate set for the ApoB series. 240 samples, three conditions. Raw CSV attached.", - attachments: [datasetId], // the datasetId from finishCreateOrUpdateFile - }, -); -assertOk(announcementResult.createAnnouncement, "createAnnouncement"); -``` - -**Expected response:** - -```json -{ - "data": { - "createAnnouncement": { - "message": "…", - "error": null - } - } -} -``` - -`createAnnouncement` returns no announcement object, and its `message` is passed through from the storage layer rather than being a fixed string — so success is `error == null` and nothing else. Verify by reading the announcement back. - -**If it fails:** - -| `error.code` | What happened | Fix | -| ------------ | ------------- | --- | -| `UNAUTHORIZED` | Caller is a Viewer, or has no role | Contributor or Owner required. Fresh grant? See the [retry note](tutorial-3-agent-access.md#step-4-the-agent-uploads) | -| `NOT_FOUND` | An `attachments` entry isn't a dataset in this lab | Pass the exact `datasetId` strings from `finishCreateOrUpdateFile`, from this lab | -| `VALIDATION_FAILED` | Empty `headline` or `body` | Both are required and non-empty | - -## Verify it worked - -```javascript -const feed = await graphql( - `query LabActivity($oclId: String!) { - labActivity(oclId: $oclId, page: 0, perPage: 10, filter: ANNOUNCEMENT) { - nodes { - __typename - ... on LabEventAnnouncement { - announcement { - id - headline - body - changeBy - eventTime - attachments { path contentType accessLevel } - } - } - } - pageInfo { totalPages currentPage } - } - }`, - { oclId }, -); -console.log(JSON.stringify(feed.labActivity.nodes[0], null, 2)); -``` - -Your announcement is the newest node, with `attachments` resolved to the full file objects — not just ids — and `changeBy` set to the wallet that posted it. It is also on the lab's public page at `${LAB_APP_URL}/projects/`. - -`labActivity` is a **public** query: anyone with a consumer credential can read the feed. - -## Complete script - -Announce and verify, standalone. Takes the `oclId` and the `datasetId` you got from `finishCreateOrUpdateFile` in Tutorial 1 or 2. - -```javascript -#!/usr/bin/env node -import { privateKeyToAccount } from "viem/accounts"; - -const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql"; -const LAB_APP_URL = "https://testnet.labs.molecule.xyz"; -const SERVICE_NAME = "tutorial-agent"; - -const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; -const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY; -const OCL_ID = process.env.OCL_ID; -// Optional: omit to announce without attaching anything. -const DATASET_ID = process.env.DATASET_ID; - -let serviceToken; - -async function graphql(query, variables) { - const headers = { "Content-Type": "application/json", Authorization: CONSUMER_CREDENTIAL }; - if (serviceToken) headers["X-Service-Token"] = serviceToken; - const res = await fetch(GRAPHQL_URL, { - method: "POST", - headers, - body: JSON.stringify({ query, variables }), - }); - const { data, errors } = await res.json(); - if (errors) throw new Error(JSON.stringify(errors)); - return data; -} - -// `details` arrives as an object (thrown queries), a JSON string (in-band), or -// a doubly-encoded JSON string (in-band today) — parse until it is not a string. -function parseDetails(details) { - let value = details; - for (let i = 0; i < 3 && typeof value === "string"; i++) { - try { - value = JSON.parse(value); - } catch { - break; - } - } - return value && typeof value === "object" ? value : {}; -} - -function assertOk(result, op) { - if (result.error) { - const { code, message, requestId } = result.error; - const { reason } = parseDetails(result.error.details); - throw new Error( - `${op} failed: ${code}${reason ? `/${reason}` : ""}: ${message} (requestId ${requestId})`, - ); - } - return result; -} - -async function main() { - const headline = process.argv[2]; - const body = process.argv[3]; - if (!headline || !body) throw new Error('Usage: node tutorial-4.js "" ""'); - if (!OCL_ID) throw new Error("Set OCL_ID"); - - const account = privateKeyToAccount(WALLET_PRIVATE_KEY); - - // ---- Service token (Tutorial 1 Step 1) ---- - const signInMessage = await graphql( - `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { - getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } - }`, - { walletAddress: account.address, serviceName: SERVICE_NAME }, - ); - const messageSignature = await account.signMessage({ - message: signInMessage.getServiceSignInMessage.message, - }); - const tokenResult = await graphql( - `mutation GenerateServiceToken($serviceName: String!, $walletAddress: String!, $messageSignature: String!) { - generateServiceToken(serviceName: $serviceName, walletAddress: $walletAddress, messageSignature: $messageSignature) { - token - error { code message requestId retryable details } - } - }`, - { serviceName: SERVICE_NAME, walletAddress: account.address, messageSignature }, - ); - assertOk(tokenResult.generateServiceToken, "generateServiceToken"); - serviceToken = tokenResult.generateServiceToken.token; - console.log("1/3 Got service token"); - - // ---- Announce ---- - const announcementResult = await graphql( - `mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!, $attachments: [String!]) { - createAnnouncement(oclId: $oclId, headline: $headline, body: $body, attachments: $attachments) { - message - error { code message requestId retryable details } - } - }`, - { - oclId: OCL_ID, - headline, - body, - attachments: DATASET_ID ? [DATASET_ID] : undefined, - }, - ); - assertOk(announcementResult.createAnnouncement, "createAnnouncement"); - console.log("2/3 Announced"); - - // ---- Verify off the activity feed ---- - const feed = await graphql( - `query LabActivity($oclId: String!) { - labActivity(oclId: $oclId, page: 0, perPage: 10, filter: ANNOUNCEMENT) { - nodes { - __typename - ... on LabEventAnnouncement { - announcement { - id headline changeBy eventTime - attachments { path contentType accessLevel } - } - } - } - } - labWithDataRoomAndFiles(oclId: $oclId) { shortname } - }`, - { oclId: OCL_ID }, - ); - const newest = feed.labActivity.nodes.find( - (n) => n.announcement?.headline === headline, - ); - if (!newest) throw new Error("Announcement not found on the activity feed"); - console.log( - "3/3 Verified:", newest.announcement.headline, - "—", newest.announcement.attachments.length, "attachment(s)", - "by", newest.announcement.changeBy, - ); - if (feed.labWithDataRoomAndFiles?.shortname) { - console.log("Lab page:", `${LAB_APP_URL}/projects/${feed.labWithDataRoomAndFiles.shortname}`); - } -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); -``` - -**Usage:** - -```bash -WALLET_PRIVATE_KEY="0x..." CONSUMER_CREDENTIAL="mol_…" \ -OCL_ID="0x0101…" DATASET_ID="did:odf:fed01…" \ -node tutorial-4.js "Baseline assay results published" "First replicate set for the ApoB series." -``` - -*** - -## Next - -| | | -| --- | --- | -| Upload another file first | [Tutorial 1 — public](tutorial-1-public-upload.md) · [Tutorial 2 — encrypted](tutorial-2-encrypted-upload.md) | -| Run it against mainnet | [Running in Production](README.md#running-in-production) | -| Search and feed queries | [Browse & Search](../labs-api/browse-and-search.md) | diff --git a/api-reference/labs-api/README.md b/api-reference/labs-api/README.md index ff93ad4..a72430c 100644 --- a/api-reference/labs-api/README.md +++ b/api-reference/labs-api/README.md @@ -162,7 +162,7 @@ A missing or malformed consumer credential is rejected before the GraphQL layer **`UNAUTHORIZED`** — the wallet behind the service token lacks the required role on the lab: -- Check the wallet's role with the public `listLabMembers(oclId)` query. Content writes (uploads, metadata, announcements, moves, deletes) need **Contributor**; `createLab` and the LabNFT-metadata mutations need **Owner** +- Check the wallet's role with the public `listLabMembers(oclId)` query. Content writes (uploads, metadata, moves, deletes) need **Contributor**; `createLab` and the LabNFT-metadata mutations need **Owner** - Not the right role? The lab owner grants one onchain — see [Tutorial 3](../getting-started/tutorial-3-agent-access.md) - **Just granted the role?** Role state reaches the API through an event indexer, so a write can still return `UNAUTHORIZED` for a few seconds after the grant confirms onchain. Retry with backoff; re-issuing the token does not help diff --git a/api-reference/labs-api/files.md b/api-reference/labs-api/files.md index 25c63d3..1657b34 100644 --- a/api-reference/labs-api/files.md +++ b/api-reference/labs-api/files.md @@ -1,6 +1,6 @@ # Files -Working with files in a Lab dataroom: the three-step upload flow (initiate → upload → finish), plus announcements, metadata updates, deletion, storage limits, and client-side encryption. Creating the Lab itself is covered in [Lab Management](lab-management.md). +Working with files in a Lab dataroom: the three-step upload flow (initiate → upload → finish), plus metadata updates, deletion, storage limits, and client-side encryption. Creating the Lab itself is covered in [Lab Management](lab-management.md). > **Looking for a runnable walkthrough?** This page is the per-operation reference. For a first upload with expected responses and failure handling at every step, use [Tutorial 1](../getting-started/tutorial-1-public-upload.md) (public file) or [Tutorial 2](../getting-started/tutorial-2-encrypted-upload.md) (encrypted, with a decrypt round trip). @@ -435,66 +435,6 @@ CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" SERVICE_TOKEN="your-servi ``` --- -## Create Announcement - -Create project announcements to share updates with your community. - -**GraphQL Mutation:** - -```graphql -mutation CreateAnnouncement( - $oclId: String! - $headline: String! - $body: String! - $attachments: [String!] -) { - createAnnouncement( - oclId: $oclId - headline: $headline - body: $body - attachments: $attachments - ) { - message - error { - code - message - requestId - retryable - details - } - } -} -``` - -**Parameters:** - -| Parameter | Type | Required | Description | -| ----------- | --------- | -------- | ------------------------------------------------ | -| oclId | String | Yes | Canonical 32-byte oclId of the lab | -| headline | String | Yes | Announcement title/headline | -| body | String | Yes | Announcement body (supports Markdown) | -| attachments | \[String] | No | Array of file DIDs to attach to the announcement | - -**Example Request:** - -```bash -curl -X POST https://production.graphql.api.molecule.xyz/graphql \ - -H 'Content-Type: application/json' \ - -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \ - -H 'X-Service-Token: YOUR_SERVICE_TOKEN' \ - -d '{ - "query": "mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!, $attachments: [String!]) { createAnnouncement(oclId: $oclId, headline: $headline, body: $body, attachments: $attachments) { message error { code message requestId retryable details } } }", - "variables": { - "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042", - "headline": "Research Milestone Achieved", - "body": "We have completed Phase 2 trials with promising results.", - "attachments": ["did:kamu:fed01..."] - } - }' -``` - ---- - ## Update File Metadata Update file metadata (description, tags, categories, access level) without creating a new version. diff --git a/api-reference/x402-gateway.md b/api-reference/x402-gateway.md index ee2f5b2..a5ce7ce 100644 --- a/api-reference/x402-gateway.md +++ b/api-reference/x402-gateway.md @@ -52,7 +52,6 @@ POST {base}/x402/labs/{mutation} | -------------------------------------------- | ------------------------------ | -------------------------------------------------------- | | `/x402/labs/initiateCreateOrUpdateFile` | `initiateCreateOrUpdateFile` | Start a file upload; returns a presigned URL | | `/x402/labs/finishCreateOrUpdateFile` | `finishCreateOrUpdateFile` | Finalise a file upload with metadata | -| `/x402/labs/createAnnouncement` | `createAnnouncement` | Publish a lab announcement | | `/x402/labs/createLab` | `createLab` | Create a lab (data room) for an onchain lab (OCL) | | `/x402/labs/generateDataEncryptionKey` | `generateDataEncryptionKey` | Generate a data encryption key (DEK) for encrypted uploads | | `/x402/labs/decryptDataKey` | `decryptDataKey` | Decrypt a file's data key for an authorized caller | @@ -122,11 +121,11 @@ The price is **quoted per request** — always read it from the challenge rather ```bash curl -i -X POST \ - https://0go1j7o645.execute-api.eu-central-2.amazonaws.com/prod/x402/labs/createAnnouncement \ + https://0go1j7o645.execute-api.eu-central-2.amazonaws.com/prod/x402/labs/createLab \ -H 'Content-Type: application/json' \ -d '{ - "query": "mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!) { createAnnouncement(oclId: $oclId, headline: $headline, body: $body) { message error { code message requestId retryable details } } }", - "variables": { "oclId": "0x0101…", "headline": "Milestone 1 complete", "body": "…" } + "query": "mutation CreateLab($oclId: String!) { createLab(input: { oclId: $oclId }) { message error { code message requestId retryable details } } }", + "variables": { "oclId": "0x0101…" } }' ``` @@ -152,8 +151,8 @@ curl -sD - -o /dev/null -X POST "$URL" -H 'Content-Type: application/json' -d "$ "x402Version": 2, "error": "Payment required", "resource": { - "url": "https://0go1j7o645.execute-api.eu-central-2.amazonaws.com/x402/labs/createAnnouncement", - "description": "x402 payment for createAnnouncement", + "url": "https://0go1j7o645.execute-api.eu-central-2.amazonaws.com/x402/labs/createLab", + "description": "x402 payment for createLab", "mimeType": "" }, "accepts": [ @@ -215,18 +214,16 @@ The signed payload is accepted under any of `Payment-Signature`, `X-Payment`, or ## Request Format ```http -POST /x402/labs/createAnnouncement HTTP/1.1 +POST /x402/labs/createLab HTTP/1.1 content-type: application/json payment-signature: { - "query": "mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!) { createAnnouncement(oclId: $oclId, headline: $headline, body: $body) { message error { code message requestId retryable details } } }", + "query": "mutation CreateLab($oclId: String!) { createLab(input: { oclId: $oclId }) { message error { code message requestId retryable details } } }", "variables": { - "oclId": "0x0101...abcd", - "headline": "Milestone 1 complete", - "body": "..." + "oclId": "0x0101...abcd" }, - "operationName": "CreateAnnouncement" + "operationName": "CreateLab" } ``` @@ -246,8 +243,8 @@ Constraints enforced by the gateway (`validateMutationQuery`): Pricing is environment-driven and resolved per-mutation. The gateway evaluates the following env vars in order and uses the first non-empty value: ``` -X402_PRICE_ e.g. X402_PRICE_CREATE_ANNOUNCEMENT -X402_PRICE_ e.g. X402_PRICE_CREATEANNOUNCEMENT +X402_PRICE_ e.g. X402_PRICE_CREATE_LAB +X402_PRICE_ e.g. X402_PRICE_CREATELAB X402_PRICE_DEFAULT fallback when no per-mutation price is set ``` diff --git a/technical-deep-dive/data/data-api-and-integration.md b/technical-deep-dive/data/data-api-and-integration.md index 2f5ce2f..4bd1b37 100644 --- a/technical-deep-dive/data/data-api-and-integration.md +++ b/technical-deep-dive/data/data-api-and-integration.md @@ -11,7 +11,7 @@ icon: computer-classic The Data Storage and Data Privacy & Access pages describe how research files enter a Lab — how they are encrypted, stored, versioned, and access-controlled. This page describes the other side of the pipeline: how onchain events are indexed into a queryable database, how that data is aggregated with research metadata and editorial content into a unified API layer, and how every consumer in the ecosystem — the frontend, MIRA, external developers, and AI agents — accesses it. -Every interaction a user has with Lab data on the Molecule platform — browsing project listings, viewing token prices, reading announcements, querying MIRA, or building an integration against the Labs API — is mediated by the Molecule API. The API is the central data hub that sits between the raw data sources and the consumers that need them. +Every interaction a user has with Lab data on the Molecule platform — browsing project listings, viewing token prices, querying MIRA, or building an integration against the Labs API — is mediated by the Molecule API. The API is the central data hub that sits between the raw data sources and the consumers that need them. ### Data Sources @@ -44,9 +44,9 @@ The Molecule API is a GraphQL API that sits on top of the Aurora database and se The API serves two broad categories of data. Market and token data — representing approximately 85% of current API traffic — includes IPT prices, market caps, liquidity depths, holder distributions, trading volumes, IP-NFT metadata, crowdsale states, treasury balances, project listings, and transaction histories. This data originates from onchain events, indexed through the pipeline described above into Aurora. -Research and Lab data — representing the remaining 15% — includes data room file listings, file versions, announcements, project activity feeds, and semantic search results. This data is served through the Labs API, which reads from Kamu for file metadata and provenance, and generates presigned S3 URLs through Filebase for file uploads and downloads. +Research and Lab data — representing the remaining 15% — includes data room file listings, file versions, project activity feeds, and semantic search results. This data is served through the Labs API, which reads from Kamu for file metadata and provenance, and generates presigned S3 URLs through Filebase for file uploads and downloads. -The API uses a two-tier authentication model. Read operations (queries) require a consumer credential, issued by the Molecule team upon request. Write operations (mutations) — file uploads, metadata updates, announcements — require both a consumer credential and a service token, which is scoped to a specific wallet address and Lab. Service tokens have configurable expiration and can be extended or revoked through the API. For detailed authentication setup, credential management, and rate limits, see the Labs API reference page. +The API uses a two-tier authentication model. Read operations (queries) require a consumer credential, issued by the Molecule team upon request. Write operations (mutations) — file uploads, metadata updates — require both a consumer credential and a service token, which is scoped to a specific wallet address and Lab. Service tokens have configurable expiration and can be extended or revoked through the API. For detailed authentication setup, credential management, and rate limits, see the Labs API reference page. ### How Consumers Access Data @@ -56,7 +56,7 @@ Different consumers interact with the data layer through different interfaces, d **MIRA** accesses data through MCP (Model Context Protocol) tools — structured function calls that the AI model invokes during conversations. MIRA's knowledge base — a curated corpus about Molecule, Molecule's architecture, and the broader ecosystem — is maintained separately and updated through an automated crawl pipeline. -**External developers and AI agents** access Lab data through the Labs API, which provides full GraphQL access to data room operations: listing projects, querying files, uploading and versioning research data, creating announcements, and performing semantic search across Labs. For market and token data, they query the Molecule API directly. All these are documented in the References section. +**External developers and AI agents** access Lab data through the Labs API, which provides full GraphQL access to data room operations: listing projects, querying files, uploading and versioning research data, and performing semantic search across Labs. For market and token data, they query the Molecule API directly. All these are documented in the References section. ### The Integration Pipeline @@ -65,13 +65,11 @@ When a researcher uploads a file to a Lab, the data flows through the full pipel Market data flows through a different path. When a token event occurs onchain — an IPT trade on a DEX, a crowdsale contribution, a treasury deployment — the indexer picks up the event, computes the relevant metrics (new price, updated volume, changed holder count), and writes the results to Aurora. The Molecule API serves the updated data on the next query. MIRA's MCP tools access this data in real time during conversations, supplemented by GeckoTerminal for historical OHLCV charts. -Announcements follow the Labs API path. A Lab owner creates an announcement through the Labs API (or the platform UI), optionally attaching data room files. The announcement is stored via Kamu with its timestamp, author, and content, and immediately appears in the project's activity feed, the global activity feed, and MIRA's context when users ask about the project. - ### Semantic Search -The API exposes a semantic search endpoint that queries across all Labs, files, and announcements in the ecosystem. Queries are processed as natural language — searching for "gene therapy for rare diseases" returns Labs whose data room contents, announcements, and metadata are semantically relevant, not just keyword matches. +The API exposes a semantic search endpoint that queries across all Labs and files in the ecosystem. Queries are processed as natural language — searching for "gene therapy for rare diseases" returns Labs whose data room contents and metadata are semantically relevant, not just keyword matches. -Search results can be filtered by tags, categories, access levels, and content kinds (files or announcements). Each result includes the matching entity, its parent Lab, and a relevance score. This powers both the platform's search interface and MIRA's ability to discover related projects during conversations. +Search results can be filtered by tags, categories, and access levels. Each result includes the matching entity, its parent Lab, and a relevance score. This powers both the platform's search interface and MIRA's ability to discover related projects during conversations. ### Access Levels and Gating @@ -83,7 +81,7 @@ This means the API can serve file metadata (path, version, content type, access The current API and MCP tool suite is weighted toward market and token data. Several research-oriented capabilities are not yet exposed through the API or MCP tools. These include structured scientific data queries (querying dataset contents by schema or field values), dataset version diffs (comparing what changed between two versions of a file), cross-Lab provenance queries (tracing how a dataset or methodology was shared or derived across multiple Labs), and research file metadata queries for MIRA (the MCP tools currently cannot access data room contents, search across file metadata, or retrieve dataset version histories on behalf of the AI). -These gaps mean that MIRA can tell you a project's token price, market cap, and recent announcements, but cannot yet directly inspect the contents of a Lab's data room or compare the scientific substance of two projects' research outputs. Developers building integrations should be aware that the Labs API provides richer data room access than the MCP tools currently expose. +These gaps mean that MIRA can tell you a project's token price and market cap, but cannot yet directly inspect the contents of a Lab's data room or compare the scientific substance of two projects' research outputs. Developers building integrations should be aware that the Labs API provides richer data room access than the MCP tools currently expose. The Sanity CMS integration is also in transition. The frontend currently calls Sanity's API directly for editorial content, bypassing the Molecule API. The planned architecture consolidates Sanity content into the Molecule API so the frontend has a single data source. This migration is actively in progress. diff --git a/technical-deep-dive/data/data-storage.md b/technical-deep-dive/data/data-storage.md index afd1a4e..1fa84d2 100644 --- a/technical-deep-dive/data/data-storage.md +++ b/technical-deep-dive/data/data-storage.md @@ -39,7 +39,7 @@ Kamu maintains a complete, append-only history of every dataset in every Lab. Wh Each version record includes the content hash, the timestamp, the author's decentralised identifier (DID) linked to their wallet address, the data room path, and a reference to the previous version. This creates a verifiable provenance chain from the current state of any dataset back to its original upload. When a collaborator, funder, or reviewer needs to verify when data was created, who created it, or how it evolved over time, the evidence is in Kamu's version graph. -Kamu also records activity events — file access, metadata changes, announcements, and other Lab actions — providing a broader context for the dataset's history beyond just version changes. +Kamu also records activity events — file access, metadata changes, and other Lab actions — providing a broader context for the dataset's history beyond just version changes. ### Permanent Persistence @@ -65,4 +65,4 @@ Even if a file record is removed from a Lab's data room index, the underlying co | File versions | Kamu provenance DB | Kamu | Append-only version history and audit trail | | Encryption metadata | In the file's encryption metadata on Kamu (ODF) | Onchain-Verified Envelope Encryption | Wrapped per-file DEK + access conditions; key custody and decryption rules are covered in [Data Privacy & Access](data-privacy-and-access.md) | | File provenance | Kamu provenance DB | Kamu | DID-based authorship, timestamps, lineage | -| Activity events | Kamu provenance DB + onchain | Kamu + Lab TBA | Access logs, metadata changes, announcements | +| Activity events | Kamu provenance DB + onchain | Kamu + Lab TBA | Access logs, metadata changes | diff --git a/technical-deep-dive/roles-and-permissions.md b/technical-deep-dive/roles-and-permissions.md index d17215d..fdc3953 100644 --- a/technical-deep-dive/roles-and-permissions.md +++ b/technical-deep-dive/roles-and-permissions.md @@ -13,7 +13,7 @@ Roles are held by **wallets**, and a Lab involves up to three of them — the ow ## Why Roles Exist -A Lab's NFT holder is its sole ultimate controller — transferring the LabNFT transfers the entire project. In practice, most research projects need to delegate day-to-day data-room work (uploading files, posting announcements, decrypting confidential research) to collaborators and AI agents without surrendering ownership. +A Lab's NFT holder is its sole ultimate controller — transferring the LabNFT transfers the entire project. In practice, most research projects need to delegate day-to-day data-room work (uploading files, decrypting confidential research) to collaborators and AI agents without surrendering ownership. The role system lets a Lab owner grant scoped, expiring access to specific wallets — human or agent — while keeping ownership, treasury control, and the ability to revoke access at any time. Invites via email are possible, meaning team members do not have to be web3-native to participate. @@ -35,7 +35,6 @@ The `hasRole` check is hierarchical: a Contributor automatically passes Viewer c | View public data-room files | ✓ | ✓ | ✓ | | Decrypt confidential data-room files | ✓ | ✓ | ✓ | | Upload / update / delete data-room files | ✓ | ✓ | | -| Create announcements | ✓ | ✓ | | | Grant / revoke Viewer role | ✓ | ✓ | | | Grant / revoke Contributor role | ✓ | | | | Transfer the LabNFT | ✓ | | | @@ -60,7 +59,7 @@ struct RoleGrant { * **Expiry** — A non-zero `expiry` makes the grant auto-expire. Expired grants still exist in storage (so `getRole` returns them for UI purposes) but are inactive: `hasRole` returns `false` once `block.timestamp >= expiry`. Expired grantees must be re-granted to regain access. * **`isAgent`** — Purely informational metadata. It does **not** change onchain authorization, but downstream systems (the members list, the data-room UI, the agent-auth flow) surface it to clearly distinguish AI-agent session keys from human team members. -A Lab owner granting access to an agent should set `isAgent = true` and a short `expiry` — typically the agent's session-key lifetime. When the session expires, the agent must request a new grant before it can continue to decrypt files or post announcements. +A Lab owner granting access to an agent should set `isAgent = true` and a short `expiry` — typically the agent's session-key lifetime. When the session expires, the agent must request a new grant before it can continue to decrypt files or write to the data room. ## How Invites Work in the App diff --git a/user-guides/developers-ai-agents.md b/user-guides/developers-ai-agents.md index b6bffc0..6c65589 100644 --- a/user-guides/developers-ai-agents.md +++ b/user-guides/developers-ai-agents.md @@ -19,7 +19,7 @@ The reference pages (Contracts, Labs API, MCP Tools) contain the full API specif Molecule exposes four primary integration layers, each serving different developer needs. -The Labs API is a GraphQL endpoint for reading and writing to Lab data rooms — the offchain encrypted storage where research files, announcements, and metadata live. This is the primary interface for applications that need to manage scientific data: uploading files, querying project activity, searching across Labs, and managing announcements. Authentication uses consumer credentials for reads and service tokens for writes. The full specification, including every query and mutation, is documented in the Labs API reference. +The Labs API is a GraphQL endpoint for reading and writing to Lab data rooms — the offchain encrypted storage where research files and metadata live. This is the primary interface for applications that need to manage scientific data: uploading files, querying project activity, and searching across Labs. Authentication uses consumer credentials for reads and service tokens for writes. The full specification, including every query and mutation, is documented in the Labs API reference. The Smart Contracts are the onchain layer. The V2 contracts (IPNFT, CrowdSale, SchmackoSwap) on Ethereum mainnet underpin the existing IP-NFT assets, token sales, and trading. The V3 contracts (OnChainLab, OnChainLabFactory, ERC7484Registry, OclTokenizer, and associated modules) are deployed on Base mainnet and Base Sepolia and introduce the modular account architecture plus Lab tokenization. Contract addresses, ABIs, and upgrade patterns are documented in the Contracts reference. The Architecture page provides the full implementation-level breakdown of how these contracts compose. @@ -58,7 +58,7 @@ The simplest agent integration is read-only: query a Lab's data room for files, A write-enabled agent goes further: it reads data, performs analysis, and writes results back as new files in the Lab's data room. This requires both a consumer credential and a service token. Two paths to a service token: * **Long-lived service token** — the agent mints its own via the `generateServiceToken` mutation, by signing a message with its wallet (a Privy session works too). No provisioning request. The token is a JWT tied to that wallet; write authorization is resolved per request from the wallet's onchain role on the target Lab. Content writes need **Contributor**. The end-to-end version — agent wallet, human grants the role, agent self-issues and uploads — is [Tutorial 3](../api-reference/getting-started/tutorial-3-agent-access.md). -* **Pay-per-call via the** [**x402 Gateway**](../api-reference/x402-gateway.md) — for agents that serve external users, charge per request, or don't have pre-provisioned credentials. The gateway settles a USDC payment on Base per call and mints a short-lived (default 5-minute) service token scoped to one mutation. Available for `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createAnnouncement`, `createLab`, `generateDataEncryptionKey`, and `decryptDataKey`. +* **Pay-per-call via the** [**x402 Gateway**](../api-reference/x402-gateway.md) — for agents that serve external users, charge per request, or don't have pre-provisioned credentials. The gateway settles a USDC payment on Base per call and mints a short-lived (default 5-minute) service token scoped to one mutation. Available for `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createLab`, `generateDataEncryptionKey`, and `decryptDataKey`. The three-step upload flow (initiate, PUT, finalize) lets you write any file type with metadata including descriptions, tags, categories, and searchable content text. If the file should be confidential, first request a key via `generateDataEncryptionKey` — the backend returns a one-shot plaintext DEK (plus its encrypted form) you use to encrypt locally before upload, attaching the encryption metadata on finish. Every file the agent writes becomes a permanent, versioned record in the Lab's history. From de4e990d4b44cce197d6af1ad7ac9975598e1c98 Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Tue, 1 Sep 2026 16:10:24 +0100 Subject: [PATCH 10/16] docs: remove every remaining mention of announcements The feature is no longer supported, so the schema-shape and historical references kept in 4e02a6d are gone too: - browse-and-search.md: the LabEventAnnouncement and SearchLabsAnnouncementHit union variants, the announcement-only labActivity curl example (now queries LabEventFileAdded), the attachments breaking-change note, the use-case list and the __typename switch in the JS example - authentication.md: the ANNOUNCEMENT filter values on labActivity and activities - changelog.md: the createAnnouncement gate-list entry (six writes -> five), the renamed-query and renamed-mutation rows, and the diff example (now finishCreateOrUpdateFile) - release-notes/labs-api.md: createAnnouncement in the write list - molecule-skill.md: the aura-orchestrator SKILL.md quote is now abridged and labelled as such, rather than quoting a stale manifest Also reworded two uses of the English verb "announced" (new error codes are "published" in the changelog) so the term does not appear at all. Co-Authored-By: Claude Opus 5 (1M context) --- ai-tooling/molecule-skill.md | 4 +- api-reference/authentication.md | 4 +- api-reference/changelog.md | 12 ++- api-reference/labs-api/README.md | 2 +- api-reference/labs-api/browse-and-search.md | 98 ++------------------- release-notes/labs-api.md | 2 +- 6 files changed, 19 insertions(+), 103 deletions(-) diff --git a/ai-tooling/molecule-skill.md b/ai-tooling/molecule-skill.md index 2c26aa2..866a767 100644 --- a/ai-tooling/molecule-skill.md +++ b/ai-tooling/molecule-skill.md @@ -154,14 +154,14 @@ mol-labs-plugin/ └── mcp/server.py # the MCP server (Python, stdio transport) ``` -The skill itself is a standard `SKILL.md` file — frontmatter that tells the harness when to use it, followed by the phase-by-phase runbook: +The skill itself is a standard `SKILL.md` file — frontmatter that tells the harness when to use it, followed by the phase-by-phase runbook (frontmatter abridged): ```yaml --- name: aura-orchestrator description: End-to-end DeSci molecule on the OCL (On-Chain Labs) surface — resolve-or-create an on-chain lab (LabNFT + token-bound account), register it, - upload files (public or private/encrypted), and announce. Driven entirely + upload files (public or private/encrypted). Driven entirely through the `molecule` MCP server. --- ``` diff --git a/api-reference/authentication.md b/api-reference/authentication.md index 376c064..1737d24 100644 --- a/api-reference/authentication.md +++ b/api-reference/authentication.md @@ -65,8 +65,8 @@ The Labs read surface is public — these queries need only a consumer credentia - `labs` - List all labs with pagination - `labWithDataRoomAndFiles` - Get lab details and files -- `labActivity` - Get activity feed for a lab, (available filters: ANNOUNCEMENT | FILE) -- `activities` - Get global activity feed, (available filters: ANNOUNCEMENT | FILE) +- `labActivity` - Get the file-event activity feed for a lab +- `activities` - Get the global file-event activity feed - `dataRoomFile` - Get file by path - `searchLabs` - Search across labs and files - `fileCategoriesAndTags` - List valid file categories and their tags diff --git a/api-reference/changelog.md b/api-reference/changelog.md index 7829db1..00b9d49 100644 --- a/api-reference/changelog.md +++ b/api-reference/changelog.md @@ -35,7 +35,7 @@ Two clarifications and one hardening, all now reflected across the API docs: ### Contributor role parity for service-token content writes -The six content-write mutations — `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createAnnouncement`, `deleteDataRoomFile`, `updateFileMetadata`, `moveEntry` — now gate a service token on the **Contributor** role, matching the Privy user path per mutation. Previously the service path required lab ownership for these, which meant a wallet granted Contributor on a lab could act through a user session but not through its own service token. +The five content-write mutations — `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata`, `moveEntry` — now gate a service token on the **Contributor** role, matching the Privy user path per mutation. Previously the service path required lab ownership for these, which meant a wallet granted Contributor on a lab could act through a user session but not through its own service token. This is what unblocks the "human owns the lab, agent contributes to it" flow: the owner grants the agent's wallet Contributor, and the agent's self-issued token can write. Owner-gated surfaces are unchanged — `createLab`, `updateLabNftMetadata` and `generateLabImageUploadUrl` still require ownership. @@ -134,7 +134,7 @@ On an in-band mutation error, `details` arrives as a JSON-encoded string (AppSyn | `UPSTREAM_UNAVAILABLE` | **true** | A dependency failed — retry with backoff. | `KAMU`, `CMS`, `IPFS` | | `INTERNAL_ERROR` | **true** | Unexpected failure; quote `requestId` when reporting it. | — | -Codes may be added over time, and each addition is announced on this page. Treat a code you do not recognise as non-retryable, keep the raw value for diagnostics and surface it to a human. `PAYMENT_REQUIRED` is reserved for the x402 gateway and is not emitted by the GraphQL API. `details.reason` values are diagnostic refinement, not a contract surface — they may be extended without notice. +Codes may be added over time, and each addition is published on this page. Treat a code you do not recognise as non-retryable, keep the raw value for diagnostics and surface it to a human. `PAYMENT_REQUIRED` is reserved for the x402 gateway and is not emitted by the GraphQL API. `details.reason` values are diagnostic refinement, not a contract surface — they may be extended without notice. #### Before / after @@ -154,9 +154,9 @@ Codes may be added over time, and each addition is announced on this page. Treat ``` ```diff -# Mutation selection (createAnnouncement) — select `error` instead of `isSuccess` - mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!) { - createAnnouncement(oclId: $oclId, headline: $headline, body: $body) { +# Mutation selection (finishCreateOrUpdateFile) — select `error` instead of `isSuccess` + mutation FinishCreateOrUpdateFile($oclId: String!, $uploadToken: String!, $path: String!) { + finishCreateOrUpdateFile(oclId: $oclId, uploadToken: $uploadToken, path: $path) { - isSuccess message - error { message code retryable } @@ -190,7 +190,6 @@ The legacy `*V2` operations and the pre-OCL naming have been **removed**. The cu | `dataRoomFileV2` | `dataRoomFile` | Identified by `oclId` + `path` | | `projectActivity` / `projectActivityV2` | `labActivity` | — | | `activitiesV2` | `activities` | — | -| `projectAnnouncementsV2` / `projectAnnouncementV2` | `labActivity` / `activities` | Removed — use the `filter: ANNOUNCEMENT` argument | #### Renamed mutations @@ -199,7 +198,6 @@ The legacy `*V2` operations and the pre-OCL naming have been **removed**. The cu | `createProject` | `createLab` | Now takes `input: { oclId }` instead of `ipnftSymbol` / `ipnftTokenId` | | `initiateCreateOrUpdateFileV2` | `initiateCreateOrUpdateFile` | — | | `finishCreateOrUpdateFileV2` | `finishCreateOrUpdateFile` | — | -| `createAnnouncementV2` | `createAnnouncement` | Takes `oclId`; the legacy `moleculeAccessLevel` param was removed | | `updateFileMetadataV2` | `updateFileMetadata` | — | | `deleteDataRoomFileV2` | `deleteDataRoomFile` | — | diff --git a/api-reference/labs-api/README.md b/api-reference/labs-api/README.md index a72430c..ea0ae82 100644 --- a/api-reference/labs-api/README.md +++ b/api-reference/labs-api/README.md @@ -148,7 +148,7 @@ if (result.error) { | `UPSTREAM_UNAVAILABLE` | **true** | A dependency failed (`details.reason` `KAMU`, `CMS`, `IPFS`) | | `INTERNAL_ERROR` | **true** | Unexpected failure — details are only in our logs, joined by `requestId` | -When `retryable` is `true`, retry with exponential backoff; when `false`, the request (or the resource state) must change before retrying. Any code not listed here: preserve it for diagnostics, treat it as non-retryable and surface it to a human — new codes are announced in the [API Changelog](../changelog.md). `PAYMENT_REQUIRED` is reserved for the [x402 Gateway](../x402-gateway.md) and is not emitted by the GraphQL API. +When `retryable` is `true`, retry with exponential backoff; when `false`, the request (or the resource state) must change before retrying. Any code not listed here: preserve it for diagnostics, treat it as non-retryable and surface it to a human — new codes are published in the [API Changelog](../changelog.md). `PAYMENT_REQUIRED` is reserved for the [x402 Gateway](../x402-gateway.md) and is not emitted by the GraphQL API. ### Troubleshooting diff --git a/api-reference/labs-api/browse-and-search.md b/api-reference/labs-api/browse-and-search.md index 56c8cd9..4de58a8 100644 --- a/api-reference/labs-api/browse-and-search.md +++ b/api-reference/labs-api/browse-and-search.md @@ -84,12 +84,10 @@ curl -X POST https://production.graphql.api.molecule.xyz/graphql \ ### Project Activity Feed -Get activity timeline for a specific project including file events and announcements. This is a **public endpoint** - no authentication required. +Get the file-event timeline for a specific project. This is a **public endpoint** - no authentication required. > **🔓 Public Endpoint**: The `labActivity` query does not require authentication. You only need a consumer credential — `Authorization: mol__`, with **no `Bearer` prefix** — and no Service Token. -> **Filtering**: By default, returns all activity types (file events and announcements). Use the optional `filter` parameter (`ANNOUNCEMENT` or `FILE`) to retrieve only a specific type. - **GraphQL Query:** ```graphql @@ -159,31 +157,11 @@ query GetProjectActivity( contentText } } - ... on LabEventAnnouncement { - announcement { - id - headline - body - attachments { - id - did - path - name - contentType - accessLevel - } - changeBy - systemTime - eventTime - } - } } } } ``` -> **⚠️ Breaking Change**: Announcement `attachments` changed from `[String!]!` (array of DIDs) to `[DataRoomFile!]!` (array of file objects). This enables querying file metadata directly without separate API calls. - **Example Request:** ```bash @@ -191,7 +169,7 @@ curl -X POST https://production.graphql.api.molecule.xyz/graphql \ -H 'Content-Type: application/json' \ -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \ -d '{ - "query": "query GetActivity($oclId: String!, $page: Int) { labActivity(oclId: $oclId, page: $page, perPage: 20) { pageInfo { hasNextPage currentPage totalPages } nodes { __typename ... on LabEventAnnouncement { announcement { headline attachments { did path contentType } } } } } }", + "query": "query GetActivity($oclId: String!, $page: Int) { labActivity(oclId: $oclId, page: $page, perPage: 20) { pageInfo { hasNextPage currentPage totalPages } nodes { __typename ... on LabEventFileAdded { entry { path contentType version accessLevel changeBy eventTime } } } } }", "variables": { "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042", "page": 0 @@ -201,10 +179,10 @@ curl -X POST https://production.graphql.api.molecule.xyz/graphql \ **Use Cases:** -- Announcement detail pages requiring full file metadata -- Download links for announcement attachments +- Project timelines showing what changed in a data room and when +- Download links for data-room files - Encrypted file access (Onchain-Verified Envelope Encryption for new files) -- Projects with many announcements (efficient pagination) +- Projects with many file events (efficient pagination) ### Global Activity Feed @@ -212,8 +190,6 @@ Get all activity across all projects. This is a **public endpoint** - no authent > **🔓 Public Endpoint**: The `activities` query does not require authentication. You only need a consumer credential — `Authorization: mol__`, with **no `Bearer` prefix** — and no Service Token. -> **Filtering**: By default, returns all activity types (file events and announcements). Use the optional `filter` parameter (`ANNOUNCEMENT` or `FILE`) to retrieve only a specific type. - **GraphQL Query:** ```graphql @@ -272,24 +248,6 @@ query GetActivities($page: Int, $perPage: Int, $filter: LabActivityFilter) { contentText } } - ... on LabEventAnnouncement { - announcement { - id - headline - body - attachments { - id - did - path - name - contentType - accessLevel - } - changeBy - systemTime - eventTime - } - } } } } @@ -301,7 +259,7 @@ query GetActivities($page: Int, $perPage: Int, $filter: LabActivityFilter) { ## Searching Labs -Perform semantic search across all projects, files, and announcements in the Labs ecosystem. +Perform semantic search across all projects and files in the Labs ecosystem. **GraphQL Query:** @@ -338,26 +296,6 @@ query SearchLabs( } } } - ... on SearchLabsAnnouncementHit { - announcement { - id - headline - body - systemTime - attachments { - id - did - path - name - contentType - accessLevel - } - } - lab { - oclId - shortname - } - } } totalCount pageInfo { @@ -395,7 +333,7 @@ curl -X POST https://production.graphql.api.molecule.xyz/graphql \ -H 'Content-Type: application/json' \ -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \ -d '{ - "query": "query SearchLabs($prompt: String!, $page: Int, $perPage: Int) { searchLabs(prompt: $prompt, page: $page, perPage: $perPage) { nodes { __typename ... on SearchLabsFileHit { entry { lab { oclId shortname } path file { contentType description tags } } } ... on SearchLabsAnnouncementHit { announcement { headline body } lab { shortname } } } totalCount pageInfo { hasNextPage currentPage totalPages } } }", + "query": "query SearchLabs($prompt: String!, $page: Int, $perPage: Int) { searchLabs(prompt: $prompt, page: $page, perPage: $perPage) { nodes { __typename ... on SearchLabsFileHit { entry { lab { oclId shortname } path file { contentType description tags } } } } totalCount pageInfo { hasNextPage currentPage totalPages } } }", "variables": { "prompt": "cancer research", "page": 0, @@ -429,9 +367,6 @@ Search results are returned as a union type. Use the `__typename` field to deter - **SearchLabsFileHit**: File search result - Access via: `entry.file` - Contains: file metadata, tags, categories, download URL -- **SearchLabsAnnouncementHit**: Announcement search result - - Access via: `announcement` - - Contains: headline, body, lab reference, **typed attachments** (file objects) **JavaScript Example:** @@ -453,17 +388,6 @@ const searchResults = await fetch(apiUrl, { file { description tags } } } - ... on SearchLabsAnnouncementHit { - announcement { - headline - attachments { - did - path - contentType - accessLevel - } - } - } } totalCount } @@ -477,13 +401,7 @@ const { nodes, totalCount } = (await searchResults.json()).data.searchLabs; // Handle different result types nodes.forEach((node) => { if (node.__typename === "SearchLabsFileHit") { - console.log("File:", node.entry.path); - } else if (node.__typename === "SearchLabsAnnouncementHit") { - console.log("Announcement:", node.announcement.headline); - // NEW: Attachments are now full file objects - node.announcement.attachments.forEach((file) => { - console.log(" Attachment:", file.path, file.contentType); - }); + console.log("File:", node.entry.path, node.entry.file.description); } }); ``` diff --git a/release-notes/labs-api.md b/release-notes/labs-api.md index b8de5ad..5da6315 100644 --- a/release-notes/labs-api.md +++ b/release-notes/labs-api.md @@ -17,7 +17,7 @@ _Released 2026-08-25_ #### Assignment Agreement no longer required before data-room writes Data-room write mutations — `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, -`deleteDataRoomFile`, `updateFileMetadata`, `moveEntry`, and `createAnnouncement` (see +`deleteDataRoomFile`, `updateFileMetadata`, and `moveEntry` (see [Files](../api-reference/labs-api/files.md)) — used to fail with `ASSIGNMENT_AGREEMENT_NOT_SIGNED` (`FAILED_PRECONDITION`) until a lab's Assignment Agreement was signed via `signLegalAgreement`. That gate is now disabled: these mutations succeed regardless of the agreement's sign state. From e23705bf26add7b3c5910ba99408fa8095203d34 Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Wed, 2 Sep 2026 16:42:32 +0100 Subject: [PATCH 11/16] chore(docs): refactor getting started readme page --- .github/prompts/docs-sync.md | 1 + SUMMARY.md | 1 + api-reference/getting-started/README.md | 135 +++--------------- api-reference/getting-started/shared-setup.md | 135 ++++++++++++++++++ .../tutorial-1-public-upload.md | 4 +- .../tutorial-2-encrypted-upload.md | 4 +- .../tutorial-3-agent-access.md | 2 +- 7 files changed, 160 insertions(+), 122 deletions(-) create mode 100644 api-reference/getting-started/shared-setup.md diff --git a/.github/prompts/docs-sync.md b/.github/prompts/docs-sync.md index 2efeb4f..99f396a 100644 --- a/.github/prompts/docs-sync.md +++ b/.github/prompts/docs-sync.md @@ -54,6 +54,7 @@ touches one of its source paths. | `api-reference/README.md` | `graphql/schemas/*.graphql` (surface inventory only), `lib/shared-api-stack.ts` | | `api-reference/authentication.md` | `lambda/appsync-authorizer-lambda/**`, service-token resolvers in `lambda/appsync-resolver-labs-lambda/**` (`services/token-manager-service.ts`, `utils/service-auth-message.ts`), `docs/service-auth.md`. The header reference and the sign-in flow both live here: a change to the sign-in message, its nonce or its validity window touches this page **and** `labs-api/service-tokens.md` **and** the tutorials' Step 1 — fix all of them or none. | | `api-reference/getting-started/README.md` | The onboarding entry point. `lib/desci-api-app-sync/constructs/api-config.ts` (endpoints, introspection, depth limit), `lambda/x402-gateway-lambda/**` (prices/base URLs quoted in the cost table), `lambda/appsync-authorizer-lambda/**` (credential shape). **Contains verified live values** — `mintFeeWei()` readings, x402 prices, gateway URLs — each stamped with the date it was checked. Never update one of those from a diff alone: if the diff suggests a value changed, flag it in the PR body and let a human re-verify against the deployed environment. | +| `api-reference/getting-started/shared-setup.md` | The config constants and helpers every tutorial's snippets assume: staging endpoint, chain, `FACTORY_ADDRESS` / `LABNFT_ADDRESS` / `ACCESS_RESOLVER_ADDRESS`, plus `graphql()`, `parseDetails()`, `assertOk()` and `withIndexerLagRetry()`. Sources: `lib/desci-api-app-sync/constructs/api-config.ts` (endpoints), `graphql/schemas/api-error.graphql` and the in-band `ApiError` shape (`parseDetails`/`assertOk`), `lambda/appsync-authorizer-lambda/**` (header names). Contract addresses are deployment values — flag a suspected change for human verification rather than editing from a diff. **This block is duplicated inline in each tutorial's complete script and in the production swap table on `getting-started/README.md`: fix all copies or none.** | | `api-reference/getting-started/for-agents.md` | Condensed mirror of Tutorial 1 plus the error contract. Sources: `graphql/schemas/ip-hubs.graphql`, `graphql/schemas/api-error.graphql`, `graphql/schemas/encryption.graphql`, `lambda/appsync-resolver-labs-lambda/**` (per-mutation role gates). **Must stay consistent with the tutorial pages** — a change to the flow touches this page and the relevant tutorial, or neither. | | `api-reference/getting-started/tutorial-1-public-upload.md` | Tutorial 1 — self-issue a token, mint, `createLab`, three-call public upload, verify. Sources: `graphql/schemas/ip-hubs.graphql`; `lambda/appsync-resolver-labs-lambda/**` for `generateServiceToken`, `createLab`, `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile` and their authorization gates; `services/token-manager-service.ts`; `lib/utils/token-expiration.ts` (the `expiresIn` default and bounds). Every code block is expected to be runnable — a signature change here is a real breakage, so correct it and say so in the PR body. Steps 1–3 are duplicated by reference in Tutorials 2 and 3: **fix all copies or none.** | | `api-reference/getting-started/tutorial-2-encrypted-upload.md` | Tutorial 2 — DEK, local AES-256-GCM, `accessControlConditions`, `encryptionMetadata`, `decryptDataKey` round trip. Sources: `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts`, the encryption resolvers and condition evaluator in `lambda/appsync-resolver-labs-lambda/**`, `lib/encryption-stack.ts`. `encryptionSystem` is backend-set and must stay "echo it verbatim" — never let a literal be hardcoded here. | diff --git a/SUMMARY.md b/SUMMARY.md index 26cb2ed..7542d5f 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -38,6 +38,7 @@ * [Tutorial 2: Upload an encrypted file](api-reference/getting-started/tutorial-2-encrypted-upload.md) * [Tutorial 3: Give your agent access to a lab](api-reference/getting-started/tutorial-3-agent-access.md) * [🤖 For Agents: One-Pager](api-reference/getting-started/for-agents.md) + * [Shared Setup](api-reference/getting-started/shared-setup.md) * [🔐 Authentication](api-reference/authentication.md) * [⚙️ Labs API](api-reference/labs-api/README.md) * [Lab Management](api-reference/labs-api/lab-management.md) diff --git a/api-reference/getting-started/README.md b/api-reference/getting-started/README.md index 4368ed7..c395064 100644 --- a/api-reference/getting-started/README.md +++ b/api-reference/getting-started/README.md @@ -9,13 +9,13 @@ icon: rocket This is the entry point to the Molecule API. It helps you **pick a lane**, lists the **two prerequisites** you actually need, and hands you a **ten-minute quickstart** that ends with a lab you can see. The three tutorials underneath it take the same ground step by step. -Everything here runs against **staging** (Base Sepolia, testnet funds). Nothing spends real money. This page also holds the two things every tutorial shares: the [shared setup block](#shared-setup) and the [staging → production swap table](#running-in-production). +Everything here runs against **staging** (Base Sepolia, testnet funds). Nothing spends real money. The code every tutorial shares — config constants and helpers — is on [Shared Setup](shared-setup.md); the staging → production swap table is [further down this page](#running-in-production). -| | | -| --- | --- | -| [**Tutorial 1**](tutorial-1-public-upload.md) | Create a lab and upload a public file — **start here** | -| [**Tutorial 2**](tutorial-2-encrypted-upload.md) | Upload an encrypted file, verified with a decrypt round trip | -| [**Tutorial 3**](tutorial-3-agent-access.md) | Give your agent access to a lab you created in the app | +| | +| --- | +| [**Create a lab and upload a public file (start here)**](tutorial-1-public-upload.md) | +| [**Upload an encrypted file, verified with a decrypt round trip**](tutorial-2-encrypted-upload.md) | +| [**Add your agent as a contributor to your lab**](tutorial-3-agent-access.md) | *** @@ -30,7 +30,7 @@ Four ways to write to a Lab. They are not ranked — pick by who is calling. | **I have no credential, or I want to pay per call** instead of holding a long-lived token | **x402 gateway** — settle USDC on Base per request, no service token to provision | [x402 Gateway](../x402-gateway.md) | | **I already made my lab in the app** (email sign-in, no wallet) **and now I want my agent writing into it** | **Agent-as-Contributor** — the human grants a role, the agent issues its own token | [Tutorial 3](tutorial-3-agent-access.md) | -The lanes compose. A common shape is the plugin lane for the workflow plus x402 for the paid mutations, which is exactly what the plugin does by default. +The lanes are composable, so choosing one now doesn't lock you in to that lane only. ### If you are an agent reading this @@ -44,7 +44,7 @@ Two things, and only one of them involves a human. ### 1. A `mol_` consumer credential — the one manual step -Every request to the API carries a consumer credential in the `Authorization` header. There is no self-service issuance yet, so this is the single "ask the team" left in the API docs. +Every request to the API carries a consumer credential in the `Authorization` header. There is no self-service issuance yet (coming soon), so you will need to request this from the Molecule team. Request it on the [Molecule Discord](https://t.co/L0VEiy4Bjk) with this template: @@ -76,7 +76,8 @@ Treat the whole string as one secret: it is not split into a public and a privat ### 2. A funded wallet on Base Sepolia -You need an EOA with testnet ETH for the LabNFT mint. Fund it from a [Base Sepolia faucet](https://docs.base.org/base-chain/tools/network-faucets). +You need an EOA with testnet ETH for the LabNFT mint, only if you want to interact with the API programmatically. If you interact with out API through our frontend, you do not require any funds, we subsidize all the transactions. +Fund it from a [Base Sepolia faucet](https://docs.base.org/base-chain/tools/network-faucets). If — and only if — you are taking the **x402 lane**, you also need testnet **USDC** on Base Sepolia: get it from the [Circle faucet](https://faucet.circle.com/) (select Base Sepolia). The service-token lane needs no USDC at all. @@ -84,12 +85,12 @@ You do **not** need a pre-issued service token. Every tutorial below mints its o ### What it costs -| Item | Cost | How we know | -| ---- | ---- | ----------- | -| **LabNFT mint** | Gas only. `mintFeeWei()` reads **0** on Base Sepolia **and** on Base mainnet (verified 2026-08-27 by `eth_call`) | Read it live yourself — the tutorials do, and send it as `value` | -| **`createLab`, uploads and other content writes** (service-token lane) | Free | Consumer credential + self-issued service token | -| **The same mutations via x402** | Quoted per request in the `402` challenge — **$0.01 USDC** on both environments today | [Read the price off the challenge](../x402-gateway.md#reading-the-402-challenge); never hardcode it | -| **Storage** | 5 GB per lab included | [Limits](../labs-api/files.md#storage-limits) | +| Item | Cost | +| ---- | ---- | +| **LabNFT mint** | Gas only. `mintFeeWei()` reads **0** on Base Sepolia **and** on Base mainnet, Read it live yourself using mintFeeWei(), and if non-zero, send it as value | +| **`createLab`, uploads and other content writes** (service-token lane) | Free | +| **The same mutations via x402** | Quoted per request in the `402` challenge — **$0.01 USDC** on both environments [Read the price off the challenge](../x402-gateway.md#reading-the-402-challenge); | +| **Storage** | 5 GB per lab included [Limits](../labs-api/files.md#storage-limits) |Ô `mintFeeWei()` is a live contract read, not a constant. The tutorials call it and forward the result, so a future non-zero fee needs no code change on your side — but it will need funds. @@ -109,107 +110,7 @@ claude --plugin-dir /path/to/mol-labs-plugin ## Shared setup -Every environment-specific value lives in this one block; swapping to production is a matter of replacing it with the table in [Running in Production](#running-in-production). - -```javascript -import { baseSepolia } from "viem/chains"; // production: `base` - -// ---- Staging (Base Sepolia) config — see "Running in Production" to swap ---- -const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql"; -const CHAIN = baseSepolia; -const FACTORY_ADDRESS = "0xd629FE2310b4309a212495F10A47f8436dcEfD90"; // OnChainLabFactory -const LABNFT_ADDRESS = "0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28"; // LabNFT (proxy) -const ACCESS_RESOLVER_ADDRESS = "0x5493F472602C87318EA5Eff753cDD593bf9bF559"; // AccessResolver -const ACCESS_CONDITION_CHAIN = "baseSepolia"; // the `chain` string inside accessControlConditions -const LAB_APP_URL = "https://testnet.labs.molecule.xyz"; // production: https://labs.molecule.xyz - -const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; // mol__ — no "Bearer" prefix -const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY; - -// Set once Step 1 exchanges a wallet signature for a token. Every call after -// that automatically starts sending it; public queries (like Step 1's own -// sign-in-message lookup) work fine without it. -let serviceToken; - -async function graphql(query, variables) { - // Authorization is always required. X-Service-Token is added once we have - // one — omit it entirely rather than sending an empty header. - const headers = { "Content-Type": "application/json", Authorization: CONSUMER_CREDENTIAL }; - if (serviceToken) headers["X-Service-Token"] = serviceToken; - - const res = await fetch(GRAPHQL_URL, { - method: "POST", - headers, - body: JSON.stringify({ query, variables }), - }); - const { data, errors } = await res.json(); - // Queries report failure here: a top-level errors[] entry whose errorType is - // the catalogue code. Mutations report expected failures in-band instead (see - // assertOk); a top-level entry on a mutation means a transport/infrastructure - // failure or an invalid request document. - if (errors) throw new Error(JSON.stringify(errors)); - return data; -} - -// Mutations report failure in-band: `error` is null on success. Throw on a -// non-null `error` so a failed step stops the workflow with the catalogue -// `code` and the `requestId` to quote in a bug report. -// `error.details` carries the specific cause under `reason`, but it arrives in -// more than one shape: a plain object on thrown query errors, a JSON string -// in-band, and currently a doubly-encoded JSON string in-band. Parse until it -// stops being a string, so one reader handles all three. -function parseDetails(details) { - let value = details; - for (let i = 0; i < 3 && typeof value === "string"; i++) { - try { - value = JSON.parse(value); - } catch { - break; - } - } - return value && typeof value === "object" ? value : {}; -} - -function assertOk(result, op) { - if (result.error) { - const { code, message, requestId } = result.error; - const { reason } = parseDetails(result.error.details); - throw new Error( - `${op} failed: ${code}${reason ? `/${reason}` : ""}: ${message} (requestId ${requestId})`, - ); - } - return result; -} - -// Onchain state — a mint, a role grant — reaches the API through an event -// indexer, so a write issued immediately after one can fail on state the chain -// already has. Retry with backoff; re-issuing the token never helps. -async function withIndexerLagRetry( - fn, - { codes = ["NOT_FOUND"], attempts = 12, baseMs = 2000, capMs = 30000 } = {}, -) { - const laggy = new RegExp(codes.join("|")); - for (let i = 0; i < attempts; i++) { - try { - return await fn(); - } catch (err) { - if (!laggy.test(String(err)) || i === attempts - 1) throw err; - const delay = Math.min(baseMs * 2 ** i, capMs); // 2s, 4s, 8s, 16s, then 30s - console.warn(`indexer not caught up (attempt ${i + 1}/${attempts}); retrying in ${delay / 1000}s`); - await new Promise((r) => setTimeout(r, delay)); - } - } -} -``` - -{% hint style="warning" %} -**Two places the indexer trails, and both need that retry.** A successful response does not mean every downstream read is caught up yet: - -* **After minting**, the lab's first write can return `NOT_FOUND` — even though `createLab` just succeeded, because `createLab` falls back to an onchain ownership check while the file mutations read the indexed record. [Tutorial 1 Step 4](tutorial-1-public-upload.md#step-4-upload-the-file). -* **After a role grant**, a write can return `UNAUTHORIZED` until the grant is indexed. [Tutorial 3](tutorial-3-agent-access.md#step-4-the-agent-uploads). - -Usually both clear within seconds — but a mint has taken **several minutes** to index on staging under backlog, so the budget above is deliberately generous (12 attempts, backoff capped at 30s, ~4 minutes total) and logs each wait. Use the retry above with `codes` set to the one you expect, and do not treat the first failure as fatal. -{% endhint %} +Every tutorial opens with the same config constants and the same `graphql()` / `assertOk()` / `withIndexerLagRetry()` helpers. They live on their own page — copy them once and every tutorial snippet runs against them: [**Shared Setup**](shared-setup.md). *** @@ -276,7 +177,7 @@ The second is visual — once `shortname` is populated, the lab has a page: ## Running in Production -All three tutorials run against staging (Base Sepolia, testnet funds). To run the same scripts against production, replace the values in the config block — nothing else changes, since every step reads from these constants: +All three tutorials run against staging (Base Sepolia, testnet funds). To run the same scripts against production, replace the values in the [shared setup](shared-setup.md) config block — nothing else changes, since every step reads from these constants: | Constant | Staging (these tutorials) | Production | | -------- | ------------------------- | ---------- | diff --git a/api-reference/getting-started/shared-setup.md b/api-reference/getting-started/shared-setup.md new file mode 100644 index 0000000..715e658 --- /dev/null +++ b/api-reference/getting-started/shared-setup.md @@ -0,0 +1,135 @@ +--- +description: >- + The config constants and helpers every tutorial opens with — copy this block + once and each tutorial's snippets run against it. +icon: code +--- + +# Shared Setup + +**Every tutorial in this section starts from the code on this page.** Creating a lab, uploading a file, encrypting and decrypting a file and adding an agent as a collaborator all assume the constants and helper functions below are already defined — their snippets call `graphql()`, `assertOk()` and `withIndexerLagRetry()` without redefining them, and read `GRAPHQL_URL`, `CHAIN`, `FACTORY_ADDRESS` and the rest from here. Copy this block into your script once, then follow whichever tutorial you need. + +You do not have to copy it by hand if you only want to run one tutorial end to end: the **complete script** at the bottom of each tutorial page carries all of this inline and runs standalone. This page exists so the shared parts are documented and maintained in one place instead of three. + +Everything here targets **staging** (Base Sepolia, testnet funds). To point the same code at mainnet, replace the constants using the table in [Running in Production](README.md#running-in-production) — nothing else changes, because every step reads from these constants. + +*** + +## The shared block + +Every environment-specific value lives in this one block, and every helper the tutorials call is defined in it. Paste it at the top of your script. + +```javascript +import { baseSepolia } from "viem/chains"; // production: `base` + +// ---- Staging (Base Sepolia) config — see "Running in Production" to swap ---- +const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql"; +const CHAIN = baseSepolia; +const FACTORY_ADDRESS = "0xd629FE2310b4309a212495F10A47f8436dcEfD90"; // OnChainLabFactory +const LABNFT_ADDRESS = "0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28"; // LabNFT (proxy) +const ACCESS_RESOLVER_ADDRESS = "0x5493F472602C87318EA5Eff753cDD593bf9bF559"; // AccessResolver +const ACCESS_CONDITION_CHAIN = "baseSepolia"; // the `chain` string inside accessControlConditions +const LAB_APP_URL = "https://testnet.labs.molecule.xyz"; // production: https://labs.molecule.xyz + +const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; // mol__ — no "Bearer" prefix +const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY; + +// Set once Step 1 exchanges a wallet signature for a token. Every call after +// that automatically starts sending it; public queries (like Step 1's own +// sign-in-message lookup) work fine without it. +let serviceToken; + +async function graphql(query, variables) { + // Authorization is always required. X-Service-Token is added once we have + // one — omit it entirely rather than sending an empty header. + const headers = { "Content-Type": "application/json", Authorization: CONSUMER_CREDENTIAL }; + if (serviceToken) headers["X-Service-Token"] = serviceToken; + + const res = await fetch(GRAPHQL_URL, { + method: "POST", + headers, + body: JSON.stringify({ query, variables }), + }); + const { data, errors } = await res.json(); + // Queries report failure here: a top-level errors[] entry whose errorType is + // the catalogue code. Mutations report expected failures in-band instead (see + // assertOk); a top-level entry on a mutation means a transport/infrastructure + // failure or an invalid request document. + if (errors) throw new Error(JSON.stringify(errors)); + return data; +} + +// Mutations report failure in-band: `error` is null on success. Throw on a +// non-null `error` so a failed step stops the workflow with the catalogue +// `code` and the `requestId` to quote in a bug report. +// `error.details` carries the specific cause under `reason`, but it arrives in +// more than one shape: a plain object on thrown query errors, a JSON string +// in-band, and currently a doubly-encoded JSON string in-band. Parse until it +// stops being a string, so one reader handles all three. +function parseDetails(details) { + let value = details; + for (let i = 0; i < 3 && typeof value === "string"; i++) { + try { + value = JSON.parse(value); + } catch { + break; + } + } + return value && typeof value === "object" ? value : {}; +} + +function assertOk(result, op) { + if (result.error) { + const { code, message, requestId } = result.error; + const { reason } = parseDetails(result.error.details); + throw new Error( + `${op} failed: ${code}${reason ? `/${reason}` : ""}: ${message} (requestId ${requestId})`, + ); + } + return result; +} + +// Onchain state — a mint, a role grant — reaches the API through an event +// indexer, so a write issued immediately after one can fail on state the chain +// already has. Retry with backoff; re-issuing the token never helps. +async function withIndexerLagRetry( + fn, + { codes = ["NOT_FOUND"], attempts = 12, baseMs = 2000, capMs = 30000 } = {}, +) { + const laggy = new RegExp(codes.join("|")); + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err) { + if (!laggy.test(String(err)) || i === attempts - 1) throw err; + const delay = Math.min(baseMs * 2 ** i, capMs); // 2s, 4s, 8s, 16s, then 30s + console.warn(`indexer not caught up (attempt ${i + 1}/${attempts}); retrying in ${delay / 1000}s`); + await new Promise((r) => setTimeout(r, delay)); + } + } +} +``` + +*** + +## Where each piece is used + +| Piece | What it does | Where it shows up | +| ----- | ------------ | ----------------- | +| The config constants | Every environment-specific value in one place — endpoint, chain, contract addresses, app URL | Every step of every tutorial | +| `graphql(query, variables)` | POSTs to the API with `Authorization` always set and `X-Service-Token` added once Step 1 has issued one | Every GraphQL call | +| `parseDetails(details)` | Reads `error.details` tolerantly — it arrives as an object, a JSON string, or a doubly-encoded JSON string | Inside `assertOk`; also useful when you branch on `details.reason` yourself | +| `assertOk(result, op)` | Turns an in-band mutation `error` into a thrown error carrying the catalogue `code` and the `requestId` | After every mutation | +| `withIndexerLagRetry(fn)` | Retries a call that failed only because onchain state has not been indexed yet | [Tutorial 1 Step 4](tutorial-1-public-upload.md#step-4-upload-the-file) (after a mint) and [Tutorial 3 Step 4](tutorial-3-agent-access.md#step-4-the-agent-uploads) (after a role grant) | + +*** + +## Then what + +| Next | Page | +| ---- | ---- | +| Prerequisites, costs and the ten-minute quickstart | [Getting Started](README.md) | +| Create a lab and upload a public file | [Tutorial 1](tutorial-1-public-upload.md) | +| Upload an encrypted file | [Tutorial 2](tutorial-2-encrypted-upload.md) | +| Give your agent access to a lab | [Tutorial 3](tutorial-3-agent-access.md) | +| Run the same code against mainnet | [Running in Production](README.md#running-in-production) | diff --git a/api-reference/getting-started/tutorial-1-public-upload.md b/api-reference/getting-started/tutorial-1-public-upload.md index 99dd06c..0ff3232 100644 --- a/api-reference/getting-started/tutorial-1-public-upload.md +++ b/api-reference/getting-started/tutorial-1-public-upload.md @@ -12,7 +12,7 @@ The default path, and the one to run first. Five steps: get a token, mint the La > **Want the file to be confidential instead?** Steps 1–3 are identical; branch at Step 4 into [Tutorial 2](tutorial-2-encrypted-upload.md). {% hint style="info" %} -**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](README.md#shared-setup), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. +**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](shared-setup.md), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. {% endhint %} ## Step 1: Get a service token @@ -214,7 +214,7 @@ DID-linking for the new lab starts automatically in the background; [`getDidLink Three calls: get a presigned URL, `PUT` the bytes, finalise with metadata. Full reference: [Files](../labs-api/files.md). {% hint style="warning" %} -**This is the one step that can fail on a lab you just created.** `createLab` succeeding does not yet mean the lab is writable: it falls back to an onchain ownership check when the mint has not been indexed, while the file mutations read the indexed record and return `NOT_FOUND` until it lands. Wrap the first call in [`withIndexerLagRetry`](README.md#shared-setup) — without it this step fails outright on a fresh mint often enough to matter. Measured on staging: usually indexed within seconds, but one mint took **over four minutes**, which is why the helper retries for that long rather than giving up after a few seconds. +**This is the one step that can fail on a lab you just created.** `createLab` succeeding does not yet mean the lab is writable: it falls back to an onchain ownership check when the mint has not been indexed, while the file mutations read the indexed record and return `NOT_FOUND` until it lands. Wrap the first call in [`withIndexerLagRetry`](shared-setup.md) — without it this step fails outright on a fresh mint often enough to matter. Measured on staging: usually indexed within seconds, but one mint took **over four minutes**, which is why the helper retries for that long rather than giving up after a few seconds. {% endhint %} ```javascript diff --git a/api-reference/getting-started/tutorial-2-encrypted-upload.md b/api-reference/getting-started/tutorial-2-encrypted-upload.md index c842736..9d826aa 100644 --- a/api-reference/getting-started/tutorial-2-encrypted-upload.md +++ b/api-reference/getting-started/tutorial-2-encrypted-upload.md @@ -14,7 +14,7 @@ Same lab, same three-call upload — but the bytes are AES-256-GCM encrypted loc Conceptually: the backend hands you a one-shot data encryption key (DEK) in two forms — plaintext, and wrapped by the key custodian. You encrypt with the plaintext copy, throw it away, and store the wrapped copy in the file's metadata alongside the conditions under which the custodian may unwrap it again. Full model: [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md). {% hint style="info" %} -**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](README.md#shared-setup), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. +**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](shared-setup.md), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. {% endhint %} ## Step 4a: Get a DEK @@ -239,7 +239,7 @@ assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); | `VALIDATION_FAILED`, `reason: INVALID_ACCESS_LEVEL` | `PUBLIC` on an encrypted file | Use `HOLDERS` or `ADMIN` | | `UNAUTHORIZED` on `generateDataEncryptionKey` | No write role on the lab | Owner or Contributor required | | `UPSTREAM_UNAVAILABLE`, "Path is occupied" on `finish` | A file already exists at that `path` — usually a re-run against the same lab | **Not retryable despite the code.** Pick a new `path`, or send `ref` (the previous `datasetId`) instead to add a version | -| `NOT_FOUND` on the first call after a mint | The mint is not indexed yet, even though `createLab` succeeded | Retry with [`withIndexerLagRetry`](README.md#shared-setup) — see [Tutorial 1 Step 4](tutorial-1-public-upload.md#step-4-upload-the-file) | +| `NOT_FOUND` on the first call after a mint | The mint is not indexed yet, even though `createLab` succeeded | Retry with [`withIndexerLagRetry`](shared-setup.md) — see [Tutorial 1 Step 4](tutorial-1-public-upload.md#step-4-upload-the-file) | ## Step 5: Verify by decrypting it diff --git a/api-reference/getting-started/tutorial-3-agent-access.md b/api-reference/getting-started/tutorial-3-agent-access.md index a207594..23257c0 100644 --- a/api-reference/getting-started/tutorial-3-agent-access.md +++ b/api-reference/getting-started/tutorial-3-agent-access.md @@ -10,7 +10,7 @@ icon: robot The most common real-world shape: a researcher created their Lab in the Labs app with an email address — no wallet, no code — and now wants an agent contributing to it. The agent gets its **own** identity rather than borrowing the human's; the human grants it a role; the agent authenticates itself from then on. {% hint style="info" %} -**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](README.md#shared-setup), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. +**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](shared-setup.md), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. {% endhint %} **Who does what:** From 8e1b33bac5043c2c1a33d11103f19ca86d78997d Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Thu, 3 Sep 2026 08:25:11 +0100 Subject: [PATCH 12/16] chore(docs): rename tutorials --- .github/prompts/docs-sync.md | 8 +++---- SUMMARY.md | 6 ++--- api-reference/README.md | 2 +- api-reference/authentication.md | 10 ++++----- api-reference/changelog.md | 2 +- api-reference/getting-started/README.md | 22 +++++++++---------- ...ccess.md => agent-as-a-lab-contributor.md} | 22 +++++++++---------- ...pload.md => create-lab-and-upload-file.md} | 14 ++++++------ api-reference/getting-started/for-agents.md | 4 ++-- api-reference/getting-started/shared-setup.md | 8 +++---- ...ted-upload.md => upload-encrypted-file.md} | 20 ++++++++--------- api-reference/labs-api/README.md | 4 ++-- api-reference/labs-api/files.md | 4 ++-- api-reference/labs-api/lab-management.md | 4 ++-- api-reference/labs-api/service-tokens.md | 2 +- user-guides/developers-ai-agents.md | 2 +- 16 files changed, 67 insertions(+), 67 deletions(-) rename api-reference/getting-started/{tutorial-3-agent-access.md => agent-as-a-lab-contributor.md} (91%) rename api-reference/getting-started/{tutorial-1-public-upload.md => create-lab-and-upload-file.md} (97%) rename api-reference/getting-started/{tutorial-2-encrypted-upload.md => upload-encrypted-file.md} (94%) diff --git a/.github/prompts/docs-sync.md b/.github/prompts/docs-sync.md index 99f396a..4b0851b 100644 --- a/.github/prompts/docs-sync.md +++ b/.github/prompts/docs-sync.md @@ -55,10 +55,10 @@ touches one of its source paths. | `api-reference/authentication.md` | `lambda/appsync-authorizer-lambda/**`, service-token resolvers in `lambda/appsync-resolver-labs-lambda/**` (`services/token-manager-service.ts`, `utils/service-auth-message.ts`), `docs/service-auth.md`. The header reference and the sign-in flow both live here: a change to the sign-in message, its nonce or its validity window touches this page **and** `labs-api/service-tokens.md` **and** the tutorials' Step 1 — fix all of them or none. | | `api-reference/getting-started/README.md` | The onboarding entry point. `lib/desci-api-app-sync/constructs/api-config.ts` (endpoints, introspection, depth limit), `lambda/x402-gateway-lambda/**` (prices/base URLs quoted in the cost table), `lambda/appsync-authorizer-lambda/**` (credential shape). **Contains verified live values** — `mintFeeWei()` readings, x402 prices, gateway URLs — each stamped with the date it was checked. Never update one of those from a diff alone: if the diff suggests a value changed, flag it in the PR body and let a human re-verify against the deployed environment. | | `api-reference/getting-started/shared-setup.md` | The config constants and helpers every tutorial's snippets assume: staging endpoint, chain, `FACTORY_ADDRESS` / `LABNFT_ADDRESS` / `ACCESS_RESOLVER_ADDRESS`, plus `graphql()`, `parseDetails()`, `assertOk()` and `withIndexerLagRetry()`. Sources: `lib/desci-api-app-sync/constructs/api-config.ts` (endpoints), `graphql/schemas/api-error.graphql` and the in-band `ApiError` shape (`parseDetails`/`assertOk`), `lambda/appsync-authorizer-lambda/**` (header names). Contract addresses are deployment values — flag a suspected change for human verification rather than editing from a diff. **This block is duplicated inline in each tutorial's complete script and in the production swap table on `getting-started/README.md`: fix all copies or none.** | -| `api-reference/getting-started/for-agents.md` | Condensed mirror of Tutorial 1 plus the error contract. Sources: `graphql/schemas/ip-hubs.graphql`, `graphql/schemas/api-error.graphql`, `graphql/schemas/encryption.graphql`, `lambda/appsync-resolver-labs-lambda/**` (per-mutation role gates). **Must stay consistent with the tutorial pages** — a change to the flow touches this page and the relevant tutorial, or neither. | -| `api-reference/getting-started/tutorial-1-public-upload.md` | Tutorial 1 — self-issue a token, mint, `createLab`, three-call public upload, verify. Sources: `graphql/schemas/ip-hubs.graphql`; `lambda/appsync-resolver-labs-lambda/**` for `generateServiceToken`, `createLab`, `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile` and their authorization gates; `services/token-manager-service.ts`; `lib/utils/token-expiration.ts` (the `expiresIn` default and bounds). Every code block is expected to be runnable — a signature change here is a real breakage, so correct it and say so in the PR body. Steps 1–3 are duplicated by reference in Tutorials 2 and 3: **fix all copies or none.** | -| `api-reference/getting-started/tutorial-2-encrypted-upload.md` | Tutorial 2 — DEK, local AES-256-GCM, `accessControlConditions`, `encryptionMetadata`, `decryptDataKey` round trip. Sources: `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts`, the encryption resolvers and condition evaluator in `lambda/appsync-resolver-labs-lambda/**`, `lib/encryption-stack.ts`. `encryptionSystem` is backend-set and must stay "echo it verbatim" — never let a literal be hardcoded here. | -| `api-reference/getting-started/tutorial-3-agent-access.md` | Tutorial 3 — a Contributor-role agent writing into a lab a human owns. Sources: the per-mutation service-token gates in `lambda/appsync-resolver-labs-lambda/**` (`authorizeServiceMember` = Contributor, `authorizeServiceAdmin` = Owner) and `services/auth-service.ts`. **If a content-write mutation moves between those two gates, this page's role claims and its Owner-only list are wrong** — that is the single highest-value thing to check here. Role grants themselves are onchain (`AccessResolver`), out of this repo. | +| `api-reference/getting-started/for-agents.md` | Condensed mirror of Create a lab and upload a file plus the error contract. Sources: `graphql/schemas/ip-hubs.graphql`, `graphql/schemas/api-error.graphql`, `graphql/schemas/encryption.graphql`, `lambda/appsync-resolver-labs-lambda/**` (per-mutation role gates). **Must stay consistent with the tutorial pages** — a change to the flow touches this page and the relevant tutorial, or neither. | +| `api-reference/getting-started/create-lab-and-upload-file.md` | Create a lab and upload a file — self-issue a token, mint, `createLab`, three-call public upload, verify. Sources: `graphql/schemas/ip-hubs.graphql`; `lambda/appsync-resolver-labs-lambda/**` for `generateServiceToken`, `createLab`, `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile` and their authorization gates; `services/token-manager-service.ts`; `lib/utils/token-expiration.ts` (the `expiresIn` default and bounds). Every code block is expected to be runnable — a signature change here is a real breakage, so correct it and say so in the PR body. Steps 1–3 are duplicated by reference in Upload an encrypted file and Agent as a lab contributor: **fix all copies or none.** | +| `api-reference/getting-started/upload-encrypted-file.md` | Upload an encrypted file — DEK, local AES-256-GCM, `accessControlConditions`, `encryptionMetadata`, `decryptDataKey` round trip. Sources: `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts`, the encryption resolvers and condition evaluator in `lambda/appsync-resolver-labs-lambda/**`, `lib/encryption-stack.ts`. `encryptionSystem` is backend-set and must stay "echo it verbatim" — never let a literal be hardcoded here. | +| `api-reference/getting-started/agent-as-a-lab-contributor.md` | Agent as a lab contributor — a Contributor-role agent writing into a lab a human owns. Sources: the per-mutation service-token gates in `lambda/appsync-resolver-labs-lambda/**` (`authorizeServiceMember` = Contributor, `authorizeServiceAdmin` = Owner) and `services/auth-service.ts`. **If a content-write mutation moves between those two gates, this page's role claims and its Owner-only list are wrong** — that is the single highest-value thing to check here. Role grants themselves are onchain (`AccessResolver`), out of this repo. | | `api-reference/labs-api/README.md` | `graphql/schemas/ip-hubs.graphql`, `lambda/appsync-resolver-labs-lambda/**` | | `api-reference/labs-api/lab-management.md` | `createLab`, `updateLabNftMetadata`, `generateLabImageUploadUrl` in `lambda/appsync-resolver-labs-lambda/**`; `lambda/labnft-metadata-lambda/**`; `lambda/ocl-processor/**` | | `api-reference/labs-api/files.md` | file operations in `lambda/appsync-resolver-labs-lambda/**` (`initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata`, `moveEntry`), `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts` | diff --git a/SUMMARY.md b/SUMMARY.md index 7542d5f..4883188 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -34,9 +34,9 @@ * [📡 API Overview](api-reference/README.md) * [🚀 Getting Started](api-reference/getting-started/README.md) - * [Tutorial 1: Create a lab and upload a public file](api-reference/getting-started/tutorial-1-public-upload.md) - * [Tutorial 2: Upload an encrypted file](api-reference/getting-started/tutorial-2-encrypted-upload.md) - * [Tutorial 3: Give your agent access to a lab](api-reference/getting-started/tutorial-3-agent-access.md) + * [Create a lab and upload a file](api-reference/getting-started/create-lab-and-upload-file.md) + * [Upload an encrypted file](api-reference/getting-started/upload-encrypted-file.md) + * [Agent as a lab contributor](api-reference/getting-started/agent-as-a-lab-contributor.md) * [🤖 For Agents: One-Pager](api-reference/getting-started/for-agents.md) * [Shared Setup](api-reference/getting-started/shared-setup.md) * [🔐 Authentication](api-reference/authentication.md) diff --git a/api-reference/README.md b/api-reference/README.md index 838add5..b4ab489 100644 --- a/api-reference/README.md +++ b/api-reference/README.md @@ -122,7 +122,7 @@ The full quickstart — prerequisites, costs, and a ten-minute path to a lab wit | Get from zero to a lab with a file in it | [Getting Started](getting-started/README.md) | | Point an AI agent at this API | [Agent one-pager](getting-started/for-agents.md) · [Molecule Skill](../ai-tooling/molecule-skill.md) | | Upload files to a Lab dataroom | [Labs API](labs-api/README.md) · [Tutorials](getting-started/README.md) | -| Let an agent write into a lab someone else owns | [Tutorial 3](getting-started/tutorial-3-agent-access.md) | +| Let an agent write into a lab someone else owns | [Agent as a lab contributor](getting-started/agent-as-a-lab-contributor.md) | | Tokenize a Lab into IP Tokens (IPTs) | [Tokenization API](tokenization-api.md) | | Pay per call without a long-lived token | [x402 Gateway](x402-gateway.md) | | Browse IP-NFTs and IPTs (legacy) | [IPNFT API (Deprecated)](ipnft-api-deprecated.md) | diff --git a/api-reference/authentication.md b/api-reference/authentication.md index 1737d24..08548b5 100644 --- a/api-reference/authentication.md +++ b/api-reference/authentication.md @@ -29,7 +29,7 @@ mol__ Send it as the `Authorization` header value directly — **no `Bearer` prefix**: `Authorization: mol__`. This differs from the Privy path below, which does use `Bearer`; adding `Bearer` in front of a consumer credential makes the request fail authentication. Treat the entire string as a secret — it is not split into a public/private part. Credentials are per environment: a staging credential does not authenticate against production. -You do **not** need to ask anyone for a Service Token. Write mutations need one, and you mint it yourself by signing a message with your wallet — see [Service Tokens](labs-api/service-tokens.md#obtaining-a-token), or [Tutorial 1 Step 1](getting-started/tutorial-1-public-upload.md#step-1-get-a-service-token) for the runnable version. +You do **not** need to ask anyone for a Service Token. Write mutations need one, and you mint it yourself by signing a message with your wallet — see [Service Tokens](labs-api/service-tokens.md#obtaining-a-token), or [Step 1 of Create a lab and upload a file](getting-started/create-lab-and-upload-file.md#step-1-get-a-service-token) for the runnable version. ## Authentication Headers @@ -136,7 +136,7 @@ Both are scoped to the caller's **own** tokens: the token presented must own the ### Obtaining a Service Token -Self-service, two calls, no human in the loop. Full reference with parameters and failure modes: [Service Tokens](labs-api/service-tokens.md#obtaining-a-token). Runnable: [Tutorial 1 Step 1](getting-started/tutorial-1-public-upload.md#step-1-get-a-service-token). +Self-service, two calls, no human in the loop. Full reference with parameters and failure modes: [Service Tokens](labs-api/service-tokens.md#obtaining-a-token). Runnable: [Step 1 of Create a lab and upload a file](getting-started/create-lab-and-upload-file.md#step-1-get-a-service-token). 1. **`getServiceSignInMessage(walletAddress, serviceName)`** — a public query returning the message to sign, plus the `expiresAt` of the nonce embedded in it. 2. **Sign it verbatim** with the wallet, as a plain personal message (EIP-191 `personal_sign` — **not** typed data). Re-wording or re-formatting the string breaks verification. @@ -165,7 +165,7 @@ On every request, the API resolves what the token's wallet may do on the lab nam * **A role granted after the token was issued takes effect without re-issuing it.** Likewise a revoked role stops the token on that lab immediately, while leaving it valid elsewhere. * **A token for a wallet with no role authenticates but cannot write.** You will see `UNAUTHENTICATED` become `UNAUTHORIZED`: the caller is known, just not permitted. -This is why an agent can be handed access to a lab it does not own — the human grants the agent's wallet a role, and the agent's own token starts working on that lab. See [Tutorial 3](getting-started/tutorial-3-agent-access.md). +This is why an agent can be handed access to a lab it does not own — the human grants the agent's wallet a role, and the agent's own token starts working on that lab. See [Agent as a lab contributor](getting-started/agent-as-a-lab-contributor.md). > Because role state reaches the API through an event indexer, there is a short window after a role grant confirms onchain in which a write can still return `UNAUTHORIZED`. Retry with backoff; re-issuing the token does not help. @@ -179,13 +179,13 @@ A working integration has up to three addresses in play at once, and they are no | Who holds the private key | The human (Privy custodies the embedded case) | The agent, and only the agent | **Nobody.** It has no key of its own — its authority derives from whoever currently owns the LabNFT | | How it gets its rights | Implicitly: holding the LabNFT makes it **Owner** | An explicit onchain grant of **Contributor** or **Viewer** from the Owner | It is the lab — rights are resolved *against* it, not held by it | | How it authenticates | `Authorization: Bearer ` + `x-wallet-address` | Signs the sign-in message → its own [service token](labs-api/service-tokens.md#obtaining-a-token) in `X-Service-Token` | It never authenticates. It signs nothing and is issued no token | -| Where its address goes | `x-wallet-address` | `walletAddress` when issuing a token, `changeBy` on writes, and whatever `:userAddress` resolves to at condition-evaluation time | `labAccountAddress` — including the `account` argument of `isAuthorizedSignerForTba` in [access conditions](getting-started/tutorial-2-encrypted-upload.md#step-4c-write-the-access-conditions) | +| Where its address goes | `x-wallet-address` | `walletAddress` when issuing a token, `changeBy` on writes, and whatever `:userAddress` resolves to at condition-evaluation time | `labAccountAddress` — including the `account` argument of `isAuthorizedSignerForTba` in [access conditions](getting-started/upload-encrypted-file.md#step-4c-write-the-access-conditions) | | What it cannot do | — | Transfer the LabNFT, call `updateLabNftMetadata` or `generateLabImageUploadUrl` — those stay Owner-only | Act as a caller: never pass it as `walletAddress` or `changeBy` | Two failure modes this prevents: * **Passing the owner's address where the OCL account belongs** in `accessControlConditions`. Condition evaluation **fails closed**, so the file uploads fine and then nobody can decrypt it — with no error saying why. The `account` argument wants `labAccountAddress`; `:userAddress` is substituted with the caller's wallet automatically. -* **Expecting the agent to inherit the human's reach.** The agent authenticates as itself, so its permissions come from its own grant. That is the point — the human never hands over a key — and it is why a few Owner-only mutations stay out of reach. Walkthrough: [Tutorial 3](getting-started/tutorial-3-agent-access.md). +* **Expecting the agent to inherit the human's reach.** The agent authenticates as itself, so its permissions come from its own grant. That is the point — the human never hands over a key — and it is why a few Owner-only mutations stay out of reach. Walkthrough: [Agent as a lab contributor](getting-started/agent-as-a-lab-contributor.md). #### `oclId` is not a wallet address diff --git a/api-reference/changelog.md b/api-reference/changelog.md index 00b9d49..d6b4004 100644 --- a/api-reference/changelog.md +++ b/api-reference/changelog.md @@ -39,7 +39,7 @@ The five content-write mutations — `initiateCreateOrUpdateFile`, `finishCreate This is what unblocks the "human owns the lab, agent contributes to it" flow: the owner grants the agent's wallet Contributor, and the agent's self-issued token can write. Owner-gated surfaces are unchanged — `createLab`, `updateLabNftMetadata` and `generateLabImageUploadUrl` still require ownership. -**Migration:** None — this is a widening. Note that role state reaches the API through an event indexer, so a write can still return `UNAUTHORIZED` for a few seconds after a grant confirms onchain; retry with backoff rather than re-issuing the token. Walkthrough: [Tutorial 3](getting-started/tutorial-3-agent-access.md). +**Migration:** None — this is a widening. Note that role state reaches the API through an event indexer, so a write can still return `UNAUTHORIZED` for a few seconds after a grant confirms onchain; retry with backoff rather than re-issuing the token. Walkthrough: [Agent as a lab contributor](getting-started/agent-as-a-lab-contributor.md). ### Backend credential stores confined to the platform network diff --git a/api-reference/getting-started/README.md b/api-reference/getting-started/README.md index c395064..d44ccd6 100644 --- a/api-reference/getting-started/README.md +++ b/api-reference/getting-started/README.md @@ -13,9 +13,9 @@ Everything here runs against **staging** (Base Sepolia, testnet funds). Nothing | | | --- | -| [**Create a lab and upload a public file (start here)**](tutorial-1-public-upload.md) | -| [**Upload an encrypted file, verified with a decrypt round trip**](tutorial-2-encrypted-upload.md) | -| [**Add your agent as a contributor to your lab**](tutorial-3-agent-access.md) | +| [**Create a lab and upload a public file (start here)**](create-lab-and-upload-file.md) | +| [**Upload an encrypted file, verified with a decrypt round trip**](upload-encrypted-file.md) | +| [**Add your agent as a contributor to your lab**](agent-as-a-lab-contributor.md) | *** @@ -26,9 +26,9 @@ Four ways to write to a Lab. They are not ranked — pick by who is calling. | Your situation | Lane | Start here | | -------------- | ---- | ---------- | | **I run an AI coding agent** (Claude Code, Codex, Cursor) and want it to do the whole workflow | **Molecule Skill plugin** — a skill + MCP server that wraps every network, onchain and crypto operation as one tool call | [Molecule Skill](../../ai-tooling/molecule-skill.md) | -| **I'm scripting against the API** in Node/TypeScript and want to see the raw calls | **Raw GraphQL + viem** — self-issue a service token, mint, upload | [Tutorial 1](tutorial-1-public-upload.md) | +| **I'm scripting against the API** in Node/TypeScript and want to see the raw calls | **Raw GraphQL + viem** — self-issue a service token, mint, upload | [Create a lab and upload a file](create-lab-and-upload-file.md) | | **I have no credential, or I want to pay per call** instead of holding a long-lived token | **x402 gateway** — settle USDC on Base per request, no service token to provision | [x402 Gateway](../x402-gateway.md) | -| **I already made my lab in the app** (email sign-in, no wallet) **and now I want my agent writing into it** | **Agent-as-Contributor** — the human grants a role, the agent issues its own token | [Tutorial 3](tutorial-3-agent-access.md) | +| **I already made my lab in the app** (email sign-in, no wallet) **and now I want my agent writing into it** | **Agent-as-Contributor** — the human grants a role, the agent issues its own token | [Agent as a lab contributor](agent-as-a-lab-contributor.md) | The lanes are composable, so choosing one now doesn't lock you in to that lane only. @@ -116,7 +116,7 @@ Every tutorial opens with the same config constants and the same `graphql()` / ` ## Ten-minute quickstart -The shortest path from "I have a credential" to "there is a lab with my file in it". Four calls and one transaction. Each step is the condensed form of [Tutorial 1](tutorial-1-public-upload.md), which shows the expected response and the failure modes for every call. +The shortest path from "I have a credential" to "there is a lab with my file in it". Four calls and one transaction. Each step is the condensed form of [Create a lab and upload a file](create-lab-and-upload-file.md), which shows the expected response and the failure modes for every call. ```bash export CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" @@ -129,10 +129,10 @@ export WALLET_PRIVATE_KEY="0x…" # funded on Base Sepolia 4. **Upload a file** — `initiateCreateOrUpdateFile` → `PUT` the bytes to the returned presigned URL → `finishCreateOrUpdateFile` with `accessLevel: "PUBLIC"`. 5. **Verify it worked** — see below. -The runnable version is the [Tutorial 1 complete script](tutorial-1-public-upload.md#complete-script): +The runnable version is the [complete script](create-lab-and-upload-file.md#complete-script): ```bash -node tutorial-1.js ./research-data.csv +node create-lab-and-upload-file.js ./research-data.csv ``` ### Verify it worked @@ -166,9 +166,9 @@ The second is visual — once `shortname` is populated, the lab has a page: | Next | Page | | ---- | ---- | -| Every step with expected responses and failure handling | [Tutorial 1 — public upload](tutorial-1-public-upload.md) | -| Encrypt a file so only wallets with a role can read it | [Tutorial 2 — encrypted upload](tutorial-2-encrypted-upload.md) | -| Let an agent write into a lab a human created in the app | [Tutorial 3 — agent access](tutorial-3-agent-access.md) | +| Every step with expected responses and failure handling | [Create a lab and upload a file](create-lab-and-upload-file.md) | +| Encrypt a file so only wallets with a role can read it | [Upload an encrypted file](upload-encrypted-file.md) | +| Let an agent write into a lab a human created in the app | [Agent as a lab contributor](agent-as-a-lab-contributor.md) | | Pay per call instead of holding a token | [x402 Gateway](../x402-gateway.md) | | Full operation reference | [Labs API](../labs-api/README.md) | | What every error code means | [Error handling](../labs-api/README.md#error-handling) | diff --git a/api-reference/getting-started/tutorial-3-agent-access.md b/api-reference/getting-started/agent-as-a-lab-contributor.md similarity index 91% rename from api-reference/getting-started/tutorial-3-agent-access.md rename to api-reference/getting-started/agent-as-a-lab-contributor.md index 23257c0..6f6d966 100644 --- a/api-reference/getting-started/tutorial-3-agent-access.md +++ b/api-reference/getting-started/agent-as-a-lab-contributor.md @@ -5,7 +5,7 @@ description: >- icon: robot --- -# Tutorial 3: Give your agent access to a lab you created in the app +# Agent as a lab contributor The most common real-world shape: a researcher created their Lab in the Labs app with an email address — no wallet, no code — and now wants an agent contributing to it. The agent gets its **own** identity rather than borrowing the human's; the human grants it a role; the agent authenticates itself from then on. @@ -80,7 +80,7 @@ Expect `role: "CONTRIBUTOR"`. `isAgent` simply echoes the flag the owner set — ## Step 3: The agent self-issues a service token -Identical to Tutorial 1 Step 1, signed by the **agent's** wallet. Fetch the message and redeem it in one go — it embeds a single-use nonce valid for 10 minutes, so an agent that waits for the human's role grant between fetching and signing will hit `UNAUTHENTICATED` / `reason: NONCE_EXPIRED`. Poll for the grant first (Step 2), then sign in: +Identical to [Step 1 of Create a lab and upload a file](create-lab-and-upload-file.md#step-1-get-a-service-token), signed by the **agent's** wallet. Fetch the message and redeem it in one go — it embeds a single-use nonce valid for 10 minutes, so an agent that waits for the human's role grant between fetching and signing will hit `UNAUTHENTICATED` / `reason: NONCE_EXPIRED`. Poll for the grant first (Step 2), then sign in: ```javascript const AGENT_SERVICE_NAME = "research-agent-1"; @@ -128,7 +128,7 @@ Issuance is **not** gated on holding a role — any wallet can mint a token for ## Step 4: The agent uploads -From here the agent is an ordinary caller. Run [Tutorial 1 Step 4](tutorial-1-public-upload.md#step-4-upload-the-file) with `changeBy: agentAccount.address`, or [Tutorial 2](tutorial-2-encrypted-upload.md) for a confidential file — the `hasRole` branch of the team conditions is exactly what lets the agent decrypt too. +From here the agent is an ordinary caller. Run [Step 4 of Create a lab and upload a file](create-lab-and-upload-file.md#step-4-upload-the-file) with `changeBy: agentAccount.address`, or [Upload an encrypted file](upload-encrypted-file.md) for a confidential file — the `hasRole` branch of the team conditions is exactly what lets the agent decrypt too. Writes by a Contributor service token are gated per mutation, matching the Privy user path: `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata` and `moveEntry` all accept Contributor. A few surfaces remain **Owner-only** and an agent Contributor cannot reach them: `updateLabNftMetadata`, `generateLabImageUploadUrl` and the legal-agreement mutations. @@ -153,14 +153,14 @@ async function withIndexerLagRetry( } } -// Same helper as Tutorial 1, with the code this step expects. +// Same helper as in Shared Setup, with the code this step expects. await withIndexerLagRetry(() => uploadFile(oclId, "./findings.csv"), { codes: ["UNAUTHORIZED"] }); ``` {% endhint %} ## Step 5: Verify from both sides -**The agent** verifies as in Tutorial 1 Step 5 — the file is in `dataRoom.files` with `createdBy` set to the agent's address: +**The agent** verifies as in [Step 5 of Create a lab and upload a file](create-lab-and-upload-file.md#step-5-verify-it-worked) — the file is in `dataRoom.files` with `createdBy` set to the agent's address: ```javascript const verify = await graphql( @@ -251,7 +251,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Writes can return UNAUTHORIZED for a few seconds after a role grant confirms // onchain — the indexer trails the chain. Retry rather than re-issuing the -// token. Same helper as Tutorial 1, which retries NOT_FOUND after a mint. +// token. Same helper as in Shared Setup, which retries NOT_FOUND after a mint. async function withIndexerLagRetry( fn, { codes = ["NOT_FOUND"], attempts = 12, baseMs = 2000, capMs = 30000 } = {}, @@ -271,7 +271,7 @@ async function withIndexerLagRetry( async function main() { const filePath = process.argv[2]; - if (!filePath) throw new Error("Usage: node tutorial-3.js "); + if (!filePath) throw new Error("Usage: node agent-as-a-lab-contributor.js "); if (!OCL_ID) throw new Error("Set OCL_ID to the lab the human owns"); // ---- Step 1: the agent's identity ---- @@ -335,7 +335,7 @@ async function main() { serviceToken = tokenResult.generateServiceToken.token; console.log("3/5 Token issued, expires", tokenResult.generateServiceToken.expiresAt); - // ---- Step 4: upload (public; see Tutorial 2 for the encrypted variant) ---- + // ---- Step 4: upload (public; see Upload an encrypted file for the encrypted variant) ---- // Retried on UNAUTHORIZED: the role grant may not be indexed yet. NOT_FOUND // is included for the case where the lab itself was minted moments ago. const bytes = readFileSync(filePath); @@ -416,11 +416,11 @@ main().catch((err) => { ```bash # First run — prints a generated agent key, then stops so the owner can grant the role -CONSUMER_CREDENTIAL="mol_…" OCL_ID="0x0101…" node tutorial-3.js ./findings.csv +CONSUMER_CREDENTIAL="mol_…" OCL_ID="0x0101…" node agent-as-a-lab-contributor.js ./findings.csv # Subsequent runs, once the owner has granted Contributor to that address AGENT_PRIVATE_KEY="0x..." CONSUMER_CREDENTIAL="mol_…" OCL_ID="0x0101…" \ -node tutorial-3.js ./findings.csv +node agent-as-a-lab-contributor.js ./findings.csv ``` *** @@ -429,6 +429,6 @@ node tutorial-3.js ./findings.csv | | | | --- | --- | -| What the agent uploads | [Tutorial 1 — public file](tutorial-1-public-upload.md) · [Tutorial 2 — encrypted file](tutorial-2-encrypted-upload.md) | +| What the agent uploads | [Create a lab and upload a file](create-lab-and-upload-file.md) · [Upload an encrypted file](upload-encrypted-file.md) | | The role model in full | [Roles & Permissions](../../technical-deep-dive/roles-and-permissions.md) | | Let the agent run the whole workflow as tool calls | [Molecule Skill](../../ai-tooling/molecule-skill.md) | diff --git a/api-reference/getting-started/tutorial-1-public-upload.md b/api-reference/getting-started/create-lab-and-upload-file.md similarity index 97% rename from api-reference/getting-started/tutorial-1-public-upload.md rename to api-reference/getting-started/create-lab-and-upload-file.md index 0ff3232..7a09c08 100644 --- a/api-reference/getting-started/tutorial-1-public-upload.md +++ b/api-reference/getting-started/create-lab-and-upload-file.md @@ -5,11 +5,11 @@ description: >- icon: file-arrow-up --- -# Tutorial 1: Create a lab and upload a public file +# Create a lab and upload a public file The default path, and the one to run first. Five steps: get a token, mint the LabNFT, register the lab, upload the file, verify. A public file is stored as-is — no key management, no access conditions. -> **Want the file to be confidential instead?** Steps 1–3 are identical; branch at Step 4 into [Tutorial 2](tutorial-2-encrypted-upload.md). +> **Want the file to be confidential instead?** Steps 1–3 are identical; branch at Step 4 into [Upload an encrypted file](upload-encrypted-file.md). {% hint style="info" %} **Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](shared-setup.md), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. @@ -343,7 +343,7 @@ Keep `datasetId` — it is the file's stable identifier for later reads and upda | Symptom | Cause | Fix | | ------- | ----- | --- | | `initiate` → `NOT_FOUND`, "Project 0x… does not exist" | The mint is not indexed yet. `createLab` can succeed before this is true, so a successful Step 3 is no guarantee | Retry with backoff — `withIndexerLagRetry` above. Usually seconds; observed up to ~4 minutes under indexer backlog. Do **not** re-run `createLab`, which returns `CONFLICT` once registered | -| `initiate` → `UNAUTHORIZED` | The wallet behind the token has no write role on this lab | You must be Owner or Contributor. See [Tutorial 3](tutorial-3-agent-access.md) | +| `initiate` → `UNAUTHORIZED` | The wallet behind the token has no write role on this lab | You must be Owner or Contributor. See [Agent as a lab contributor](agent-as-a-lab-contributor.md) | | `PUT` → `403` | URL expired (~15 min), or headers altered | Re-run `initiate`; send the returned `headers` verbatim | | `PUT` → `400`/`411` | Body wasn't sent as raw bytes | Send the buffer, not a JSON wrapper. In curl: `--data-binary` | | `finish` → `VALIDATION_FAILED`, `details.field: "path"` | `path` contains an underscore, or both `path` and `ref` were sent | Underscores are not allowed in `path`; use `path` for a new file **or** `ref` for a new version, never both | @@ -474,7 +474,7 @@ async function withIndexerLagRetry( async function main() { const filePath = process.argv[2]; - if (!filePath) throw new Error("Usage: node tutorial-1.js "); + if (!filePath) throw new Error("Usage: node create-lab-and-upload-file.js "); const account = privateKeyToAccount(WALLET_PRIVATE_KEY); const publicClient = createPublicClient({ chain: CHAIN, transport: http() }); @@ -622,7 +622,7 @@ main().catch((err) => { ```bash WALLET_PRIVATE_KEY="0x..." \ CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" \ -node tutorial-1.js ./research-data.csv +node create-lab-and-upload-file.js ./research-data.csv ``` *** @@ -631,7 +631,7 @@ node tutorial-1.js ./research-data.csv | | | | --- | --- | -| Make the next file confidential | [Tutorial 2 — Upload an encrypted file](tutorial-2-encrypted-upload.md) | -| Let an agent write into this lab | [Tutorial 3 — Give your agent access](tutorial-3-agent-access.md) | +| Make the next file confidential | [Upload an encrypted file](upload-encrypted-file.md) | +| Let an agent write into this lab | [Agent as a lab contributor](agent-as-a-lab-contributor.md) | | Run it against mainnet | [Running in Production](README.md#running-in-production) | | Per-operation reference | [Files](../labs-api/files.md) · [Lab Management](../labs-api/lab-management.md) | diff --git a/api-reference/getting-started/for-agents.md b/api-reference/getting-started/for-agents.md index 8c41450..8193a72 100644 --- a/api-reference/getting-started/for-agents.md +++ b/api-reference/getting-started/for-agents.md @@ -156,7 +156,7 @@ Public query, `Authorization` only. Your `path` is in `dataRoom.files`. A `null` 3. `finishCreateOrUpdateFile` with `accessLevel: "HOLDERS"` (or `"ADMIN"`) and `encryptionMetadata: { encryptionSystem, encryptedDek, iv, contentHash, accessControlConditions, encryptedBy, encryptedAt }` — echo `encryptionSystem` verbatim, never hardcode it. 4. To read it back: `decryptDataKey(oclId:, filePath:)` → `{ plaintextDEK, iv }` after the backend re-evaluates the file's onchain access conditions against your wallet. -Full recipe including `accessControlConditions`: [Tutorial 2](tutorial-2-encrypted-upload.md). +Full recipe including `accessControlConditions`: [Upload an encrypted file](upload-encrypted-file.md). ## Rules that break runs when ignored @@ -166,7 +166,7 @@ Full recipe including `accessControlConditions`: [Tutorial 2](tutorial-2-encrypt 4. Read `error.details` with the tolerant `parseDetails` above — never a bare `JSON.parse`. In-band it is a JSON string (currently doubly encoded); on thrown query errors it is already an object. 5. Filter mint receipt logs to the **LabNFT** address before decoding `OclIdentityCreated`. 6. Send the presigned `PUT` with the returned headers unchanged, and the raw bytes as the body. -7. Writing into a lab you do not own needs a **Contributor** role on it — see [Tutorial 3](tutorial-3-agent-access.md). After a role grant, an indexer lag of a few seconds can still return `UNAUTHORIZED`; retry with backoff. +7. Writing into a lab you do not own needs a **Contributor** role on it — see [Agent as a lab contributor](agent-as-a-lab-contributor.md). After a role grant, an indexer lag of a few seconds can still return `UNAUTHORIZED`; retry with backoff. 8. **A successful `createLab` does not mean the lab is writable yet.** Step 4's first call can return `NOT_FOUND` ("Project 0x… does not exist") for a few seconds, because `createLab` falls back to an onchain ownership check while the file mutations read the indexed record. Retry `NOT_FOUND` with backoff on the first write after a mint; do not re-run `createLab`, which then returns `CONFLICT`. 9. **Three addresses, not interchangeable**: your own wallet (`walletAddress`, `changeBy`), the human owner's (`x-wallet-address`, their path only), and the Lab's OCL account (`labAccountAddress`, and the `account` argument in access conditions). Putting an owner's address where `labAccountAddress` belongs uploads fine and then locks everyone out of the file, with no error saying why. `oclId` is none of them — it is a lab id whose trailing 40 hex chars happen to be the OCL account address. See [the three wallets](../authentication.md#the-three-wallets-side-by-side). 10. Production has introspection off and a depth limit of 10. Generate types against staging. diff --git a/api-reference/getting-started/shared-setup.md b/api-reference/getting-started/shared-setup.md index 715e658..64a0ca2 100644 --- a/api-reference/getting-started/shared-setup.md +++ b/api-reference/getting-started/shared-setup.md @@ -120,7 +120,7 @@ async function withIndexerLagRetry( | `graphql(query, variables)` | POSTs to the API with `Authorization` always set and `X-Service-Token` added once Step 1 has issued one | Every GraphQL call | | `parseDetails(details)` | Reads `error.details` tolerantly — it arrives as an object, a JSON string, or a doubly-encoded JSON string | Inside `assertOk`; also useful when you branch on `details.reason` yourself | | `assertOk(result, op)` | Turns an in-band mutation `error` into a thrown error carrying the catalogue `code` and the `requestId` | After every mutation | -| `withIndexerLagRetry(fn)` | Retries a call that failed only because onchain state has not been indexed yet | [Tutorial 1 Step 4](tutorial-1-public-upload.md#step-4-upload-the-file) (after a mint) and [Tutorial 3 Step 4](tutorial-3-agent-access.md#step-4-the-agent-uploads) (after a role grant) | +| `withIndexerLagRetry(fn)` | Retries a call that failed only because onchain state has not been indexed yet | [Step 4 of Create a lab and upload a file](create-lab-and-upload-file.md#step-4-upload-the-file) (after a mint) and [Step 4 of Agent as a lab contributor](agent-as-a-lab-contributor.md#step-4-the-agent-uploads) (after a role grant) | *** @@ -129,7 +129,7 @@ async function withIndexerLagRetry( | Next | Page | | ---- | ---- | | Prerequisites, costs and the ten-minute quickstart | [Getting Started](README.md) | -| Create a lab and upload a public file | [Tutorial 1](tutorial-1-public-upload.md) | -| Upload an encrypted file | [Tutorial 2](tutorial-2-encrypted-upload.md) | -| Give your agent access to a lab | [Tutorial 3](tutorial-3-agent-access.md) | +| Create a lab and upload a public file | [Create a lab and upload a file](create-lab-and-upload-file.md) | +| Upload an encrypted file | [Upload an encrypted file](upload-encrypted-file.md) | +| Give your agent access to a lab | [Agent as a lab contributor](agent-as-a-lab-contributor.md) | | Run the same code against mainnet | [Running in Production](README.md#running-in-production) | diff --git a/api-reference/getting-started/tutorial-2-encrypted-upload.md b/api-reference/getting-started/upload-encrypted-file.md similarity index 94% rename from api-reference/getting-started/tutorial-2-encrypted-upload.md rename to api-reference/getting-started/upload-encrypted-file.md index 9d826aa..675078c 100644 --- a/api-reference/getting-started/tutorial-2-encrypted-upload.md +++ b/api-reference/getting-started/upload-encrypted-file.md @@ -5,11 +5,11 @@ description: >- icon: lock --- -# Tutorial 2: Upload an encrypted file +# Upload an encrypted file Same lab, same three-call upload — but the bytes are AES-256-GCM encrypted locally before they leave your machine, and decryption is gated on live onchain state. Encryption is a first-class flow, not an appendix: reach for it whenever the file is confidential and access should follow the lab's roles. -**Steps 1–3 are identical to [Tutorial 1](tutorial-1-public-upload.md)** — get a service token, mint, register. Pick up here with `oclId`, `labAccountAddress`, `account` and `serviceToken` already in hand (or with any lab you already hold a role on). +**Steps 1–3 are identical to [Create a lab and upload a file](create-lab-and-upload-file.md)** — get a service token, mint, register. Pick up here with `oclId`, `labAccountAddress`, `account` and `serviceToken` already in hand (or with any lab you already hold a role on). Conceptually: the backend hands you a one-shot data encryption key (DEK) in two forms — plaintext, and wrapped by the key custodian. You encrypt with the plaintext copy, throw it away, and store the wrapped copy in the file's metadata alongside the conditions under which the custodian may unwrap it again. Full model: [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md). @@ -109,7 +109,7 @@ const ownerOnlyConditions = JSON.stringify([ ]); ``` -**Owner or Contributor or Viewer** — the recipe to use when the lab has a team, and the one Tutorial 3's agent needs. `hasRole` is hierarchical (`ROLE_VIEWER = 1`; a Contributor and the Owner both pass a Viewer check), and the explicit `isAuthorizedSignerForTba` branch keeps the Owner covered even if conditions are ever evaluated against a non-canonical chain: +**Owner or Contributor or Viewer** — the recipe to use when the lab has a team, and the one the contributor agent in [Agent as a lab contributor](agent-as-a-lab-contributor.md) needs. `hasRole` is hierarchical (`ROLE_VIEWER = 1`; a Contributor and the Owner both pass a Viewer check), and the explicit `isAuthorizedSignerForTba` branch keeps the Owner covered even if conditions are ever evaluated against a non-canonical chain: ```javascript const accessResolverAbi = { @@ -163,7 +163,7 @@ const teamConditions = JSON.stringify([ ## Step 4d: Upload the ciphertext -The same three calls as Tutorial 1, with the ciphertext in place of the raw file and `encryptionMetadata` attached on finish. +The same three calls as the public upload, with the ciphertext in place of the raw file and `encryptionMetadata` attached on finish. ```javascript const initiateResult = await graphql( @@ -239,7 +239,7 @@ assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); | `VALIDATION_FAILED`, `reason: INVALID_ACCESS_LEVEL` | `PUBLIC` on an encrypted file | Use `HOLDERS` or `ADMIN` | | `UNAUTHORIZED` on `generateDataEncryptionKey` | No write role on the lab | Owner or Contributor required | | `UPSTREAM_UNAVAILABLE`, "Path is occupied" on `finish` | A file already exists at that `path` — usually a re-run against the same lab | **Not retryable despite the code.** Pick a new `path`, or send `ref` (the previous `datasetId`) instead to add a version | -| `NOT_FOUND` on the first call after a mint | The mint is not indexed yet, even though `createLab` succeeded | Retry with [`withIndexerLagRetry`](shared-setup.md) — see [Tutorial 1 Step 4](tutorial-1-public-upload.md#step-4-upload-the-file) | +| `NOT_FOUND` on the first call after a mint | The mint is not indexed yet, even though `createLab` succeeded | Retry with [`withIndexerLagRetry`](shared-setup.md) — see [Step 4 of Create a lab and upload a file](create-lab-and-upload-file.md#step-4-upload-the-file) | ## Step 5: Verify by decrypting it @@ -311,7 +311,7 @@ console.log("Round trip verified —", recovered.length, "bytes recovered"); ## Complete script -Steps 1–3 are Tutorial 1's verbatim; this script carries them so it runs standalone. +Steps 1–3 are verbatim from [Create a lab and upload a file](create-lab-and-upload-file.md); this script carries them so it runs standalone. ```javascript #!/usr/bin/env node @@ -431,7 +431,7 @@ function buildTeamConditions(oclId, labAccountAddress) { async function main() { const filePath = process.argv[2]; - if (!filePath) throw new Error("Usage: node tutorial-2.js "); + if (!filePath) throw new Error("Usage: node upload-encrypted-file.js "); const account = privateKeyToAccount(WALLET_PRIVATE_KEY); const publicClient = createPublicClient({ chain: CHAIN, transport: http() }); @@ -650,11 +650,11 @@ main().catch((err) => { ```bash WALLET_PRIVATE_KEY="0x..." \ CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" \ -node tutorial-2.js ./confidential-results.csv +node upload-encrypted-file.js ./confidential-results.csv # or against a lab you already have OCL_ID="0x0101…" WALLET_PRIVATE_KEY="0x..." CONSUMER_CREDENTIAL="mol_…" \ -node tutorial-2.js ./confidential-results.csv +node upload-encrypted-file.js ./confidential-results.csv ``` *** @@ -663,6 +663,6 @@ node tutorial-2.js ./confidential-results.csv | | | | --- | --- | -| Let an agent decrypt and contribute too | [Tutorial 3 — Give your agent access](tutorial-3-agent-access.md) | +| Let an agent decrypt and contribute too | [Agent as a lab contributor](agent-as-a-lab-contributor.md) | | Run it against mainnet | [Running in Production](README.md#running-in-production) | | How conditions are evaluated, in depth | [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md) | diff --git a/api-reference/labs-api/README.md b/api-reference/labs-api/README.md index ea0ae82..675c720 100644 --- a/api-reference/labs-api/README.md +++ b/api-reference/labs-api/README.md @@ -21,7 +21,7 @@ The Labs API allows developers to interact with Molecule Labs datarooms without | | | | --- | --- | | **First time here** | [🚀 Getting Started](../getting-started/README.md) — prerequisites, costs, ten-minute quickstart | -| **You want runnable code** | [Tutorial 1](../getting-started/tutorial-1-public-upload.md) (public upload) · [2](../getting-started/tutorial-2-encrypted-upload.md) (encrypted) · [3](../getting-started/tutorial-3-agent-access.md) (agent access) | +| **You want runnable code** | [Create a lab and upload a file](../getting-started/create-lab-and-upload-file.md) · [Upload an encrypted file](../getting-started/upload-encrypted-file.md) · [Agent as a lab contributor](../getting-started/agent-as-a-lab-contributor.md) | | **You're an AI agent** | [Agent one-pager](../getting-started/for-agents.md), or drive this API through the [Molecule Skill](../../ai-tooling/molecule-skill.md) plugin | | **You want to pay per call** | [x402 Gateway](../x402-gateway.md) | @@ -163,7 +163,7 @@ A missing or malformed consumer credential is rejected before the GraphQL layer **`UNAUTHORIZED`** — the wallet behind the service token lacks the required role on the lab: - Check the wallet's role with the public `listLabMembers(oclId)` query. Content writes (uploads, metadata, moves, deletes) need **Contributor**; `createLab` and the LabNFT-metadata mutations need **Owner** -- Not the right role? The lab owner grants one onchain — see [Tutorial 3](../getting-started/tutorial-3-agent-access.md) +- Not the right role? The lab owner grants one onchain — see [Agent as a lab contributor](../getting-started/agent-as-a-lab-contributor.md) - **Just granted the role?** Role state reaches the API through an event indexer, so a write can still return `UNAUTHORIZED` for a few seconds after the grant confirms onchain. Retry with backoff; re-issuing the token does not help **Upload to presigned URL fails:** diff --git a/api-reference/labs-api/files.md b/api-reference/labs-api/files.md index 1657b34..6849efd 100644 --- a/api-reference/labs-api/files.md +++ b/api-reference/labs-api/files.md @@ -2,7 +2,7 @@ Working with files in a Lab dataroom: the three-step upload flow (initiate → upload → finish), plus metadata updates, deletion, storage limits, and client-side encryption. Creating the Lab itself is covered in [Lab Management](lab-management.md). -> **Looking for a runnable walkthrough?** This page is the per-operation reference. For a first upload with expected responses and failure handling at every step, use [Tutorial 1](../getting-started/tutorial-1-public-upload.md) (public file) or [Tutorial 2](../getting-started/tutorial-2-encrypted-upload.md) (encrypted, with a decrypt round trip). +> **Looking for a runnable walkthrough?** This page is the per-operation reference. For a first upload with expected responses and failure handling at every step, use [Create a lab and upload a file](../getting-started/create-lab-and-upload-file.md) or [Upload an encrypted file](../getting-started/upload-encrypted-file.md) (encrypted, with a decrypt round trip). > **Note**: Every mutation on this page returns its failure in-band: the result carries `error: ApiError`, and success means `error` is `null`. Branch on `error.code` — never on `message` text — and quote `requestId` when reporting a problem. See [Error Handling](README.md#error-handling) for the `ApiError` shape, how to read `details`, and the list of error codes. @@ -649,7 +649,7 @@ Enhance file discoverability with optional metadata: ## Advanced: Encrypted File Upload -> **Step-by-step version:** [Tutorial 2 — Upload an encrypted file](../getting-started/tutorial-2-encrypted-upload.md), including both access-condition recipes (owner-only, and owner/contributor/viewer) and a decrypt round trip that verifies the gate actually works. +> **Step-by-step version:** [Upload an encrypted file](../getting-started/upload-encrypted-file.md), including both access-condition recipes (owner-only, and owner/contributor/viewer) and a decrypt round trip that verifies the gate actually works. For files requiring client-side encryption, obtain a data encryption key via the `generateDataEncryptionKey` mutation, encrypt locally, upload as normal, and include an `encryptionMetadata` object on `finishCreateOrUpdateFile`. The full end-to-end model — key wrapping, onchain access conditions, and condition-gated decryption — is documented on the [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md) page. diff --git a/api-reference/labs-api/lab-management.md b/api-reference/labs-api/lab-management.md index d099b8b..25b2dd8 100644 --- a/api-reference/labs-api/lab-management.md +++ b/api-reference/labs-api/lab-management.md @@ -6,7 +6,7 @@ Operations for creating and administering a Lab: creating the dataroom, managing ## Mint the LabNFT -Before `createLab` can attach a dataroom, an onchain lab (OCL) has to exist: a LabNFT minted to your wallet with its ERC-6551 account (Token Bound Account) deployed. This step is **onchain only** — there is no Labs API mutation for it. See [Lab Creation](../../technical-deep-dive/architecture.md#lab-creation) for the contract-level flow and [Molecule Labs](../../technical-deep-dive/onchain-lab.md) for what a Lab is. If you'd rather not touch contracts directly, the Molecule app does this for you — see [Creating a Lab](../../user-guides/scientists-researchers.md#creating-a-lab). For a runnable end-to-end version of the mint, see [Tutorial 1 Step 2](../getting-started/tutorial-1-public-upload.md#step-2-mint-the-labnft). +Before `createLab` can attach a dataroom, an onchain lab (OCL) has to exist: a LabNFT minted to your wallet with its ERC-6551 account (Token Bound Account) deployed. This step is **onchain only** — there is no Labs API mutation for it. See [Lab Creation](../../technical-deep-dive/architecture.md#lab-creation) for the contract-level flow and [Molecule Labs](../../technical-deep-dive/onchain-lab.md) for what a Lab is. If you'd rather not touch contracts directly, the Molecule app does this for you — see [Creating a Lab](../../user-guides/scientists-researchers.md#creating-a-lab). For a runnable end-to-end version of the mint, see [Step 2 of Create a lab and upload a file](../getting-started/create-lab-and-upload-file.md#step-2-mint-the-labnft). ### Contract Addresses @@ -302,7 +302,7 @@ Issue it yourself — no request, no waiting. Two calls with the owner wallet: 1. `getServiceSignInMessage(walletAddress, serviceName)` — public query, returns the message to sign. 2. Sign it verbatim (EIP-191 `personal_sign`), then `generateServiceToken(serviceName, walletAddress, messageSignature)` — returns the JWT for `X-Service-Token`. -Full parameters and bounds: [Service Tokens](service-tokens.md#obtaining-a-token). Runnable: [Tutorial 1 Step 1](../getting-started/tutorial-1-public-upload.md#step-1-get-a-service-token). +Full parameters and bounds: [Service Tokens](service-tokens.md#obtaining-a-token). Runnable: [Step 1 of Create a lab and upload a file](../getting-started/create-lab-and-upload-file.md#step-1-get-a-service-token). The only credential you have to request is the **consumer credential** — see [Getting Started](../getting-started/README.md#1-a-mol-consumer-credential-the-one-manual-step) for the request template. diff --git a/api-reference/labs-api/service-tokens.md b/api-reference/labs-api/service-tokens.md index 200aeb9..138a3a0 100644 --- a/api-reference/labs-api/service-tokens.md +++ b/api-reference/labs-api/service-tokens.md @@ -6,7 +6,7 @@ A service token is the credential that proves *which wallet* a write request act ## Obtaining a Token -Two calls, no human in the loop — the path for autonomous agents, bots and CI/CD pipelines that have no browser-based Privy session. Fetch a fresh sign-in message, sign it with the service wallet, then exchange the signature for a token. For the runnable version, see [Tutorial 1 Step 1](../getting-started/tutorial-1-public-upload.md#step-1-get-a-service-token). +Two calls, no human in the loop — the path for autonomous agents, bots and CI/CD pipelines that have no browser-based Privy session. Fetch a fresh sign-in message, sign it with the service wallet, then exchange the signature for a token. For the runnable version, see [Step 1 of Create a lab and upload a file](../getting-started/create-lab-and-upload-file.md#step-1-get-a-service-token). Issuance is **not** gated on holding a role on any lab: any wallet can mint a token for itself. The role is what makes the token useful. diff --git a/user-guides/developers-ai-agents.md b/user-guides/developers-ai-agents.md index 6c65589..615c8d0 100644 --- a/user-guides/developers-ai-agents.md +++ b/user-guides/developers-ai-agents.md @@ -57,7 +57,7 @@ The simplest agent integration is read-only: query a Lab's data room for files, A write-enabled agent goes further: it reads data, performs analysis, and writes results back as new files in the Lab's data room. This requires both a consumer credential and a service token. Two paths to a service token: -* **Long-lived service token** — the agent mints its own via the `generateServiceToken` mutation, by signing a message with its wallet (a Privy session works too). No provisioning request. The token is a JWT tied to that wallet; write authorization is resolved per request from the wallet's onchain role on the target Lab. Content writes need **Contributor**. The end-to-end version — agent wallet, human grants the role, agent self-issues and uploads — is [Tutorial 3](../api-reference/getting-started/tutorial-3-agent-access.md). +* **Long-lived service token** — the agent mints its own via the `generateServiceToken` mutation, by signing a message with its wallet (a Privy session works too). No provisioning request. The token is a JWT tied to that wallet; write authorization is resolved per request from the wallet's onchain role on the target Lab. Content writes need **Contributor**. The end-to-end version — agent wallet, human grants the role, agent self-issues and uploads — is [Agent as a lab contributor](../api-reference/getting-started/agent-as-a-lab-contributor.md). * **Pay-per-call via the** [**x402 Gateway**](../api-reference/x402-gateway.md) — for agents that serve external users, charge per request, or don't have pre-provisioned credentials. The gateway settles a USDC payment on Base per call and mints a short-lived (default 5-minute) service token scoped to one mutation. Available for `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createLab`, `generateDataEncryptionKey`, and `decryptDataKey`. The three-step upload flow (initiate, PUT, finalize) lets you write any file type with metadata including descriptions, tags, categories, and searchable content text. If the file should be confidential, first request a key via `generateDataEncryptionKey` — the backend returns a one-shot plaintext DEK (plus its encrypted form) you use to encrypt locally before upload, attaching the encryption metadata on finish. Every file the agent writes becomes a permanent, versioned record in the Lab's history. From 40cab72b39823e635f1a3c4c7698c651f7d41a0c Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Thu, 3 Sep 2026 09:37:10 +0100 Subject: [PATCH 13/16] chore(docs): refactor getting started readme page / add a glossary page --- .github/prompts/docs-sync.md | 1 + SUMMARY.md | 3 +- ai-tooling/molecule-skill.md | 2 +- api-reference/README.md | 2 +- api-reference/getting-started/README.md | 147 ++++++++---------- .../agent-as-a-lab-contributor.md | 2 +- .../create-lab-and-upload-file.md | 10 +- api-reference/getting-started/for-agents.md | 2 +- .../getting-started/upload-encrypted-file.md | 2 +- api-reference/x402-gateway.md | 2 +- references/glossary.md | 70 +++++++++ user-guides/developers-ai-agents.md | 2 +- 12 files changed, 151 insertions(+), 94 deletions(-) create mode 100644 references/glossary.md diff --git a/.github/prompts/docs-sync.md b/.github/prompts/docs-sync.md index 4b0851b..a11e73f 100644 --- a/.github/prompts/docs-sync.md +++ b/.github/prompts/docs-sync.md @@ -59,6 +59,7 @@ touches one of its source paths. | `api-reference/getting-started/create-lab-and-upload-file.md` | Create a lab and upload a file — self-issue a token, mint, `createLab`, three-call public upload, verify. Sources: `graphql/schemas/ip-hubs.graphql`; `lambda/appsync-resolver-labs-lambda/**` for `generateServiceToken`, `createLab`, `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile` and their authorization gates; `services/token-manager-service.ts`; `lib/utils/token-expiration.ts` (the `expiresIn` default and bounds). Every code block is expected to be runnable — a signature change here is a real breakage, so correct it and say so in the PR body. Steps 1–3 are duplicated by reference in Upload an encrypted file and Agent as a lab contributor: **fix all copies or none.** | | `api-reference/getting-started/upload-encrypted-file.md` | Upload an encrypted file — DEK, local AES-256-GCM, `accessControlConditions`, `encryptionMetadata`, `decryptDataKey` round trip. Sources: `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts`, the encryption resolvers and condition evaluator in `lambda/appsync-resolver-labs-lambda/**`, `lib/encryption-stack.ts`. `encryptionSystem` is backend-set and must stay "echo it verbatim" — never let a literal be hardcoded here. | | `api-reference/getting-started/agent-as-a-lab-contributor.md` | Agent as a lab contributor — a Contributor-role agent writing into a lab a human owns. Sources: the per-mutation service-token gates in `lambda/appsync-resolver-labs-lambda/**` (`authorizeServiceMember` = Contributor, `authorizeServiceAdmin` = Owner) and `services/auth-service.ts`. **If a content-write mutation moves between those two gates, this page's role claims and its Owner-only list are wrong** — that is the single highest-value thing to check here. Role grants themselves are onchain (`AccessResolver`), out of this repo. | +| `references/glossary.md` | Plain-language definitions of every Molecule term used in the API docs (Lab, LabNFT, `oclId`, data room, Kamu, roles, service token, consumer credential, x402, indexer). Definitions are summarised from `technical-deep-dive/**` and the Labs API pages rather than from source directly — if a diff changes what one of those terms *means* (a renamed identifier, a changed role name, a new credential type), update the entry here as well as the page it came from. Never let this page and the deep-dive pages disagree. | | `api-reference/labs-api/README.md` | `graphql/schemas/ip-hubs.graphql`, `lambda/appsync-resolver-labs-lambda/**` | | `api-reference/labs-api/lab-management.md` | `createLab`, `updateLabNftMetadata`, `generateLabImageUploadUrl` in `lambda/appsync-resolver-labs-lambda/**`; `lambda/labnft-metadata-lambda/**`; `lambda/ocl-processor/**` | | `api-reference/labs-api/files.md` | file operations in `lambda/appsync-resolver-labs-lambda/**` (`initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata`, `moveEntry`), `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts` | diff --git a/SUMMARY.md b/SUMMARY.md index 4883188..1810a4f 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -34,11 +34,11 @@ * [📡 API Overview](api-reference/README.md) * [🚀 Getting Started](api-reference/getting-started/README.md) + * [Shared Setup](api-reference/getting-started/shared-setup.md) * [Create a lab and upload a file](api-reference/getting-started/create-lab-and-upload-file.md) * [Upload an encrypted file](api-reference/getting-started/upload-encrypted-file.md) * [Agent as a lab contributor](api-reference/getting-started/agent-as-a-lab-contributor.md) * [🤖 For Agents: One-Pager](api-reference/getting-started/for-agents.md) - * [Shared Setup](api-reference/getting-started/shared-setup.md) * [🔐 Authentication](api-reference/authentication.md) * [⚙️ Labs API](api-reference/labs-api/README.md) * [Lab Management](api-reference/labs-api/lab-management.md) @@ -64,6 +64,7 @@ ## REFERENCES +* [Glossary](references/glossary.md) * [Contracts](references/contracts/README.md) * [IPT](references/contracts/ipt.md) * [Tokenizer](references/contracts/tokenizer.md) diff --git a/ai-tooling/molecule-skill.md b/ai-tooling/molecule-skill.md index 866a767..b99ecdf 100644 --- a/ai-tooling/molecule-skill.md +++ b/ai-tooling/molecule-skill.md @@ -249,7 +249,7 @@ This lists every tool and exercises the pure-compute ones (encryption round-trip ### Related Pages -* [Getting Started](../api-reference/getting-started/README.md) — the four lanes, prerequisites and costs; this plugin is the "agent runner" lane +* [Getting Started](../api-reference/getting-started/README.md) — the ways in, prerequisites and costs; this plugin is the one for AI coding agents * [Tutorials](../api-reference/getting-started/README.md) — the same workflow as raw GraphQL, if you want to see the calls underneath * [Molecule Labs](../technical-deep-dive/onchain-lab.md) — what an Onchain Lab is * [Roles & Permissions](../technical-deep-dive/roles-and-permissions.md) — the role model used by access conditions diff --git a/api-reference/README.md b/api-reference/README.md index b4ab489..f914aae 100644 --- a/api-reference/README.md +++ b/api-reference/README.md @@ -4,7 +4,7 @@ The Molecule Protocol provides programmatic APIs for building applications, integrations, and automated workflows on top of decentralized science infrastructure. These APIs enable developers to query project data, tokenize research, and manage research datarooms. -> **New here? Start at [🚀 Getting Started](getting-started/README.md).** It picks your lane, lists the two prerequisites, and walks a ten-minute quickstart that ends in a lab with a file in it. Agents: the [one-pager](getting-started/for-agents.md) is the whole default flow on one page. +> **New here? Start at [🚀 Getting Started](getting-started/README.md).** It explains what a Lab is, lists the two prerequisites, and walks a ten-minute quickstart that ends in a lab with a file in it. Agents: the [one-pager](getting-started/for-agents.md) is the whole default flow on one page. ## API Areas diff --git a/api-reference/getting-started/README.md b/api-reference/getting-started/README.md index d44ccd6..e1020b6 100644 --- a/api-reference/getting-started/README.md +++ b/api-reference/getting-started/README.md @@ -1,36 +1,35 @@ --- description: >- - Pick the lane that fits your caller, gather the two prerequisites, and get a - lab with a file in it inside ten minutes. + What a Lab is, what you need before your first call, and a ten-minute path to + a lab with a file in it. icon: rocket --- # 🚀 Getting Started -This is the entry point to the Molecule API. It helps you **pick a lane**, lists the **two prerequisites** you actually need, and hands you a **ten-minute quickstart** that ends with a lab you can see. The three tutorials underneath it take the same ground step by step. +The Molecule API lets you create a **Lab** — a research project with its own onchain identity and its own file store — and then read and write that Lab's files from code. -Everything here runs against **staging** (Base Sepolia, testnet funds). Nothing spends real money. The code every tutorial shares — config constants and helpers — is on [Shared Setup](shared-setup.md); the staging → production swap table is [further down this page](#running-in-production). +This page covers the two things you need before your first call, which way of calling the API fits you, and a ten-minute path that ends with a lab you can open in a browser. -| | -| --- | -| [**Create a lab and upload a public file (start here)**](create-lab-and-upload-file.md) | -| [**Upload an encrypted file, verified with a decrypt round trip**](upload-encrypted-file.md) | -| [**Add your agent as a contributor to your lab**](agent-as-a-lab-contributor.md) | +{% hint style="info" %} +**New to the Molecule ecosystem?** The [Glossary](../../references/glossary.md) defines every term used in these guides — Lab, LabNFT, oclId, data room, service token, indexer — in a sentence or two each. Worth keeping open in a second tab. +{% endhint %} + +Everything here runs against **staging** (Base Sepolia, testnet funds), so nothing spends real money. Moving to mainnet later is a matter of swapping a handful of constants: [Running in Production](#running-in-production). *** -## Choose your path +## Choose how you'll call the API -Four ways to write to a Lab. They are not ranked — pick by who is calling. +Three ways in. None is better than the others — pick by who is making the calls. -| Your situation | Lane | Start here | -| -------------- | ---- | ---------- | -| **I run an AI coding agent** (Claude Code, Codex, Cursor) and want it to do the whole workflow | **Molecule Skill plugin** — a skill + MCP server that wraps every network, onchain and crypto operation as one tool call | [Molecule Skill](../../ai-tooling/molecule-skill.md) | -| **I'm scripting against the API** in Node/TypeScript and want to see the raw calls | **Raw GraphQL + viem** — self-issue a service token, mint, upload | [Create a lab and upload a file](create-lab-and-upload-file.md) | -| **I have no credential, or I want to pay per call** instead of holding a long-lived token | **x402 gateway** — settle USDC on Base per request, no service token to provision | [x402 Gateway](../x402-gateway.md) | -| **I already made my lab in the app** (email sign-in, no wallet) **and now I want my agent writing into it** | **Agent-as-Contributor** — the human grants a role, the agent issues its own token | [Agent as a lab contributor](agent-as-a-lab-contributor.md) | +| If this is you | What you'll use | Start here | +| -------------- | --------------- | ---------- | +| **You're writing a script** in Node/TypeScript and want to see the raw calls | GraphQL requests plus [viem](https://viem.sh) for the one onchain step | [The tutorials](#the-tutorials) below | +| **You run an AI coding agent** (Claude Code, Codex, Cursor) and want it to do the whole workflow for you | The Molecule Skill plugin, which wraps every network, onchain and crypto operation as a single tool call | [Molecule Skill](../../ai-tooling/molecule-skill.md) | +| **You'd rather pay per call** than hold a long-lived credential | The x402 gateway, which settles USDC on Base per request | [x402 Gateway](../x402-gateway.md) | -The lanes are composable, so choosing one now doesn't lock you in to that lane only. +These combine rather than compete — choosing one now doesn't lock you out of the others. A common setup is the plugin for the workflow and x402 for the calls that cost money. ### If you are an agent reading this @@ -38,6 +37,20 @@ Go to the [**Agent one-pager**](for-agents.md) instead. It is the whole default *** +## The tutorials + +Each one is runnable end to end against staging, and shows the expected response and the failure modes at every step. + +| Tutorial | What you have when you finish | +| -------- | ----------------------------- | +| [**Create a lab and upload a file**](create-lab-and-upload-file.md) — start here | A Lab of your own, with a public file in it | +| [**Upload an encrypted file**](upload-encrypted-file.md) | A confidential file that only certain people that you specify can decrypt | +| [**Agent as a lab contributor**](agent-as-a-lab-contributor.md) | An agent writing into a Lab that a human owns | + +All three open with the same configuration constants and helper functions, which live on one page: [**Shared Setup**](shared-setup.md). Copy that block once and every snippet in the tutorials runs against it. + +*** + ## Prerequisites Two things, and only one of them involves a human. @@ -76,23 +89,22 @@ Treat the whole string as one secret: it is not split into a public and a privat ### 2. A funded wallet on Base Sepolia -You need an EOA with testnet ETH for the LabNFT mint, only if you want to interact with the API programmatically. If you interact with out API through our frontend, you do not require any funds, we subsidize all the transactions. -Fund it from a [Base Sepolia faucet](https://docs.base.org/base-chain/tools/network-faucets). +**You only need this if you are creating a new Lab from code.** Creating a Lab means minting a LabNFT, which is an onchain transaction, and the wallet that sends it pays the gas. If you create your Lab in the Molecule app instead, you need no funds at all — Molecule covers those transactions for you. The app is at [labs.molecule.xyz](https://labs.molecule.xyz/), or [testnet.labs.molecule.xyz](https://testnet.labs.molecule.xyz/) for the Base Sepolia environment these tutorials run against. -If — and only if — you are taking the **x402 lane**, you also need testnet **USDC** on Base Sepolia: get it from the [Circle faucet](https://faucet.circle.com/) (select Base Sepolia). The service-token lane needs no USDC at all. +For the programmatic path you need an [EOA](../../references/glossary.md#calling-the-api) — an ordinary wallet with a private key — holding testnet ETH on Base Sepolia. Fund it from a [Base Sepolia faucet](https://docs.base.org/base-chain/tools/network-faucets). -You do **not** need a pre-issued service token. Every tutorial below mints its own from a wallet signature in the first step. +If — and only if — you are using the **x402 gateway**, you also need testnet **USDC** on Base Sepolia: get it from the [Circle faucet](https://faucet.circle.com/) (select Base Sepolia). Paying with a service token needs no USDC at all. -### What it costs +You do **not** need a pre-issued **service token** — it is proof that you control a particular wallet, and every tutorial issues its own by signing a message in its first step. What it is, what it authorizes and how it is sent: [Authentication](../authentication.md#what-a-service-token-actually-authorizes). -| Item | Cost | -| ---- | ---- | -| **LabNFT mint** | Gas only. `mintFeeWei()` reads **0** on Base Sepolia **and** on Base mainnet, Read it live yourself using mintFeeWei(), and if non-zero, send it as value | -| **`createLab`, uploads and other content writes** (service-token lane) | Free | -| **The same mutations via x402** | Quoted per request in the `402` challenge — **$0.01 USDC** on both environments [Read the price off the challenge](../x402-gateway.md#reading-the-402-challenge); | -| **Storage** | 5 GB per lab included [Limits](../labs-api/files.md#storage-limits) |Ô +### What it costs -`mintFeeWei()` is a live contract read, not a constant. The tutorials call it and forward the result, so a future non-zero fee needs no code change on your side — but it will need funds. +| Item | Cost | +| ---- | ---- | +| **LabNFT mint** | Gas only, on Base Sepolia **and** on Base mainnet. Read the fee live with `mintFeeWei()` and, if it is ever non-zero, send it as `value` — the tutorials already do this, so a future fee needs no code change on your side | +| **`createLab`, uploads and other content writes** | Free | +| **The same mutations through the x402 gateway** | Quoted per request in the `402` challenge, currently **$0.01 USDC** on both environments. [Read the price off the challenge](../x402-gateway.md#reading-the-402-challenge) rather than hardcoding it | +| **Storage** | 5 GB per lab included — see [Limits](../labs-api/files.md#storage-limits) | ### Tooling @@ -100,7 +112,7 @@ You do **not** need a pre-issued service token. Every tutorial below mints its o npm install viem # Node 18+ has fetch and node:crypto built in ``` -Or, for the plugin lane, install [`mol-labs-plugin`](../../ai-tooling/molecule-skill.md#getting-the-plugin) and run `config_doctor` — it names exactly which configuration is still missing instead of letting a tool guess: +Or, if you are using the Molecule Skill plugin, install [`mol-labs-plugin`](../../ai-tooling/molecule-skill.md#getting-the-plugin) and run `config_doctor` — it names exactly which configuration is still missing instead of letting a tool guess: ```bash claude --plugin-dir /path/to/mol-labs-plugin @@ -108,24 +120,18 @@ claude --plugin-dir /path/to/mol-labs-plugin *** -## Shared setup - -Every tutorial opens with the same config constants and the same `graphql()` / `assertOk()` / `withIndexerLagRetry()` helpers. They live on their own page — copy them once and every tutorial snippet runs against them: [**Shared Setup**](shared-setup.md). - -*** - ## Ten-minute quickstart -The shortest path from "I have a credential" to "there is a lab with my file in it". Four calls and one transaction. Each step is the condensed form of [Create a lab and upload a file](create-lab-and-upload-file.md), which shows the expected response and the failure modes for every call. +The shortest path from "I have a credential" to "there is a lab with my file in it". Four API calls and one transaction. Each step below is the condensed form of [Create a lab and upload a file](create-lab-and-upload-file.md), which shows the expected response and the failure modes for every call. ```bash export CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" export WALLET_PRIVATE_KEY="0x…" # funded on Base Sepolia ``` -1. **Self-issue a service token** — `getServiceSignInMessage` → sign the message with your wallet (EIP-191 `personal_sign`) → `generateServiceToken`. No human in the loop. Keep the three calls together: the message carries a single-use nonce valid for 10 minutes. +1. **Issue yourself a service token** — `getServiceSignInMessage` → sign the message with your wallet (EIP-191 `personal_sign`) → `generateServiceToken`. No human in the loop. Keep the three calls together: the message carries a single-use nonce valid for 10 minutes. 2. **Mint the LabNFT** — `OnChainLabFactory.mintAndCreateAccount(yourAddress)` with `value: mintFeeWei()`. Read `oclId` off the `OclIdentityCreated` event. -3. **Register the lab** — `createLab(input: { oclId })`. +3. **Register the lab** — `createLab(input: { oclId })`. This attaches the data room your files will live in. 4. **Upload a file** — `initiateCreateOrUpdateFile` → `PUT` the bytes to the returned presigned URL → `finishCreateOrUpdateFile` with `accessLevel: "PUBLIC"`. 5. **Verify it worked** — see below. @@ -149,35 +155,22 @@ curl -s -X POST https://staging.graphql.api.molecule.xyz/graphql \ }' ``` -Your file appears in `dataRoom.files` with the `path` you sent and `accessLevel: "PUBLIC"`. If `labWithDataRoomAndFiles` returns `null`, `createLab` did not complete — it is one of the two nullable queries on this API, so a missing lab nulls the field rather than throwing. +Your file appears in `dataRoom.files` with the `path` you sent and `accessLevel: "PUBLIC"`. If `labWithDataRoomAndFiles` comes back `null`, `createLab` did not complete — a missing lab nulls the field rather than throwing an error. -The second is visual — once `shortname` is populated, the lab has a page: +The second check is visual — once `shortname` is populated, the lab has a page of its own: | Environment | Lab page | | ----------- | -------- | | Staging | `https://testnet.labs.molecule.xyz/projects/` | | Production | `https://labs.molecule.xyz/projects/` | -`shortname` is derived server-side from the lab's name and is `null` until it has been derived — a freshly minted lab is reachable by `oclId` before it is reachable by slug. - -*** - -## Then what - -| Next | Page | -| ---- | ---- | -| Every step with expected responses and failure handling | [Create a lab and upload a file](create-lab-and-upload-file.md) | -| Encrypt a file so only wallets with a role can read it | [Upload an encrypted file](upload-encrypted-file.md) | -| Let an agent write into a lab a human created in the app | [Agent as a lab contributor](agent-as-a-lab-contributor.md) | -| Pay per call instead of holding a token | [x402 Gateway](../x402-gateway.md) | -| Full operation reference | [Labs API](../labs-api/README.md) | -| What every error code means | [Error handling](../labs-api/README.md#error-handling) | +`shortname` is derived server-side from the lab's name and is `null` until that has happened, so a freshly minted lab is reachable by `oclId` before it is reachable by slug. *** ## Running in Production -All three tutorials run against staging (Base Sepolia, testnet funds). To run the same scripts against production, replace the values in the [shared setup](shared-setup.md) config block — nothing else changes, since every step reads from these constants: +The tutorials all run against staging (Base Sepolia, testnet funds). To run the same scripts against production, replace the values in the [Shared Setup](shared-setup.md) config block — nothing else changes, because every step reads from these constants: | Constant | Staging (these tutorials) | Production | | -------- | ------------------------- | ---------- | @@ -189,28 +182,16 @@ All three tutorials run against staging (Base Sepolia, testnet funds). To run th | `ACCESS_CONDITION_CHAIN` | `"baseSepolia"` | `"base"` | | `LAB_APP_URL` | `https://testnet.labs.molecule.xyz` | `https://labs.molecule.xyz` | -```javascript -import { base } from "viem/chains"; // instead of baseSepolia - -const GRAPHQL_URL = "https://production.graphql.api.molecule.xyz/graphql"; -const CHAIN = base; -const FACTORY_ADDRESS = "0xECdF4f05384056507485C90aeAb0a83268760D6E"; -const LABNFT_ADDRESS = "0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92"; -const ACCESS_RESOLVER_ADDRESS = "0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B"; -const ACCESS_CONDITION_CHAIN = "base"; -const LAB_APP_URL = "https://labs.molecule.xyz"; -``` - A few things follow automatically from that swap: * **Headers and the `graphql()` helper** are identical — `Authorization` (consumer credential, no `Bearer`) and the self-issued `X-Service-Token` work the same against both endpoints. Note that credentials are **per environment**: a staging `mol_` credential does not authenticate against production. -* **The `mintFeeWei()` read** already queries the live contract, so it picks up whatever fee production has configured with no code change. It reads `0` today on both chains. -* **The condition ABIs** are unchanged; only `contractAddress` and `chain` differ, and both come from the config block. +* **The `mintFeeWei()` read** already queries the live contract, so it picks up whatever fee production has configured with no code change. +* **The access-condition ABIs** are unchanged; only `contractAddress` and `chain` differ, and both come from the config block. What doesn't follow automatically, and is on you: * **Real funds.** Minting on `base` spends real ETH. Test on staging first. -* **Introspection is off in production** and query depth is capped at 10. Generate types against staging — see [Getting the schema](README.md#getting-the-schema). +* **Introspection is off in production** and query depth is capped at 10. Generate types against staging — see [Getting the schema](#getting-the-schema). * **`SERVICE_NAME`** should identify the real integration; it is echoed into the sign-in message and stored against the issued token. * Full deployment list, including every other OCL contract on both chains: [Contracts reference](../../references/contracts/README.md). @@ -220,10 +201,6 @@ What doesn't follow automatically, and is on you: **Staging has GraphQL introspection enabled** — point codegen, a playground or an SDK generator straight at it: -``` -https://staging.graphql.api.molecule.xyz/graphql -``` - ```bash # graphql-codegen, apollo, gql.tada… all work against staging DESCI_API_SCHEMA=https://staging.graphql.api.molecule.xyz/graphql npx graphql-codegen @@ -231,17 +208,19 @@ DESCI_API_SCHEMA=https://staging.graphql.api.molecule.xyz/graphql npx graphql-co Introspection requires only the `Authorization` consumer-credential header, the same as any query. -**Production has introspection disabled**, deliberately — `__schema` and `__type` return a validation error there (`__typename` still resolves), and selection-set depth is capped at 10. Generate against staging and point the generated client at production; the two environments serve the same schema. +**Production has introspection disabled**, deliberately — `__schema` and `__type` return a validation error there (`__typename` still resolves). Generate against staging and point the generated client at production; the two environments serve the same schema. Production also enforces a query-depth limit of 10, which fails at execution time with `errorType: "QueryDepthLimitReached"` and partial data — a plain GraphQL error, not the catalogued shape. Handle both. *** -## Endpoints - -``` -Staging: https://staging.graphql.api.molecule.xyz/graphql -Production: https://production.graphql.api.molecule.xyz/graphql -``` +## Where to go next -x402 gateway base URLs are published on the [x402 Gateway](../x402-gateway.md#gateway-base-urls) page. +| Next | Page | +| ---- | ---- | +| What every term in these guides means | [Glossary](../../references/glossary.md) | +| The config and helpers every tutorial uses | [Shared Setup](shared-setup.md) | +| Every operation, parameter and error code | [Labs API](../labs-api/README.md) | +| What each error code means and how to read it | [Error handling](../labs-api/README.md#error-handling) | +| Paying per call, and the gateway base URLs | [x402 Gateway](../x402-gateway.md) | +| What a Lab actually is, onchain | [Molecule Labs](../../technical-deep-dive/onchain-lab.md) | diff --git a/api-reference/getting-started/agent-as-a-lab-contributor.md b/api-reference/getting-started/agent-as-a-lab-contributor.md index 6f6d966..8fdb982 100644 --- a/api-reference/getting-started/agent-as-a-lab-contributor.md +++ b/api-reference/getting-started/agent-as-a-lab-contributor.md @@ -10,7 +10,7 @@ icon: robot The most common real-world shape: a researcher created their Lab in the Labs app with an email address — no wallet, no code — and now wants an agent contributing to it. The agent gets its **own** identity rather than borrowing the human's; the human grants it a role; the agent authenticates itself from then on. {% hint style="info" %} -**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](shared-setup.md), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. +**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](shared-setup.md), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. Unfamiliar with a term used here? See the [Glossary](../../references/glossary.md). {% endhint %} **Who does what:** diff --git a/api-reference/getting-started/create-lab-and-upload-file.md b/api-reference/getting-started/create-lab-and-upload-file.md index 7a09c08..627d229 100644 --- a/api-reference/getting-started/create-lab-and-upload-file.md +++ b/api-reference/getting-started/create-lab-and-upload-file.md @@ -12,7 +12,7 @@ The default path, and the one to run first. Five steps: get a token, mint the La > **Want the file to be confidential instead?** Steps 1–3 are identical; branch at Step 4 into [Upload an encrypted file](upload-encrypted-file.md). {% hint style="info" %} -**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](shared-setup.md), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. +**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](shared-setup.md), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. Unfamiliar with a term used here? See the [Glossary](../../references/glossary.md). {% endhint %} ## Step 1: Get a service token @@ -214,7 +214,13 @@ DID-linking for the new lab starts automatically in the background; [`getDidLink Three calls: get a presigned URL, `PUT` the bytes, finalise with metadata. Full reference: [Files](../labs-api/files.md). {% hint style="warning" %} -**This is the one step that can fail on a lab you just created.** `createLab` succeeding does not yet mean the lab is writable: it falls back to an onchain ownership check when the mint has not been indexed, while the file mutations read the indexed record and return `NOT_FOUND` until it lands. Wrap the first call in [`withIndexerLagRetry`](shared-setup.md) — without it this step fails outright on a fresh mint often enough to matter. Measured on staging: usually indexed within seconds, but one mint took **over four minutes**, which is why the helper retries for that long rather than giving up after a few seconds. +**This is the step that most often fails on a lab you have just created.** + +Molecule runs onchain and offchain systems side by side, and the offchain side learns about onchain events through an indexer. Keeping the two in step takes a moment, so a call that succeeded does not mean every read has caught up yet. + +That is exactly what happens here. `createLab` can succeed before your mint has been indexed, because it falls back to checking ownership onchain. The file mutations have no such fallback — they read the indexed record, and return `NOT_FOUND` until it arrives. + +So wrap the first call in [`withIndexerLagRetry`](shared-setup.md). On staging the record usually appears within seconds, but one mint took **over four minutes**, which is why the helper keeps retrying that long instead of giving up after a few seconds. {% endhint %} ```javascript diff --git a/api-reference/getting-started/for-agents.md b/api-reference/getting-started/for-agents.md index 8193a72..c0fc970 100644 --- a/api-reference/getting-started/for-agents.md +++ b/api-reference/getting-started/for-agents.md @@ -174,7 +174,7 @@ Full recipe including `accessControlConditions`: [Upload an encrypted file](uplo ## Related * [The three wallets](../authentication.md#the-three-wallets-side-by-side) — owner vs agent vs OCL account, and which field each address goes in -* [Getting Started](README.md) — prerequisites, costs, the lane you should be in +* [Getting Started](README.md) — how to interact with our products, prerequisites, costs * [Tutorials](README.md) — the same flow with responses and failure handling * [Labs API](../labs-api/README.md) — full operation reference * [Molecule Skill](../../ai-tooling/molecule-skill.md) — the same workflow as MCP tool calls diff --git a/api-reference/getting-started/upload-encrypted-file.md b/api-reference/getting-started/upload-encrypted-file.md index 675078c..84bf6e0 100644 --- a/api-reference/getting-started/upload-encrypted-file.md +++ b/api-reference/getting-started/upload-encrypted-file.md @@ -14,7 +14,7 @@ Same lab, same three-call upload — but the bytes are AES-256-GCM encrypted loc Conceptually: the backend hands you a one-shot data encryption key (DEK) in two forms — plaintext, and wrapped by the key custodian. You encrypt with the plaintext copy, throw it away, and store the wrapped copy in the file's metadata alongside the conditions under which the custodian may unwrap it again. Full model: [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md). {% hint style="info" %} -**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](shared-setup.md), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. +**Before you start:** you need the [two prerequisites](README.md#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](shared-setup.md), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. Unfamiliar with a term used here? See the [Glossary](../../references/glossary.md). {% endhint %} ## Step 4a: Get a DEK diff --git a/api-reference/x402-gateway.md b/api-reference/x402-gateway.md index a5ce7ce..b41f699 100644 --- a/api-reference/x402-gateway.md +++ b/api-reference/x402-gateway.md @@ -295,7 +295,7 @@ If you would rather not implement the handshake at all, the [Molecule Skill](../ ## Related -- [Getting Started](getting-started/README.md) — which lane to pick, prerequisites, costs +- [Getting Started](getting-started/README.md) — how to interact with our products, prerequisites, costs - [Molecule Skill](../ai-tooling/molecule-skill.md) — `x402_pay` does this handshake in one tool call - [Labs API](labs-api/README.md) — full mutation signatures and variable types - [Developers / AI Agents](../user-guides/developers-ai-agents.md) — agent integration guide diff --git a/references/glossary.md b/references/glossary.md new file mode 100644 index 0000000..3053f9e --- /dev/null +++ b/references/glossary.md @@ -0,0 +1,70 @@ +--- +description: >- + Every Molecule-specific term used in the API docs, defined in one or two + sentences, with a link to the page that goes deeper. +icon: book-open +--- + +# Glossary + +If a term in the tutorials is unfamiliar, it is defined here. Each entry is short on purpose — follow the link when you need the full picture. + +*** + +## The Lab + +**Lab** — the core object you build against. A Lab is an NFT that owns its own smart-contract wallet, so it can hold assets, store research files, and grant other people access to them. One research project, one Lab. Full model: [Molecule Labs](../technical-deep-dive/onchain-lab.md). + +**LabNFT** — the ERC-721 token that represents a Lab. Whoever holds it controls the Lab. Minting a LabNFT is the onchain step that brings a new Lab into existence; you do it once, before any API call can attach data to it. + +**Lab account (Token Bound Account, TBA)** — the smart-contract wallet permanently bound to the LabNFT (ERC-6551). It has no private key of its own: it takes its authority from whoever currently holds the NFT. Its address is the Lab's permanent identity, and it does not change when the NFT is sold or transferred. + +**OCL / `oclId`** — "onchain lab". `oclId` is the Lab's canonical identifier: a 32-byte hex string (`0x…`) emitted in the `OclIdentityCreated` event when the LabNFT is minted. Every Labs API call that targets a Lab takes this value. How it is derived: [Lab Management](../api-reference/labs-api/lab-management.md#how-oclid-is-derived). + +**`shortname`** — a URL slug derived server-side from the Lab's name, used in the Lab's public page address. It is `null` until the server has derived it, so a freshly minted Lab is reachable by `oclId` before it is reachable by slug. + +*** + +## Data + +**Data room** — a Lab's file store. Every file you upload lands in the Lab's data room at a `path` you choose, and the data room keeps every version of it rather than overwriting. + +**Kamu** — the data layer behind the data room. It records each file's version history, content hash, author and provenance. You never call Kamu directly; the Labs API does it for you. Details: [Data Storage](../technical-deep-dive/data/data-storage.md). + +**`accessLevel`** — whether a file is `PUBLIC` (stored as-is, readable by anyone) or confidential (encrypted before upload, readable only by wallets that pass its access conditions). + +**DEK (data encryption key)** — a fresh AES-256-GCM key generated per confidential file. You encrypt the file with it locally, and the API stores the key in wrapped form. It is released to you only when an onchain check confirms your wallet still qualifies. Details: [Data Privacy & Access](../technical-deep-dive/data/data-privacy-and-access.md). + +**Access control conditions** — the rules attached to a confidential file that decide who may decrypt it, evaluated against live onchain state at the moment of the request rather than at upload time. + +*** + +## Access and roles + +**Owner** — the wallet holding the LabNFT. Passes every permission check and is the only role that can grant Contributor. + +**Contributor** — an explicit onchain grant (`ROLE_CONTRIBUTOR = 2`). Can read and write the data room and grant Viewers, but cannot add other Contributors or transfer the NFT. This is the role an agent needs in order to upload. + +**Viewer** — an explicit onchain grant (`ROLE_VIEWER = 1`). Read-only, including decrypting confidential files. + +Role checks are hierarchical: a Contributor passes Viewer checks, and the Owner passes everything. Full matrix: [Roles & Permissions](../technical-deep-dive/roles-and-permissions.md). + +**AccessResolver** — the contract that holds those role grants and answers `hasRole`. Granting a role is an onchain transaction sent by the Lab owner. Reference: [AccessResolver](contracts/accessresolver.md). + +*** + +## Calling the API + +**Consumer credential** — the `mol__` string that identifies your integration. It goes in the `Authorization` header on every request, with **no `Bearer` prefix**, and it is issued per environment. This is the one credential you have to request from the Molecule team. + +**Service token** — proof that you control a particular wallet. You issue it yourself by signing a message with that wallet, then send it as the `X-Service-Token` header. What it lets you write is decided by that wallet's onchain role on the target Lab, not by the token itself. Reference: [Service Tokens](../api-reference/labs-api/service-tokens.md). + +**Privy user token** — the alternative to a service token, used when a human is signed in through the Molecule app rather than a script. It is the one credential that does use `Authorization: Bearer …`. Reference: [Authentication](../api-reference/authentication.md). + +**EOA (externally owned account)** — an ordinary wallet controlled by a private key, as opposed to a smart-contract wallet. This is what you sign with and what pays gas when you mint a LabNFT yourself. + +**x402** — a gateway that lets you pay per request in USDC on Base instead of holding a long-lived service token. The price comes back in the `402` response; read it from there rather than hardcoding it. Reference: [x402 Gateway](../api-reference/x402-gateway.md). + +**Indexer / indexer lag** — onchain events reach the API through an indexing service, which takes a moment to catch up. A transaction that has confirmed onchain is therefore not immediately visible to the API, which is why a write straight after a mint or a role grant can fail and should be retried rather than treated as a real error. + +**Staging vs production** — staging runs on Base Sepolia with testnet funds and nothing costs real money; production runs on Base mainnet. Credentials are per environment and are not interchangeable. diff --git a/user-guides/developers-ai-agents.md b/user-guides/developers-ai-agents.md index 615c8d0..d3e6a15 100644 --- a/user-guides/developers-ai-agents.md +++ b/user-guides/developers-ai-agents.md @@ -7,7 +7,7 @@ icon: robot # Developers/AI Agents -> **Want to start writing code now?** Go to [🚀 Getting Started](../api-reference/getting-started/README.md) — it picks your lane and gets you to a lab with a file in it in about ten minutes. This page is the narrative map of every integration surface, for when you need to decide *what* to build rather than *how* to make the first call. +> **Want to start writing code now?** Go to [🚀 Getting Started](../api-reference/getting-started/README.md) — it helps you pick a way in and gets you to a lab with a file in it in about ten minutes. This page is the narrative map of every integration surface, for when you need to decide *what* to build rather than *how* to make the first call. ### Who This Guide Is For From 4974fdaa590b03b2427db844fb0809560489e244 Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Thu, 3 Sep 2026 09:52:45 +0100 Subject: [PATCH 14/16] chore(docs): make glossary more reachable --- .github/prompts/docs-sync.md | 6 +++--- SUMMARY.md | 4 ++-- ai-tooling/molecule-skill.md | 1 + api-reference/README.md | 3 ++- api-reference/authentication.md | 12 ++++++------ api-reference/changelog.md | 2 +- api-reference/getting-started/README.md | 6 +++--- .../getting-started/agent-as-a-lab-contributor.md | 10 +++++----- .../getting-started/create-lab-and-upload-file.md | 4 ++-- api-reference/getting-started/for-agents.md | 3 ++- api-reference/getting-started/shared-setup.md | 7 ++++--- .../getting-started/upload-encrypted-file.md | 10 +++++----- api-reference/labs-api/README.md | 5 +++-- api-reference/labs-api/files.md | 2 +- api-reference/labs-api/lab-management.md | 4 ++-- api-reference/labs-api/service-tokens.md | 2 +- api-reference/x402-gateway.md | 1 + user-guides/developers-ai-agents.md | 4 ++-- 18 files changed, 46 insertions(+), 40 deletions(-) diff --git a/.github/prompts/docs-sync.md b/.github/prompts/docs-sync.md index a11e73f..489c145 100644 --- a/.github/prompts/docs-sync.md +++ b/.github/prompts/docs-sync.md @@ -55,10 +55,10 @@ touches one of its source paths. | `api-reference/authentication.md` | `lambda/appsync-authorizer-lambda/**`, service-token resolvers in `lambda/appsync-resolver-labs-lambda/**` (`services/token-manager-service.ts`, `utils/service-auth-message.ts`), `docs/service-auth.md`. The header reference and the sign-in flow both live here: a change to the sign-in message, its nonce or its validity window touches this page **and** `labs-api/service-tokens.md` **and** the tutorials' Step 1 — fix all of them or none. | | `api-reference/getting-started/README.md` | The onboarding entry point. `lib/desci-api-app-sync/constructs/api-config.ts` (endpoints, introspection, depth limit), `lambda/x402-gateway-lambda/**` (prices/base URLs quoted in the cost table), `lambda/appsync-authorizer-lambda/**` (credential shape). **Contains verified live values** — `mintFeeWei()` readings, x402 prices, gateway URLs — each stamped with the date it was checked. Never update one of those from a diff alone: if the diff suggests a value changed, flag it in the PR body and let a human re-verify against the deployed environment. | | `api-reference/getting-started/shared-setup.md` | The config constants and helpers every tutorial's snippets assume: staging endpoint, chain, `FACTORY_ADDRESS` / `LABNFT_ADDRESS` / `ACCESS_RESOLVER_ADDRESS`, plus `graphql()`, `parseDetails()`, `assertOk()` and `withIndexerLagRetry()`. Sources: `lib/desci-api-app-sync/constructs/api-config.ts` (endpoints), `graphql/schemas/api-error.graphql` and the in-band `ApiError` shape (`parseDetails`/`assertOk`), `lambda/appsync-authorizer-lambda/**` (header names). Contract addresses are deployment values — flag a suspected change for human verification rather than editing from a diff. **This block is duplicated inline in each tutorial's complete script and in the production swap table on `getting-started/README.md`: fix all copies or none.** | -| `api-reference/getting-started/for-agents.md` | Condensed mirror of Create a lab and upload a file plus the error contract. Sources: `graphql/schemas/ip-hubs.graphql`, `graphql/schemas/api-error.graphql`, `graphql/schemas/encryption.graphql`, `lambda/appsync-resolver-labs-lambda/**` (per-mutation role gates). **Must stay consistent with the tutorial pages** — a change to the flow touches this page and the relevant tutorial, or neither. | -| `api-reference/getting-started/create-lab-and-upload-file.md` | Create a lab and upload a file — self-issue a token, mint, `createLab`, three-call public upload, verify. Sources: `graphql/schemas/ip-hubs.graphql`; `lambda/appsync-resolver-labs-lambda/**` for `generateServiceToken`, `createLab`, `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile` and their authorization gates; `services/token-manager-service.ts`; `lib/utils/token-expiration.ts` (the `expiresIn` default and bounds). Every code block is expected to be runnable — a signature change here is a real breakage, so correct it and say so in the PR body. Steps 1–3 are duplicated by reference in Upload an encrypted file and Agent as a lab contributor: **fix all copies or none.** | +| `api-reference/getting-started/for-agents.md` | Condensed mirror of Create a lab and upload a public file plus the error contract. Sources: `graphql/schemas/ip-hubs.graphql`, `graphql/schemas/api-error.graphql`, `graphql/schemas/encryption.graphql`, `lambda/appsync-resolver-labs-lambda/**` (per-mutation role gates). **Must stay consistent with the tutorial pages** — a change to the flow touches this page and the relevant tutorial, or neither. | +| `api-reference/getting-started/create-lab-and-upload-file.md` | Create a lab and upload a public file — self-issue a token, mint, `createLab`, three-call public upload, verify. Sources: `graphql/schemas/ip-hubs.graphql`; `lambda/appsync-resolver-labs-lambda/**` for `generateServiceToken`, `createLab`, `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile` and their authorization gates; `services/token-manager-service.ts`; `lib/utils/token-expiration.ts` (the `expiresIn` default and bounds). Every code block is expected to be runnable — a signature change here is a real breakage, so correct it and say so in the PR body. Steps 1–3 are duplicated by reference in Upload an encrypted file and Agent access: **fix all copies or none.** | | `api-reference/getting-started/upload-encrypted-file.md` | Upload an encrypted file — DEK, local AES-256-GCM, `accessControlConditions`, `encryptionMetadata`, `decryptDataKey` round trip. Sources: `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts`, the encryption resolvers and condition evaluator in `lambda/appsync-resolver-labs-lambda/**`, `lib/encryption-stack.ts`. `encryptionSystem` is backend-set and must stay "echo it verbatim" — never let a literal be hardcoded here. | -| `api-reference/getting-started/agent-as-a-lab-contributor.md` | Agent as a lab contributor — a Contributor-role agent writing into a lab a human owns. Sources: the per-mutation service-token gates in `lambda/appsync-resolver-labs-lambda/**` (`authorizeServiceMember` = Contributor, `authorizeServiceAdmin` = Owner) and `services/auth-service.ts`. **If a content-write mutation moves between those two gates, this page's role claims and its Owner-only list are wrong** — that is the single highest-value thing to check here. Role grants themselves are onchain (`AccessResolver`), out of this repo. | +| `api-reference/getting-started/agent-as-a-lab-contributor.md` | Agent access — a Contributor-role agent writing into a lab a human owns. Sources: the per-mutation service-token gates in `lambda/appsync-resolver-labs-lambda/**` (`authorizeServiceMember` = Contributor, `authorizeServiceAdmin` = Owner) and `services/auth-service.ts`. **If a content-write mutation moves between those two gates, this page's role claims and its Owner-only list are wrong** — that is the single highest-value thing to check here. Role grants themselves are onchain (`AccessResolver`), out of this repo. | | `references/glossary.md` | Plain-language definitions of every Molecule term used in the API docs (Lab, LabNFT, `oclId`, data room, Kamu, roles, service token, consumer credential, x402, indexer). Definitions are summarised from `technical-deep-dive/**` and the Labs API pages rather than from source directly — if a diff changes what one of those terms *means* (a renamed identifier, a changed role name, a new credential type), update the entry here as well as the page it came from. Never let this page and the deep-dive pages disagree. | | `api-reference/labs-api/README.md` | `graphql/schemas/ip-hubs.graphql`, `lambda/appsync-resolver-labs-lambda/**` | | `api-reference/labs-api/lab-management.md` | `createLab`, `updateLabNftMetadata`, `generateLabImageUploadUrl` in `lambda/appsync-resolver-labs-lambda/**`; `lambda/labnft-metadata-lambda/**`; `lambda/ocl-processor/**` | diff --git a/SUMMARY.md b/SUMMARY.md index 1810a4f..4f8dc29 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -35,9 +35,9 @@ * [📡 API Overview](api-reference/README.md) * [🚀 Getting Started](api-reference/getting-started/README.md) * [Shared Setup](api-reference/getting-started/shared-setup.md) - * [Create a lab and upload a file](api-reference/getting-started/create-lab-and-upload-file.md) + * [Create a lab and upload a public file](api-reference/getting-started/create-lab-and-upload-file.md) * [Upload an encrypted file](api-reference/getting-started/upload-encrypted-file.md) - * [Agent as a lab contributor](api-reference/getting-started/agent-as-a-lab-contributor.md) + * [Agent access](api-reference/getting-started/agent-as-a-lab-contributor.md) * [🤖 For Agents: One-Pager](api-reference/getting-started/for-agents.md) * [🔐 Authentication](api-reference/authentication.md) * [⚙️ Labs API](api-reference/labs-api/README.md) diff --git a/ai-tooling/molecule-skill.md b/ai-tooling/molecule-skill.md index b99ecdf..ca900fe 100644 --- a/ai-tooling/molecule-skill.md +++ b/ai-tooling/molecule-skill.md @@ -250,6 +250,7 @@ This lists every tool and exercises the pure-compute ones (encryption round-trip ### Related Pages * [Getting Started](../api-reference/getting-started/README.md) — the ways in, prerequisites and costs; this plugin is the one for AI coding agents +* [Glossary](../references/glossary.md) — every Molecule term these docs use, defined in a sentence * [Tutorials](../api-reference/getting-started/README.md) — the same workflow as raw GraphQL, if you want to see the calls underneath * [Molecule Labs](../technical-deep-dive/onchain-lab.md) — what an Onchain Lab is * [Roles & Permissions](../technical-deep-dive/roles-and-permissions.md) — the role model used by access conditions diff --git a/api-reference/README.md b/api-reference/README.md index f914aae..a5fb3fe 100644 --- a/api-reference/README.md +++ b/api-reference/README.md @@ -122,7 +122,7 @@ The full quickstart — prerequisites, costs, and a ten-minute path to a lab wit | Get from zero to a lab with a file in it | [Getting Started](getting-started/README.md) | | Point an AI agent at this API | [Agent one-pager](getting-started/for-agents.md) · [Molecule Skill](../ai-tooling/molecule-skill.md) | | Upload files to a Lab dataroom | [Labs API](labs-api/README.md) · [Tutorials](getting-started/README.md) | -| Let an agent write into a lab someone else owns | [Agent as a lab contributor](getting-started/agent-as-a-lab-contributor.md) | +| Let an agent write into a lab someone else owns | [Agent access](getting-started/agent-as-a-lab-contributor.md) | | Tokenize a Lab into IP Tokens (IPTs) | [Tokenization API](tokenization-api.md) | | Pay per call without a long-lived token | [x402 Gateway](x402-gateway.md) | | Browse IP-NFTs and IPTs (legacy) | [IPNFT API (Deprecated)](ipnft-api-deprecated.md) | @@ -155,6 +155,7 @@ If you encounter any issues or have questions about the APIs: ## Additional Resources +* [Glossary](../references/glossary.md) — every Molecule term used in these docs, defined in a sentence * [Getting Started](getting-started/README.md) — prerequisites, costs, ten-minute quickstart * [Agent one-pager](getting-started/for-agents.md) — the default flow, paste-ready * [Getting the schema](getting-started/README.md#getting-the-schema) — staging introspection is enabled; production's is not diff --git a/api-reference/authentication.md b/api-reference/authentication.md index 08548b5..75d84a7 100644 --- a/api-reference/authentication.md +++ b/api-reference/authentication.md @@ -2,7 +2,7 @@ All Molecule APIs require authentication. This page covers how to obtain credentials, which headers each API expects, and the specific authentication model for the Labs API. -There are two credentials, and they do different jobs: +There are two credentials, and they do different jobs. If any term on this page is unfamiliar, the [Glossary](../references/glossary.md) defines it in a sentence. | Credential | Answers | How you get it | | ---------- | ------- | -------------- | @@ -29,7 +29,7 @@ mol__ Send it as the `Authorization` header value directly — **no `Bearer` prefix**: `Authorization: mol__`. This differs from the Privy path below, which does use `Bearer`; adding `Bearer` in front of a consumer credential makes the request fail authentication. Treat the entire string as a secret — it is not split into a public/private part. Credentials are per environment: a staging credential does not authenticate against production. -You do **not** need to ask anyone for a Service Token. Write mutations need one, and you mint it yourself by signing a message with your wallet — see [Service Tokens](labs-api/service-tokens.md#obtaining-a-token), or [Step 1 of Create a lab and upload a file](getting-started/create-lab-and-upload-file.md#step-1-get-a-service-token) for the runnable version. +You do **not** need to ask anyone for a Service Token. Write mutations need one, and you mint it yourself by signing a message with your wallet — see [Service Tokens](labs-api/service-tokens.md#obtaining-a-token), or [Step 1 of Create a lab and upload a public file](getting-started/create-lab-and-upload-file.md#step-1-get-a-service-token) for the runnable version. ## Authentication Headers @@ -136,7 +136,7 @@ Both are scoped to the caller's **own** tokens: the token presented must own the ### Obtaining a Service Token -Self-service, two calls, no human in the loop. Full reference with parameters and failure modes: [Service Tokens](labs-api/service-tokens.md#obtaining-a-token). Runnable: [Step 1 of Create a lab and upload a file](getting-started/create-lab-and-upload-file.md#step-1-get-a-service-token). +Self-service, two calls, no human in the loop. Full reference with parameters and failure modes: [Service Tokens](labs-api/service-tokens.md#obtaining-a-token). Runnable: [Step 1 of Create a lab and upload a public file](getting-started/create-lab-and-upload-file.md#step-1-get-a-service-token). 1. **`getServiceSignInMessage(walletAddress, serviceName)`** — a public query returning the message to sign, plus the `expiresAt` of the nonce embedded in it. 2. **Sign it verbatim** with the wallet, as a plain personal message (EIP-191 `personal_sign` — **not** typed data). Re-wording or re-formatting the string breaks verification. @@ -159,13 +159,13 @@ Issuance is **not** gated on holding a role on any lab — any wallet can mint a A Service Token is **wallet-bound, not lab-bound.** It says "this wallet is calling"; it does not carry a list of labs. -On every request, the API resolves what the token's wallet may do on the lab named in the call from that wallet's **live onchain role**. Three consequences worth internalising: +On every request, the API resolves what the token's wallet may do on the lab named in the call from that wallet's **live onchain role**. This has three practical consequences: * **One token works across every lab the wallet has a role on.** You do not issue a token per lab. * **A role granted after the token was issued takes effect without re-issuing it.** Likewise a revoked role stops the token on that lab immediately, while leaving it valid elsewhere. * **A token for a wallet with no role authenticates but cannot write.** You will see `UNAUTHENTICATED` become `UNAUTHORIZED`: the caller is known, just not permitted. -This is why an agent can be handed access to a lab it does not own — the human grants the agent's wallet a role, and the agent's own token starts working on that lab. See [Agent as a lab contributor](getting-started/agent-as-a-lab-contributor.md). +This is why an agent can be handed access to a lab it does not own — the human grants the agent's wallet a role, and the agent's own token starts working on that lab. See [Agent access](getting-started/agent-as-a-lab-contributor.md). > Because role state reaches the API through an event indexer, there is a short window after a role grant confirms onchain in which a write can still return `UNAUTHORIZED`. Retry with backoff; re-issuing the token does not help. @@ -185,7 +185,7 @@ A working integration has up to three addresses in play at once, and they are no Two failure modes this prevents: * **Passing the owner's address where the OCL account belongs** in `accessControlConditions`. Condition evaluation **fails closed**, so the file uploads fine and then nobody can decrypt it — with no error saying why. The `account` argument wants `labAccountAddress`; `:userAddress` is substituted with the caller's wallet automatically. -* **Expecting the agent to inherit the human's reach.** The agent authenticates as itself, so its permissions come from its own grant. That is the point — the human never hands over a key — and it is why a few Owner-only mutations stay out of reach. Walkthrough: [Agent as a lab contributor](getting-started/agent-as-a-lab-contributor.md). +* **Expecting the agent to inherit the human's reach.** The agent authenticates as itself, so its permissions come from its own grant. That is the point — the human never hands over a key — and it is why a few Owner-only mutations stay out of reach. Walkthrough: [Agent access](getting-started/agent-as-a-lab-contributor.md). #### `oclId` is not a wallet address diff --git a/api-reference/changelog.md b/api-reference/changelog.md index d6b4004..a124952 100644 --- a/api-reference/changelog.md +++ b/api-reference/changelog.md @@ -39,7 +39,7 @@ The five content-write mutations — `initiateCreateOrUpdateFile`, `finishCreate This is what unblocks the "human owns the lab, agent contributes to it" flow: the owner grants the agent's wallet Contributor, and the agent's self-issued token can write. Owner-gated surfaces are unchanged — `createLab`, `updateLabNftMetadata` and `generateLabImageUploadUrl` still require ownership. -**Migration:** None — this is a widening. Note that role state reaches the API through an event indexer, so a write can still return `UNAUTHORIZED` for a few seconds after a grant confirms onchain; retry with backoff rather than re-issuing the token. Walkthrough: [Agent as a lab contributor](getting-started/agent-as-a-lab-contributor.md). +**Migration:** None — this is a widening. Note that role state reaches the API through an event indexer, so a write can still return `UNAUTHORIZED` for a few seconds after a grant confirms onchain; retry with backoff rather than re-issuing the token. Walkthrough: [Agent access](getting-started/agent-as-a-lab-contributor.md). ### Backend credential stores confined to the platform network diff --git a/api-reference/getting-started/README.md b/api-reference/getting-started/README.md index e1020b6..8b53af8 100644 --- a/api-reference/getting-started/README.md +++ b/api-reference/getting-started/README.md @@ -43,9 +43,9 @@ Each one is runnable end to end against staging, and shows the expected response | Tutorial | What you have when you finish | | -------- | ----------------------------- | -| [**Create a lab and upload a file**](create-lab-and-upload-file.md) — start here | A Lab of your own, with a public file in it | +| [**Create a lab and upload a public file**](create-lab-and-upload-file.md) — start here | A Lab of your own, with a public file in it | | [**Upload an encrypted file**](upload-encrypted-file.md) | A confidential file that only certain people that you specify can decrypt | -| [**Agent as a lab contributor**](agent-as-a-lab-contributor.md) | An agent writing into a Lab that a human owns | +| [**Agent access**](agent-as-a-lab-contributor.md) | An agent writing into a Lab that a human owns | All three open with the same configuration constants and helper functions, which live on one page: [**Shared Setup**](shared-setup.md). Copy that block once and every snippet in the tutorials runs against it. @@ -122,7 +122,7 @@ claude --plugin-dir /path/to/mol-labs-plugin ## Ten-minute quickstart -The shortest path from "I have a credential" to "there is a lab with my file in it". Four API calls and one transaction. Each step below is the condensed form of [Create a lab and upload a file](create-lab-and-upload-file.md), which shows the expected response and the failure modes for every call. +The shortest path from "I have a credential" to "there is a lab with my file in it". Four API calls and one transaction. Each step below is the condensed form of [Create a lab and upload a public file](create-lab-and-upload-file.md), which shows the expected response and the failure modes for every call. ```bash export CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" diff --git a/api-reference/getting-started/agent-as-a-lab-contributor.md b/api-reference/getting-started/agent-as-a-lab-contributor.md index 8fdb982..2633306 100644 --- a/api-reference/getting-started/agent-as-a-lab-contributor.md +++ b/api-reference/getting-started/agent-as-a-lab-contributor.md @@ -5,7 +5,7 @@ description: >- icon: robot --- -# Agent as a lab contributor +# Agent access The most common real-world shape: a researcher created their Lab in the Labs app with an email address — no wallet, no code — and now wants an agent contributing to it. The agent gets its **own** identity rather than borrowing the human's; the human grants it a role; the agent authenticates itself from then on. @@ -80,7 +80,7 @@ Expect `role: "CONTRIBUTOR"`. `isAgent` simply echoes the flag the owner set — ## Step 3: The agent self-issues a service token -Identical to [Step 1 of Create a lab and upload a file](create-lab-and-upload-file.md#step-1-get-a-service-token), signed by the **agent's** wallet. Fetch the message and redeem it in one go — it embeds a single-use nonce valid for 10 minutes, so an agent that waits for the human's role grant between fetching and signing will hit `UNAUTHENTICATED` / `reason: NONCE_EXPIRED`. Poll for the grant first (Step 2), then sign in: +Identical to [Step 1 of Create a lab and upload a public file](create-lab-and-upload-file.md#step-1-get-a-service-token), signed by the **agent's** wallet. Fetch the message and redeem it in one go — it embeds a single-use nonce valid for 10 minutes, so an agent that waits for the human's role grant between fetching and signing will hit `UNAUTHENTICATED` / `reason: NONCE_EXPIRED`. Poll for the grant first (Step 2), then sign in: ```javascript const AGENT_SERVICE_NAME = "research-agent-1"; @@ -128,7 +128,7 @@ Issuance is **not** gated on holding a role — any wallet can mint a token for ## Step 4: The agent uploads -From here the agent is an ordinary caller. Run [Step 4 of Create a lab and upload a file](create-lab-and-upload-file.md#step-4-upload-the-file) with `changeBy: agentAccount.address`, or [Upload an encrypted file](upload-encrypted-file.md) for a confidential file — the `hasRole` branch of the team conditions is exactly what lets the agent decrypt too. +From here the agent is an ordinary caller. Run [Step 4 of Create a lab and upload a public file](create-lab-and-upload-file.md#step-4-upload-the-file) with `changeBy: agentAccount.address`, or [Upload an encrypted file](upload-encrypted-file.md) for a confidential file — the `hasRole` branch of the team conditions is exactly what lets the agent decrypt too. Writes by a Contributor service token are gated per mutation, matching the Privy user path: `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata` and `moveEntry` all accept Contributor. A few surfaces remain **Owner-only** and an agent Contributor cannot reach them: `updateLabNftMetadata`, `generateLabImageUploadUrl` and the legal-agreement mutations. @@ -160,7 +160,7 @@ await withIndexerLagRetry(() => uploadFile(oclId, "./findings.csv"), { codes: [" ## Step 5: Verify from both sides -**The agent** verifies as in [Step 5 of Create a lab and upload a file](create-lab-and-upload-file.md#step-5-verify-it-worked) — the file is in `dataRoom.files` with `createdBy` set to the agent's address: +**The agent** verifies as in [Step 5 of Create a lab and upload a public file](create-lab-and-upload-file.md#step-5-verify-it-worked) — the file is in `dataRoom.files` with `createdBy` set to the agent's address: ```javascript const verify = await graphql( @@ -429,6 +429,6 @@ node agent-as-a-lab-contributor.js ./findings.csv | | | | --- | --- | -| What the agent uploads | [Create a lab and upload a file](create-lab-and-upload-file.md) · [Upload an encrypted file](upload-encrypted-file.md) | +| What the agent uploads | [Create a lab and upload a public file](create-lab-and-upload-file.md) · [Upload an encrypted file](upload-encrypted-file.md) | | The role model in full | [Roles & Permissions](../../technical-deep-dive/roles-and-permissions.md) | | Let the agent run the whole workflow as tool calls | [Molecule Skill](../../ai-tooling/molecule-skill.md) | diff --git a/api-reference/getting-started/create-lab-and-upload-file.md b/api-reference/getting-started/create-lab-and-upload-file.md index 627d229..22de959 100644 --- a/api-reference/getting-started/create-lab-and-upload-file.md +++ b/api-reference/getting-started/create-lab-and-upload-file.md @@ -349,7 +349,7 @@ Keep `datasetId` — it is the file's stable identifier for later reads and upda | Symptom | Cause | Fix | | ------- | ----- | --- | | `initiate` → `NOT_FOUND`, "Project 0x… does not exist" | The mint is not indexed yet. `createLab` can succeed before this is true, so a successful Step 3 is no guarantee | Retry with backoff — `withIndexerLagRetry` above. Usually seconds; observed up to ~4 minutes under indexer backlog. Do **not** re-run `createLab`, which returns `CONFLICT` once registered | -| `initiate` → `UNAUTHORIZED` | The wallet behind the token has no write role on this lab | You must be Owner or Contributor. See [Agent as a lab contributor](agent-as-a-lab-contributor.md) | +| `initiate` → `UNAUTHORIZED` | The wallet behind the token has no write role on this lab | You must be Owner or Contributor. See [Agent access](agent-as-a-lab-contributor.md) | | `PUT` → `403` | URL expired (~15 min), or headers altered | Re-run `initiate`; send the returned `headers` verbatim | | `PUT` → `400`/`411` | Body wasn't sent as raw bytes | Send the buffer, not a JSON wrapper. In curl: `--data-binary` | | `finish` → `VALIDATION_FAILED`, `details.field: "path"` | `path` contains an underscore, or both `path` and `ref` were sent | Underscores are not allowed in `path`; use `path` for a new file **or** `ref` for a new version, never both | @@ -638,6 +638,6 @@ node create-lab-and-upload-file.js ./research-data.csv | | | | --- | --- | | Make the next file confidential | [Upload an encrypted file](upload-encrypted-file.md) | -| Let an agent write into this lab | [Agent as a lab contributor](agent-as-a-lab-contributor.md) | +| Let an agent write into this lab | [Agent access](agent-as-a-lab-contributor.md) | | Run it against mainnet | [Running in Production](README.md#running-in-production) | | Per-operation reference | [Files](../labs-api/files.md) · [Lab Management](../labs-api/lab-management.md) | diff --git a/api-reference/getting-started/for-agents.md b/api-reference/getting-started/for-agents.md index c0fc970..da33658 100644 --- a/api-reference/getting-started/for-agents.md +++ b/api-reference/getting-started/for-agents.md @@ -166,7 +166,7 @@ Full recipe including `accessControlConditions`: [Upload an encrypted file](uplo 4. Read `error.details` with the tolerant `parseDetails` above — never a bare `JSON.parse`. In-band it is a JSON string (currently doubly encoded); on thrown query errors it is already an object. 5. Filter mint receipt logs to the **LabNFT** address before decoding `OclIdentityCreated`. 6. Send the presigned `PUT` with the returned headers unchanged, and the raw bytes as the body. -7. Writing into a lab you do not own needs a **Contributor** role on it — see [Agent as a lab contributor](agent-as-a-lab-contributor.md). After a role grant, an indexer lag of a few seconds can still return `UNAUTHORIZED`; retry with backoff. +7. Writing into a lab you do not own needs a **Contributor** role on it — see [Agent access](agent-as-a-lab-contributor.md). After a role grant, an indexer lag of a few seconds can still return `UNAUTHORIZED`; retry with backoff. 8. **A successful `createLab` does not mean the lab is writable yet.** Step 4's first call can return `NOT_FOUND` ("Project 0x… does not exist") for a few seconds, because `createLab` falls back to an onchain ownership check while the file mutations read the indexed record. Retry `NOT_FOUND` with backoff on the first write after a mint; do not re-run `createLab`, which then returns `CONFLICT`. 9. **Three addresses, not interchangeable**: your own wallet (`walletAddress`, `changeBy`), the human owner's (`x-wallet-address`, their path only), and the Lab's OCL account (`labAccountAddress`, and the `account` argument in access conditions). Putting an owner's address where `labAccountAddress` belongs uploads fine and then locks everyone out of the file, with no error saying why. `oclId` is none of them — it is a lab id whose trailing 40 hex chars happen to be the OCL account address. See [the three wallets](../authentication.md#the-three-wallets-side-by-side). 10. Production has introspection off and a depth limit of 10. Generate types against staging. @@ -175,6 +175,7 @@ Full recipe including `accessControlConditions`: [Upload an encrypted file](uplo * [The three wallets](../authentication.md#the-three-wallets-side-by-side) — owner vs agent vs OCL account, and which field each address goes in * [Getting Started](README.md) — how to interact with our products, prerequisites, costs +* [Glossary](../../references/glossary.md) — every term used here, defined in a sentence * [Tutorials](README.md) — the same flow with responses and failure handling * [Labs API](../labs-api/README.md) — full operation reference * [Molecule Skill](../../ai-tooling/molecule-skill.md) — the same workflow as MCP tool calls diff --git a/api-reference/getting-started/shared-setup.md b/api-reference/getting-started/shared-setup.md index 64a0ca2..9243547 100644 --- a/api-reference/getting-started/shared-setup.md +++ b/api-reference/getting-started/shared-setup.md @@ -120,7 +120,7 @@ async function withIndexerLagRetry( | `graphql(query, variables)` | POSTs to the API with `Authorization` always set and `X-Service-Token` added once Step 1 has issued one | Every GraphQL call | | `parseDetails(details)` | Reads `error.details` tolerantly — it arrives as an object, a JSON string, or a doubly-encoded JSON string | Inside `assertOk`; also useful when you branch on `details.reason` yourself | | `assertOk(result, op)` | Turns an in-band mutation `error` into a thrown error carrying the catalogue `code` and the `requestId` | After every mutation | -| `withIndexerLagRetry(fn)` | Retries a call that failed only because onchain state has not been indexed yet | [Step 4 of Create a lab and upload a file](create-lab-and-upload-file.md#step-4-upload-the-file) (after a mint) and [Step 4 of Agent as a lab contributor](agent-as-a-lab-contributor.md#step-4-the-agent-uploads) (after a role grant) | +| `withIndexerLagRetry(fn)` | Retries a call that failed only because onchain state has not been indexed yet | [Step 4 of Create a lab and upload a public file](create-lab-and-upload-file.md#step-4-upload-the-file) (after a mint) and [Step 4 of Agent access](agent-as-a-lab-contributor.md#step-4-the-agent-uploads) (after a role grant) | *** @@ -129,7 +129,8 @@ async function withIndexerLagRetry( | Next | Page | | ---- | ---- | | Prerequisites, costs and the ten-minute quickstart | [Getting Started](README.md) | -| Create a lab and upload a public file | [Create a lab and upload a file](create-lab-and-upload-file.md) | +| What every term used here means | [Glossary](../../references/glossary.md) | +| Create a lab and upload a public file | [Create a lab and upload a public file](create-lab-and-upload-file.md) | | Upload an encrypted file | [Upload an encrypted file](upload-encrypted-file.md) | -| Give your agent access to a lab | [Agent as a lab contributor](agent-as-a-lab-contributor.md) | +| Give your agent access to a lab | [Agent access](agent-as-a-lab-contributor.md) | | Run the same code against mainnet | [Running in Production](README.md#running-in-production) | diff --git a/api-reference/getting-started/upload-encrypted-file.md b/api-reference/getting-started/upload-encrypted-file.md index 84bf6e0..cea9878 100644 --- a/api-reference/getting-started/upload-encrypted-file.md +++ b/api-reference/getting-started/upload-encrypted-file.md @@ -9,7 +9,7 @@ icon: lock Same lab, same three-call upload — but the bytes are AES-256-GCM encrypted locally before they leave your machine, and decryption is gated on live onchain state. Encryption is a first-class flow, not an appendix: reach for it whenever the file is confidential and access should follow the lab's roles. -**Steps 1–3 are identical to [Create a lab and upload a file](create-lab-and-upload-file.md)** — get a service token, mint, register. Pick up here with `oclId`, `labAccountAddress`, `account` and `serviceToken` already in hand (or with any lab you already hold a role on). +**Steps 1–3 are identical to [Create a lab and upload a public file](create-lab-and-upload-file.md)** — get a service token, mint, register. Pick up here with `oclId`, `labAccountAddress`, `account` and `serviceToken` already in hand (or with any lab you already hold a role on). Conceptually: the backend hands you a one-shot data encryption key (DEK) in two forms — plaintext, and wrapped by the key custodian. You encrypt with the plaintext copy, throw it away, and store the wrapped copy in the file's metadata alongside the conditions under which the custodian may unwrap it again. Full model: [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md). @@ -109,7 +109,7 @@ const ownerOnlyConditions = JSON.stringify([ ]); ``` -**Owner or Contributor or Viewer** — the recipe to use when the lab has a team, and the one the contributor agent in [Agent as a lab contributor](agent-as-a-lab-contributor.md) needs. `hasRole` is hierarchical (`ROLE_VIEWER = 1`; a Contributor and the Owner both pass a Viewer check), and the explicit `isAuthorizedSignerForTba` branch keeps the Owner covered even if conditions are ever evaluated against a non-canonical chain: +**Owner or Contributor or Viewer** — the recipe to use when the lab has a team, and the one the contributor agent in [Agent access](agent-as-a-lab-contributor.md) needs. `hasRole` is hierarchical (`ROLE_VIEWER = 1`; a Contributor and the Owner both pass a Viewer check), and the explicit `isAuthorizedSignerForTba` branch keeps the Owner covered even if conditions are ever evaluated against a non-canonical chain: ```javascript const accessResolverAbi = { @@ -239,7 +239,7 @@ assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); | `VALIDATION_FAILED`, `reason: INVALID_ACCESS_LEVEL` | `PUBLIC` on an encrypted file | Use `HOLDERS` or `ADMIN` | | `UNAUTHORIZED` on `generateDataEncryptionKey` | No write role on the lab | Owner or Contributor required | | `UPSTREAM_UNAVAILABLE`, "Path is occupied" on `finish` | A file already exists at that `path` — usually a re-run against the same lab | **Not retryable despite the code.** Pick a new `path`, or send `ref` (the previous `datasetId`) instead to add a version | -| `NOT_FOUND` on the first call after a mint | The mint is not indexed yet, even though `createLab` succeeded | Retry with [`withIndexerLagRetry`](shared-setup.md) — see [Step 4 of Create a lab and upload a file](create-lab-and-upload-file.md#step-4-upload-the-file) | +| `NOT_FOUND` on the first call after a mint | The mint is not indexed yet, even though `createLab` succeeded | Retry with [`withIndexerLagRetry`](shared-setup.md) — see [Step 4 of Create a lab and upload a public file](create-lab-and-upload-file.md#step-4-upload-the-file) | ## Step 5: Verify by decrypting it @@ -311,7 +311,7 @@ console.log("Round trip verified —", recovered.length, "bytes recovered"); ## Complete script -Steps 1–3 are verbatim from [Create a lab and upload a file](create-lab-and-upload-file.md); this script carries them so it runs standalone. +Steps 1–3 are verbatim from [Create a lab and upload a public file](create-lab-and-upload-file.md); this script carries them so it runs standalone. ```javascript #!/usr/bin/env node @@ -663,6 +663,6 @@ node upload-encrypted-file.js ./confidential-results.csv | | | | --- | --- | -| Let an agent decrypt and contribute too | [Agent as a lab contributor](agent-as-a-lab-contributor.md) | +| Let an agent decrypt and contribute too | [Agent access](agent-as-a-lab-contributor.md) | | Run it against mainnet | [Running in Production](README.md#running-in-production) | | How conditions are evaluated, in depth | [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md) | diff --git a/api-reference/labs-api/README.md b/api-reference/labs-api/README.md index 675c720..0832ffe 100644 --- a/api-reference/labs-api/README.md +++ b/api-reference/labs-api/README.md @@ -21,7 +21,8 @@ The Labs API allows developers to interact with Molecule Labs datarooms without | | | | --- | --- | | **First time here** | [🚀 Getting Started](../getting-started/README.md) — prerequisites, costs, ten-minute quickstart | -| **You want runnable code** | [Create a lab and upload a file](../getting-started/create-lab-and-upload-file.md) · [Upload an encrypted file](../getting-started/upload-encrypted-file.md) · [Agent as a lab contributor](../getting-started/agent-as-a-lab-contributor.md) | +| **A term here is unfamiliar** | [Glossary](../../references/glossary.md) — Lab, `oclId`, data room, service token, indexer | +| **You want runnable code** | [Create a lab and upload a public file](../getting-started/create-lab-and-upload-file.md) · [Upload an encrypted file](../getting-started/upload-encrypted-file.md) · [Agent access](../getting-started/agent-as-a-lab-contributor.md) | | **You're an AI agent** | [Agent one-pager](../getting-started/for-agents.md), or drive this API through the [Molecule Skill](../../ai-tooling/molecule-skill.md) plugin | | **You want to pay per call** | [x402 Gateway](../x402-gateway.md) | @@ -163,7 +164,7 @@ A missing or malformed consumer credential is rejected before the GraphQL layer **`UNAUTHORIZED`** — the wallet behind the service token lacks the required role on the lab: - Check the wallet's role with the public `listLabMembers(oclId)` query. Content writes (uploads, metadata, moves, deletes) need **Contributor**; `createLab` and the LabNFT-metadata mutations need **Owner** -- Not the right role? The lab owner grants one onchain — see [Agent as a lab contributor](../getting-started/agent-as-a-lab-contributor.md) +- Not the right role? The lab owner grants one onchain — see [Agent access](../getting-started/agent-as-a-lab-contributor.md) - **Just granted the role?** Role state reaches the API through an event indexer, so a write can still return `UNAUTHORIZED` for a few seconds after the grant confirms onchain. Retry with backoff; re-issuing the token does not help **Upload to presigned URL fails:** diff --git a/api-reference/labs-api/files.md b/api-reference/labs-api/files.md index 6849efd..b690713 100644 --- a/api-reference/labs-api/files.md +++ b/api-reference/labs-api/files.md @@ -2,7 +2,7 @@ Working with files in a Lab dataroom: the three-step upload flow (initiate → upload → finish), plus metadata updates, deletion, storage limits, and client-side encryption. Creating the Lab itself is covered in [Lab Management](lab-management.md). -> **Looking for a runnable walkthrough?** This page is the per-operation reference. For a first upload with expected responses and failure handling at every step, use [Create a lab and upload a file](../getting-started/create-lab-and-upload-file.md) or [Upload an encrypted file](../getting-started/upload-encrypted-file.md) (encrypted, with a decrypt round trip). +> **Looking for a runnable walkthrough?** This page is the per-operation reference. For a first upload with expected responses and failure handling at every step, use [Create a lab and upload a public file](../getting-started/create-lab-and-upload-file.md) or [Upload an encrypted file](../getting-started/upload-encrypted-file.md) (encrypted, with a decrypt round trip). > **Note**: Every mutation on this page returns its failure in-band: the result carries `error: ApiError`, and success means `error` is `null`. Branch on `error.code` — never on `message` text — and quote `requestId` when reporting a problem. See [Error Handling](README.md#error-handling) for the `ApiError` shape, how to read `details`, and the list of error codes. diff --git a/api-reference/labs-api/lab-management.md b/api-reference/labs-api/lab-management.md index 25b2dd8..2e22ff2 100644 --- a/api-reference/labs-api/lab-management.md +++ b/api-reference/labs-api/lab-management.md @@ -6,7 +6,7 @@ Operations for creating and administering a Lab: creating the dataroom, managing ## Mint the LabNFT -Before `createLab` can attach a dataroom, an onchain lab (OCL) has to exist: a LabNFT minted to your wallet with its ERC-6551 account (Token Bound Account) deployed. This step is **onchain only** — there is no Labs API mutation for it. See [Lab Creation](../../technical-deep-dive/architecture.md#lab-creation) for the contract-level flow and [Molecule Labs](../../technical-deep-dive/onchain-lab.md) for what a Lab is. If you'd rather not touch contracts directly, the Molecule app does this for you — see [Creating a Lab](../../user-guides/scientists-researchers.md#creating-a-lab). For a runnable end-to-end version of the mint, see [Step 2 of Create a lab and upload a file](../getting-started/create-lab-and-upload-file.md#step-2-mint-the-labnft). +Before `createLab` can attach a dataroom, an onchain lab (OCL) has to exist: a LabNFT minted to your wallet with its ERC-6551 account (Token Bound Account) deployed. This step is **onchain only** — there is no Labs API mutation for it. See [Lab Creation](../../technical-deep-dive/architecture.md#lab-creation) for the contract-level flow and [Molecule Labs](../../technical-deep-dive/onchain-lab.md) for what a Lab is. If you'd rather not touch contracts directly, the Molecule app does this for you — see [Creating a Lab](../../user-guides/scientists-researchers.md#creating-a-lab). For a runnable end-to-end version of the mint, see [Step 2 of Create a lab and upload a public file](../getting-started/create-lab-and-upload-file.md#step-2-mint-the-labnft). ### Contract Addresses @@ -302,7 +302,7 @@ Issue it yourself — no request, no waiting. Two calls with the owner wallet: 1. `getServiceSignInMessage(walletAddress, serviceName)` — public query, returns the message to sign. 2. Sign it verbatim (EIP-191 `personal_sign`), then `generateServiceToken(serviceName, walletAddress, messageSignature)` — returns the JWT for `X-Service-Token`. -Full parameters and bounds: [Service Tokens](service-tokens.md#obtaining-a-token). Runnable: [Step 1 of Create a lab and upload a file](../getting-started/create-lab-and-upload-file.md#step-1-get-a-service-token). +Full parameters and bounds: [Service Tokens](service-tokens.md#obtaining-a-token). Runnable: [Step 1 of Create a lab and upload a public file](../getting-started/create-lab-and-upload-file.md#step-1-get-a-service-token). The only credential you have to request is the **consumer credential** — see [Getting Started](../getting-started/README.md#1-a-mol-consumer-credential-the-one-manual-step) for the request template. diff --git a/api-reference/labs-api/service-tokens.md b/api-reference/labs-api/service-tokens.md index 138a3a0..ef19abb 100644 --- a/api-reference/labs-api/service-tokens.md +++ b/api-reference/labs-api/service-tokens.md @@ -6,7 +6,7 @@ A service token is the credential that proves *which wallet* a write request act ## Obtaining a Token -Two calls, no human in the loop — the path for autonomous agents, bots and CI/CD pipelines that have no browser-based Privy session. Fetch a fresh sign-in message, sign it with the service wallet, then exchange the signature for a token. For the runnable version, see [Step 1 of Create a lab and upload a file](../getting-started/create-lab-and-upload-file.md#step-1-get-a-service-token). +Two calls, no human in the loop — the path for autonomous agents, bots and CI/CD pipelines that have no browser-based Privy session. Fetch a fresh sign-in message, sign it with the service wallet, then exchange the signature for a token. For the runnable version, see [Step 1 of Create a lab and upload a public file](../getting-started/create-lab-and-upload-file.md#step-1-get-a-service-token). Issuance is **not** gated on holding a role on any lab: any wallet can mint a token for itself. The role is what makes the token useful. diff --git a/api-reference/x402-gateway.md b/api-reference/x402-gateway.md index b41f699..c5e7f66 100644 --- a/api-reference/x402-gateway.md +++ b/api-reference/x402-gateway.md @@ -296,6 +296,7 @@ If you would rather not implement the handshake at all, the [Molecule Skill](../ ## Related - [Getting Started](getting-started/README.md) — how to interact with our products, prerequisites, costs +- [Glossary](../references/glossary.md) — every Molecule term used in these docs, defined in a sentence - [Molecule Skill](../ai-tooling/molecule-skill.md) — `x402_pay` does this handshake in one tool call - [Labs API](labs-api/README.md) — full mutation signatures and variable types - [Developers / AI Agents](../user-guides/developers-ai-agents.md) — agent integration guide diff --git a/user-guides/developers-ai-agents.md b/user-guides/developers-ai-agents.md index d3e6a15..30a9ac9 100644 --- a/user-guides/developers-ai-agents.md +++ b/user-guides/developers-ai-agents.md @@ -7,7 +7,7 @@ icon: robot # Developers/AI Agents -> **Want to start writing code now?** Go to [🚀 Getting Started](../api-reference/getting-started/README.md) — it helps you pick a way in and gets you to a lab with a file in it in about ten minutes. This page is the narrative map of every integration surface, for when you need to decide *what* to build rather than *how* to make the first call. +> **Want to start writing code now?** Go to [🚀 Getting Started](../api-reference/getting-started/README.md) — it helps you pick a way in and gets you to a lab with a file in it in about ten minutes. This page is the narrative map of every integration surface, for when you need to decide *what* to build rather than *how* to make the first call. New to the ecosystem? The [Glossary](../references/glossary.md) defines every Molecule term these docs use. ### Who This Guide Is For @@ -57,7 +57,7 @@ The simplest agent integration is read-only: query a Lab's data room for files, A write-enabled agent goes further: it reads data, performs analysis, and writes results back as new files in the Lab's data room. This requires both a consumer credential and a service token. Two paths to a service token: -* **Long-lived service token** — the agent mints its own via the `generateServiceToken` mutation, by signing a message with its wallet (a Privy session works too). No provisioning request. The token is a JWT tied to that wallet; write authorization is resolved per request from the wallet's onchain role on the target Lab. Content writes need **Contributor**. The end-to-end version — agent wallet, human grants the role, agent self-issues and uploads — is [Agent as a lab contributor](../api-reference/getting-started/agent-as-a-lab-contributor.md). +* **Long-lived service token** — the agent mints its own via the `generateServiceToken` mutation, by signing a message with its wallet (a Privy session works too). No provisioning request. The token is a JWT tied to that wallet; write authorization is resolved per request from the wallet's onchain role on the target Lab. Content writes need **Contributor**. The end-to-end version — agent wallet, human grants the role, agent self-issues and uploads — is [Agent access](../api-reference/getting-started/agent-as-a-lab-contributor.md). * **Pay-per-call via the** [**x402 Gateway**](../api-reference/x402-gateway.md) — for agents that serve external users, charge per request, or don't have pre-provisioned credentials. The gateway settles a USDC payment on Base per call and mints a short-lived (default 5-minute) service token scoped to one mutation. Available for `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createLab`, `generateDataEncryptionKey`, and `decryptDataKey`. The three-step upload flow (initiate, PUT, finalize) lets you write any file type with metadata including descriptions, tags, categories, and searchable content text. If the file should be confidential, first request a key via `generateDataEncryptionKey` — the backend returns a one-shot plaintext DEK (plus its encrypted form) you use to encrypt locally before upload, attaching the encryption metadata on finish. Every file the agent writes becomes a permanent, versioned record in the Lab's history. From c9b46848ec69fbb696661bb9133a6ebc503c6d36 Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Thu, 3 Sep 2026 20:30:07 +0100 Subject: [PATCH 15/16] chore(docs): handle pr comments --- .github/prompts/docs-sync.md | 6 +-- api-reference/authentication.md | 10 ++--- api-reference/changelog.md | 38 ++++++++++++++----- api-reference/getting-started/README.md | 12 +++--- .../agent-as-a-lab-contributor.md | 2 +- .../create-lab-and-upload-file.md | 18 +++++---- api-reference/getting-started/for-agents.md | 7 +++- api-reference/labs-api/browse-and-search.md | 6 ++- api-reference/labs-api/example-workflow.md | 19 ++++++++++ api-reference/labs-api/service-tokens.md | 7 ++-- api-reference/x402-gateway.md | 1 + references/glossary.md | 2 +- release-notes/labs-api.md | 2 +- user-guides/developers-ai-agents.md | 2 +- 14 files changed, 91 insertions(+), 41 deletions(-) create mode 100644 api-reference/labs-api/example-workflow.md diff --git a/.github/prompts/docs-sync.md b/.github/prompts/docs-sync.md index 489c145..7aa057f 100644 --- a/.github/prompts/docs-sync.md +++ b/.github/prompts/docs-sync.md @@ -53,7 +53,7 @@ touches one of its source paths. | -- | -- | | `api-reference/README.md` | `graphql/schemas/*.graphql` (surface inventory only), `lib/shared-api-stack.ts` | | `api-reference/authentication.md` | `lambda/appsync-authorizer-lambda/**`, service-token resolvers in `lambda/appsync-resolver-labs-lambda/**` (`services/token-manager-service.ts`, `utils/service-auth-message.ts`), `docs/service-auth.md`. The header reference and the sign-in flow both live here: a change to the sign-in message, its nonce or its validity window touches this page **and** `labs-api/service-tokens.md` **and** the tutorials' Step 1 — fix all of them or none. | -| `api-reference/getting-started/README.md` | The onboarding entry point. `lib/desci-api-app-sync/constructs/api-config.ts` (endpoints, introspection, depth limit), `lambda/x402-gateway-lambda/**` (prices/base URLs quoted in the cost table), `lambda/appsync-authorizer-lambda/**` (credential shape). **Contains verified live values** — `mintFeeWei()` readings, x402 prices, gateway URLs — each stamped with the date it was checked. Never update one of those from a diff alone: if the diff suggests a value changed, flag it in the PR body and let a human re-verify against the deployed environment. | +| `api-reference/getting-started/README.md` | The onboarding entry point. `lib/desci-api-app-sync/constructs/api-config.ts` (endpoints, introspection, depth limit), `lambda/x402-gateway-lambda/**` (prices/base URLs quoted in the cost table), `lambda/appsync-authorizer-lambda/**` (credential shape). **Contains live deployment values** — `mintFeeWei()` behaviour, x402 prices, gateway URLs, contract addresses. Never update one of those from a diff alone: if the diff suggests a value changed, flag it in the PR body and let a human re-verify against the deployed environment. | | `api-reference/getting-started/shared-setup.md` | The config constants and helpers every tutorial's snippets assume: staging endpoint, chain, `FACTORY_ADDRESS` / `LABNFT_ADDRESS` / `ACCESS_RESOLVER_ADDRESS`, plus `graphql()`, `parseDetails()`, `assertOk()` and `withIndexerLagRetry()`. Sources: `lib/desci-api-app-sync/constructs/api-config.ts` (endpoints), `graphql/schemas/api-error.graphql` and the in-band `ApiError` shape (`parseDetails`/`assertOk`), `lambda/appsync-authorizer-lambda/**` (header names). Contract addresses are deployment values — flag a suspected change for human verification rather than editing from a diff. **This block is duplicated inline in each tutorial's complete script and in the production swap table on `getting-started/README.md`: fix all copies or none.** | | `api-reference/getting-started/for-agents.md` | Condensed mirror of Create a lab and upload a public file plus the error contract. Sources: `graphql/schemas/ip-hubs.graphql`, `graphql/schemas/api-error.graphql`, `graphql/schemas/encryption.graphql`, `lambda/appsync-resolver-labs-lambda/**` (per-mutation role gates). **Must stay consistent with the tutorial pages** — a change to the flow touches this page and the relevant tutorial, or neither. | | `api-reference/getting-started/create-lab-and-upload-file.md` | Create a lab and upload a public file — self-issue a token, mint, `createLab`, three-call public upload, verify. Sources: `graphql/schemas/ip-hubs.graphql`; `lambda/appsync-resolver-labs-lambda/**` for `generateServiceToken`, `createLab`, `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile` and their authorization gates; `services/token-manager-service.ts`; `lib/utils/token-expiration.ts` (the `expiresIn` default and bounds). Every code block is expected to be runnable — a signature change here is a real breakage, so correct it and say so in the PR body. Steps 1–3 are duplicated by reference in Upload an encrypted file and Agent access: **fix all copies or none.** | @@ -65,11 +65,11 @@ touches one of its source paths. | `api-reference/labs-api/files.md` | file operations in `lambda/appsync-resolver-labs-lambda/**` (`initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata`, `moveEntry`), `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts` | | `api-reference/labs-api/browse-and-search.md` | `labs`, `searchLabs`, `labWithDataRoomAndFiles`, `dataRoomFile`, `activities`, `labActivity` resolvers; `graphql/schemas/onchain-activity.graphql` | | `api-reference/labs-api/legal-agreements.md` | `signLegalAgreement`, `legalAgreementTemplate`, `legalAgreementStatus` resolvers — **frozen and hidden** (`hidden: true`, out of `SUMMARY.md`) since IP-3028: the assignment agreement is no longer a gate on anything and the feature may be removed. Correct outright errors only; never expand it, and never re-introduce a `legalAgreement*` mention into `getting-started/**`, `authentication.md`, `labs-api/README.md` | -| `api-reference/labs-api/service-tokens.md` | `generateServiceToken`, `extendServiceToken`, `revokeServiceToken` resolvers in `lambda/appsync-resolver-labs-lambda/**` (`services/token-manager-service.ts`); `utils/service-auth-message.ts` and the sign-in nonce constants (`SIGNIN_NONCE_VALIDITY_MS` — the documented 10-minute window) in `services/token-manager-service.ts`; `lambda/appsync-authorizer-lambda/**`. **The documented `details.reason` values (`NONCE_NOT_FOUND`, `NONCE_EXPIRED`, `INVALID_SIGNATURE`, `WALLET_MISMATCH`) come from the `generateServiceToken` resolver** — keep the table and the validity window in step with it | +| `api-reference/labs-api/service-tokens.md` | `generateServiceToken`, `extendServiceToken`, `revokeServiceToken` resolvers in `lambda/appsync-resolver-labs-lambda/**` (`services/token-manager-service.ts`); `utils/service-auth-message.ts` and the sign-in nonce constants (`SIGNIN_NONCE_VALIDITY_MS` — the documented 10-minute window) in `services/token-manager-service.ts`; `lambda/appsync-authorizer-lambda/**`. **The documented `details.reason` values (`NONCE_NOT_FOUND`, `NONCE_EXPIRED`, `INVALID_SIGNATURE`) are the complete set the `generateServiceToken` signature path emits** — `WALLET_MISMATCH` is *not* one of them (it belongs to the legal-agreement resolvers' Privy path), and `expiresIn` is not validated in this resolver, so a bad value surfaces as `INTERNAL_ERROR` / `TOKEN_GENERATION_FAILED` — keep the table and the validity window in step with it | | `api-reference/tokenization-api.md` | `graphql/schemas/evm-tokenization.graphql`, `lambda/appsync-resolver-evm-tokenization/**`, `lib/evm-tokenization-service-stack.ts` | | `api-reference/x402-gateway.md` | `lambda/x402-gateway-lambda/**` | | `api-reference/ipnft-api-deprecated.md` | `lambda/desci-api-lambda/**` (legacy IPNFT resolvers), `lambda/desci-ipnfts-processor/**`, `lambda/ipnft-events-lambda/**` — **deprecated: correct errors, never expand** | -| `api-reference/changelog.md` | `graphql/schemas/**`, `prisma/schema.prisma` — breaking changes and migrations only | +| `api-reference/changelog.md` | `graphql/schemas/**`, `prisma/schema.prisma` — breaking changes and migrations only. **The "Typical `details.reason`" column lists only values the backend actually emits today**, verified against `lambda/common/errors/legacy-mapping.ts` (`LEGACY_CODE_MAP`) and the `details: { reason: … }` literals in `lambda/**`. Do **not** copy aspirational reasons out of desci-infra's `docs/api-standards.md` or `docs/error-codes.md` — those spec pages list planned values (`FILTER_COMPLEXITY_LIMIT`, `RESULT_CARDINALITY_LIMIT`, `TOKEN_EXPIRED`, `NOT_CONTRIBUTOR`, `SERVICE_NOT_WHITELISTED`) that no code path emits. A reason belongs in this table only if you can point at the line that returns it | | `release-notes/*.md` | any consumer-visible change (see the release-notes step) | | `technical-deep-dive/data/data-api-and-integration.md` | `lambda/kamu-client-lambda/**`, `lambda/did-linking-worker/**` | | `technical-deep-dive/data/data-privacy-and-access.md` | `graphql/schemas/encryption.graphql`, `lambda/common/services/kms-service.ts`, encryption resolvers in `lambda/appsync-resolver-labs-lambda/**`, `lib/encryption-stack.ts` | diff --git a/api-reference/authentication.md b/api-reference/authentication.md index 75d84a7..26da735 100644 --- a/api-reference/authentication.md +++ b/api-reference/authentication.md @@ -11,7 +11,7 @@ There are two credentials, and they do different jobs. If any term on this page ## Obtaining API Access -Every request carries a **consumer credential** in the `Authorization` header. Request one on the [Molecule Discord](https://t.co/L0VEiy4Bjk) with this template: +Every request carries a **consumer credential** in the `Authorization` header. Request one on the [Molecule Discord](https://t.co/L0VEiy4Bjk): post in [the API channel](https://discord.com/channels/608198475598790656/832947534983987281) and ping **@ella**, using this template (the channel link needs you to be in the server — join with the invite first): ``` Consumer credential request @@ -151,7 +151,7 @@ Self-service, two calls, no human in the loop. Full reference with parameters an | Minimum | 1 hour | | Maximum | 2 years | -A value outside those bounds, or in another format, is rejected with `VALIDATION_FAILED`. +**Validate this before you send it.** `expiresIn` is not checked by the resolver: a value outside those bounds, or in another format, fails inside token generation and comes back as `INTERNAL_ERROR` with `details.reason: TOKEN_GENERATION_FAILED` and a masked message, not as `VALIDATION_FAILED`. `INTERNAL_ERROR` is flagged `retryable: true`, but this one is permanent — fix the value rather than retrying. Issuance is **not** gated on holding a role on any lab — any wallet can mint a token for itself. The role is what makes the token useful. @@ -192,9 +192,9 @@ Two failure modes this prevents: `oclId` identifies a lab and looks like an address, but it is a 32-byte value that **packs the OCL account address inside it**, together with the LabNFT `tokenId`: ``` -oclId 0x 01 01 000000000005f6 f923ca46329c8fcb2fcf8a03512f1483c52c63c5 - ^^ ^^ ^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - version namespace tokenId (1526) the OCL account address, verbatim +oclId 0x 01 01 000000000000000005f6 f923ca46329c8fcb2fcf8a03512f1483c52c63c5 + ^^ ^^ ^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + version namespace tokenId (1526) the OCL account address, verbatim ``` So the trailing 40 hex characters of an `oclId` are the lab's `labAccountAddress` — which is why a zeroed one is rejected with `VALIDATION_FAILED` / `"embedded address is zero"` rather than a not-found. Pass `oclId` wherever a lab is named; never pass it as a wallet address, and never truncate it to one. diff --git a/api-reference/changelog.md b/api-reference/changelog.md index a124952..cc6f349 100644 --- a/api-reference/changelog.md +++ b/api-reference/changelog.md @@ -19,7 +19,7 @@ This page tracks breaking changes, deprecations, and additions across the Molecu - There is **one outstanding nonce per `(walletAddress, serviceName)`**, last-write-wins — calling the query again invalidates an unredeemed message. - Signatures over the older nonce-free message format no longer verify. -Failures come back as `UNAUTHENTICATED` with `details.reason` one of `NONCE_NOT_FOUND` (never requested, or already consumed), `NONCE_EXPIRED`, `INVALID_SIGNATURE` (altered text, or a message superseded by a later call) or `WALLET_MISMATCH`. +Failures come back as `UNAUTHENTICATED` with `details.reason` one of `NONCE_NOT_FOUND` (never requested, or already consumed), `NONCE_EXPIRED`, or `INVALID_SIGNATURE` (altered text, a message superseded by a later call, or a `walletAddress` that is not the signer). **Migration:** Fetch the message immediately before signing, and treat every one of those reasons as "request a new message and sign it again" rather than as a retryable call — re-submitting the same signature can never succeed. Callers that already ran `getServiceSignInMessage` → sign → `generateServiceToken` back to back need no change; callers that cached the message, cached a signature, or reconstructed the string client-side must stop doing so. See [Obtaining a Token](labs-api/service-tokens.md#obtaining-a-token). @@ -35,7 +35,7 @@ Two clarifications and one hardening, all now reflected across the API docs: ### Contributor role parity for service-token content writes -The five content-write mutations — `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata`, `moveEntry` — now gate a service token on the **Contributor** role, matching the Privy user path per mutation. Previously the service path required lab ownership for these, which meant a wallet granted Contributor on a lab could act through a user session but not through its own service token. +The six content-write mutations — `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata`, `moveEntry` and `createAnnouncement` (announcements have [since been deprecated](#announcements-are-deprecated)) — now gate a service token on the **Contributor** role, matching the Privy user path per mutation. Previously the service path required lab ownership for these, which meant a wallet granted Contributor on a lab could act through a user session but not through its own service token. This is what unblocks the "human owns the lab, agent contributes to it" flow: the owner grants the agent's wallet Contributor, and the agent's self-issued token can write. Owner-gated surfaces are unchanged — `createLab`, `updateLabNftMetadata` and `generateLabImageUploadUrl` still require ownership. @@ -62,6 +62,22 @@ All Molecule APIs (Labs, Tokenization, and IPNFT (Deprecated) — they share one ## Labs API +### Announcements are deprecated + +Announcements are no longer surfaced in the Molecule app, and they are out of every tutorial, how-to and feature-description page. **The API surface is unchanged and still works** — nothing has been removed from the schema and no call has started failing. This is a "stop building on it" notice, not a breaking change. + +Still live, and still returned to callers who ask for it: + +| Surface | Status | +| ------- | ------ | +| `createAnnouncement` mutation | Live. Gated on **Contributor**, like the file writes | +| `/x402/labs/createAnnouncement` gateway endpoint | Live, still in `X402_WRITE_MUTATIONS`, still priced | +| `LabEventAnnouncement` in the `LabActivityNode` union | Live — returned by unfiltered `labActivity` / `activities` | +| `SearchLabsAnnouncementHit` in the `SearchLabsHit` union | Live — returned by `searchLabs` | +| `LabActivityFilter.ANNOUNCEMENT` | Live | + +**Migration:** None required; existing integrations keep working. Do not add new dependencies on announcements. If you consume `labActivity` or `activities` and want a file-only feed, pass `filter: FILE` rather than assuming one — the unfiltered feed still contains announcement nodes for labs that have them. If you switch on `__typename` across `LabActivityNode` or `SearchLabsHit`, keep the announcement arms handled. See [Browse & Search](labs-api/browse-and-search.md). + ### Assignment Agreement is no longer a gate, and is out of the API docs Signing the assignment agreement is **not** a precondition for `createLab`, for uploading files, or for any other Labs API operation. It was previously presented as a required onboarding step, and the `_NOT_SIGNED` / `AGREEMENT_CHECK_UNAVAILABLE` failure causes are now dormant — reserved in the catalogue, but not emitted. @@ -122,17 +138,17 @@ On an in-band mutation error, `details` arrives as a JSON-encoded string (AppSyn | Code | `retryable` | Meaning | Typical `details.reason` | | --------------------------- | ----------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | -| `UNAUTHENTICATED` | false | Missing, invalid or expired credentials. | `TOKEN_EXPIRED`, `INVALID_SIGNATURE`, `WALLET_MISMATCH` | -| `UNAUTHORIZED` | false | Authenticated but not allowed (role or membership). | `NOT_LAB_OWNER`, `NOT_CONTRIBUTOR`, `SERVICE_NOT_WHITELISTED` | -| `NOT_FOUND` | false | The referenced resource does not exist. | `LAB_NOT_FOUND`, `PROJECT_NOT_FOUND`, … | -| `VALIDATION_FAILED` | false | Input failed validation; `details.field` names the offending field. | `INVALID_OCL_ID`, … | -| `CONFLICT` | false | A valid request conflicts with current state. | `SHORTNAME_TAKEN`, `ALREADY_SIGNED`, `PROJECT_CONFLICT`, `ACCOUNT_NAME_CONFLICT` | -| `FAILED_PRECONDITION` | false | Resource state makes the operation impossible until that state changes. | `TEMPLATE_EXPIRED`, `LEGACY_ENCRYPTION`, `NOT_ENCRYPTED` | -| `COMPLEXITY_LIMIT_EXCEEDED` | false | Query shape or result size is over the limit. | `FILTER_COMPLEXITY_LIMIT`, `RESULT_CARDINALITY_LIMIT` | +| `UNAUTHENTICATED` | false | Missing, invalid or expired credentials. | `AUTH_FAILED`, `SERVICE_AUTH_FAILED`, `INVALID_SIGNATURE`, `NONCE_NOT_FOUND`, `NONCE_EXPIRED`, `WALLET_MISMATCH`, `CONSUMER_CREDENTIAL_REQUIRED` | +| `UNAUTHORIZED` | false | Authenticated but not allowed (role or membership). | `UNAUTHORIZED` (role/membership denial), `NOT_LAB_OWNER`, `MUTATION_NOT_ALLOWED` | +| `NOT_FOUND` | false | The referenced resource does not exist. | `LAB_NOT_FOUND`, `OCL_NOT_FOUND`, `TOKEN_NOT_FOUND`, `PROJECT_NOT_FOUND`, … | +| `VALIDATION_FAILED` | false | Input failed validation; `details.field` names the offending field. | `INVALID_OCL_ID`, `INVALID_INPUT`, `MISSING_INPUT`, … | +| `CONFLICT` | false | A valid request conflicts with current state. | `SHORTNAME_TAKEN`, `PROJECT_CONFLICT`, `ACCOUNT_NAME_CONFLICT`, `ALREADY_REVOKED` | +| `FAILED_PRECONDITION` | false | Resource state makes the operation impossible until that state changes. | `TOKEN_REVOKED`, `LEGACY_ENCRYPTION`, `NOT_ENCRYPTED`, `MISSING_DEK` | +| `COMPLEXITY_LIMIT_EXCEEDED` | false | Query shape or result size is over the limit. | `COMPLEXITY_LIMIT_EXCEEDED` | | `RATE_LIMITED` | **true** | Throttled — retry with backoff. | — | | `TIMEOUT` | **true** | Execution exceeded the request budget. | — | | `UPSTREAM_UNAVAILABLE` | **true** | A dependency failed — retry with backoff. | `KAMU`, `CMS`, `IPFS` | -| `INTERNAL_ERROR` | **true** | Unexpected failure; quote `requestId` when reporting it. | — | +| `INTERNAL_ERROR` | **true** | Unexpected failure; quote `requestId` when reporting it. | `TOKEN_GENERATION_FAILED`, `CREATE_LAB_FAILED`, `UPLOAD_INIT_ERROR`, `KMS_ERROR`, … | Codes may be added over time, and each addition is published on this page. Treat a code you do not recognise as non-retryable, keep the raw value for diagnostics and surface it to a human. `PAYMENT_REQUIRED` is reserved for the x402 gateway and is not emitted by the GraphQL API. `details.reason` values are diagnostic refinement, not a contract surface — they may be extended without notice. @@ -189,6 +205,7 @@ The legacy `*V2` operations and the pre-OCL naming have been **removed**. The cu | `projectWithDataRoomAndFiles` / `…V2` | `labWithDataRoomAndFiles` | Look up by `oclId` (or `shortname`) instead of `ipnftUid` | | `dataRoomFileV2` | `dataRoomFile` | Identified by `oclId` + `path` | | `projectActivity` / `projectActivityV2` | `labActivity` | — | +| `projectAnnouncementsV2` / `projectAnnouncementV2` | `labActivity` / `activities` | Use the `filter: ANNOUNCEMENT` argument (announcements [since deprecated](#announcements-are-deprecated)) | | `activitiesV2` | `activities` | — | #### Renamed mutations @@ -196,6 +213,7 @@ The legacy `*V2` operations and the pre-OCL naming have been **removed**. The cu | Legacy (removed) | Current | Notes | | ------------------------------ | ---------------------------- | ---------------------------------------------------------------------- | | `createProject` | `createLab` | Now takes `input: { oclId }` instead of `ipnftSymbol` / `ipnftTokenId` | +| `createAnnouncementV2` | `createAnnouncement` | Takes `oclId`; the legacy `moleculeAccessLevel` param was removed. Announcements [since deprecated](#announcements-are-deprecated) | | `initiateCreateOrUpdateFileV2` | `initiateCreateOrUpdateFile` | — | | `finishCreateOrUpdateFileV2` | `finishCreateOrUpdateFile` | — | | `updateFileMetadataV2` | `updateFileMetadata` | — | diff --git a/api-reference/getting-started/README.md b/api-reference/getting-started/README.md index 8b53af8..fab6ce7 100644 --- a/api-reference/getting-started/README.md +++ b/api-reference/getting-started/README.md @@ -59,7 +59,7 @@ Two things, and only one of them involves a human. Every request to the API carries a consumer credential in the `Authorization` header. There is no self-service issuance yet (coming soon), so you will need to request this from the Molecule team. -Request it on the [Molecule Discord](https://t.co/L0VEiy4Bjk) with this template: +Request it on the [Molecule Discord](https://t.co/L0VEiy4Bjk): post in [the API channel](https://discord.com/channels/608198475598790656/832947534983987281) and ping **@ella**, using this template (the channel link needs you to be in the server — join with the invite first): ``` Consumer credential request @@ -157,14 +157,16 @@ curl -s -X POST https://staging.graphql.api.molecule.xyz/graphql \ Your file appears in `dataRoom.files` with the `path` you sent and `accessLevel: "PUBLIC"`. If `labWithDataRoomAndFiles` comes back `null`, `createLab` did not complete — a missing lab nulls the field rather than throwing an error. -The second check is visual — once `shortname` is populated, the lab has a page of its own: +The second check is visual — the lab has a page of its own, at `/projects/`: | Environment | Lab page | | ----------- | -------- | -| Staging | `https://testnet.labs.molecule.xyz/projects/` | -| Production | `https://labs.molecule.xyz/projects/` | +| Staging | `https://testnet.labs.molecule.xyz/projects/` | +| Production | `https://labs.molecule.xyz/projects/` | -`shortname` is derived server-side from the lab's name and is `null` until that has happened, so a freshly minted lab is reachable by `oclId` before it is reachable by slug. +Until the lab is renamed, that slug is **`lab-`**, built from the `labNftTokenId` that `createLab` returns — so a lab you have just created is at `.../projects/lab-1274`. Once the owner renames the lab, the slug becomes the `shortname` derived from the new name, and the `lab-` form stops resolving. + +Do not build this URL from `oclId`. It identifies the lab in API calls, it is not a page slug, and it does not resolve here. *** diff --git a/api-reference/getting-started/agent-as-a-lab-contributor.md b/api-reference/getting-started/agent-as-a-lab-contributor.md index 2633306..b4af052 100644 --- a/api-reference/getting-started/agent-as-a-lab-contributor.md +++ b/api-reference/getting-started/agent-as-a-lab-contributor.md @@ -133,7 +133,7 @@ From here the agent is an ordinary caller. Run [Step 4 of Create a lab and uploa Writes by a Contributor service token are gated per mutation, matching the Privy user path: `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata` and `moveEntry` all accept Contributor. A few surfaces remain **Owner-only** and an agent Contributor cannot reach them: `updateLabNftMetadata`, `generateLabImageUploadUrl` and the legal-agreement mutations. {% hint style="warning" %} -**Retry on `UNAUTHORIZED` right after the grant.** Role state reaches the API through an event indexer, so for a window after `grantRole` confirms onchain a write still returns `UNAUTHORIZED` (`reason: NOT_CONTRIBUTOR`). It is not a permissions problem and re-issuing the token will not help — wait and retry. Usually seconds, but the same indexer has taken minutes on staging, so retry generously: +**Retry on `UNAUTHORIZED` right after the grant.** Role state reaches the API through an event indexer, so for a window after `grantRole` confirms onchain a write still returns `UNAUTHORIZED` (`details.reason` is also `UNAUTHORIZED` — there is no separate `NOT_CONTRIBUTOR` reason; match on `error.code`). It is not a permissions problem and re-issuing the token will not help — wait and retry. Usually seconds, but the same indexer has taken minutes on staging, so retry generously: ```javascript async function withIndexerLagRetry( diff --git a/api-reference/getting-started/create-lab-and-upload-file.md b/api-reference/getting-started/create-lab-and-upload-file.md index 22de959..924f71d 100644 --- a/api-reference/getting-started/create-lab-and-upload-file.md +++ b/api-reference/getting-started/create-lab-and-upload-file.md @@ -97,11 +97,11 @@ serviceToken = tokenResult.generateServiceToken.token; | `error.code` | What happened | Fix | | ------------ | ------------- | --- | -| `UNAUTHENTICATED`, `reason: INVALID_SIGNATURE` | The signed bytes are not the message the backend recomposes — altered text, or a message superseded by a later `getServiceSignInMessage` call | Sign the most recent `message` byte-for-byte. Use `personal_sign` / viem's `signMessage`, not `signTypedData` | +| `UNAUTHENTICATED`, `reason: INVALID_SIGNATURE` | The signed bytes are not the message the backend recomposes — altered text, a message superseded by a later `getServiceSignInMessage` call, or a `walletAddress` that is not the address that produced the signature | Sign the most recent `message` byte-for-byte, and send the signing address as `walletAddress`. Use `personal_sign` / viem's `signMessage`, not `signTypedData` | | `UNAUTHENTICATED`, `reason: NONCE_NOT_FOUND` | No nonce on file — never requested for this wallet + service, or already redeemed by an earlier token | Re-run the query and sign the new `message`. Retrying the same signature never works | | `UNAUTHENTICATED`, `reason: NONCE_EXPIRED` | More than 10 minutes passed between fetching the message and redeeming it | Re-run the query and sign the new `message` | -| `UNAUTHENTICATED`, `reason: WALLET_MISMATCH` | `walletAddress` isn't the signer | Pass the same address that signed | -| `VALIDATION_FAILED` | Bad `expiresIn` | Format is ``, unit one of `s m h d w M y`; between 1 hour and 2 years | +| `VALIDATION_FAILED` | Malformed `walletAddress` | Send a checksummed or lowercase `0x`-prefixed 20-byte address | +| `INTERNAL_ERROR`, `reason: TOKEN_GENERATION_FAILED` | Bad `expiresIn` — the value is not validated before use, so a malformed or out-of-bounds one surfaces as a masked server error | Validate before sending: format is ``, unit one of `s m h d w M y`, between 1 hour and 2 years. Despite `retryable: true` on `INTERNAL_ERROR`, retrying will not fix it | | HTTP `401` before GraphQL runs | Consumer credential missing or malformed | Check `Authorization` — no `Bearer` prefix. See [Authentication](../authentication.md) | `expiresIn` defaults to **`180d`** when omitted. The returned token is **wallet-bound, not lab-bound**: it carries this wallet's identity, and authorisation is resolved per request from that wallet's onchain role on whichever lab you name. @@ -196,7 +196,7 @@ assertOk(createLabResult.createLab, "createLab"); } ``` -`shortname` is derived server-side from the lab's name and is `null` until it has been derived — that is expected on a lab that has just been minted and not yet named. +`shortname` is `null` here, which is expected on a lab that has just been minted and not yet named — it is derived server-side once the lab is given one. Note `labNftTokenId` in the response: until the lab is renamed, that is what its page URL is built from (see [Step 5](#step-5-verify-it-worked)). **If it fails:** @@ -220,7 +220,7 @@ Molecule runs onchain and offchain systems side by side, and the offchain side l That is exactly what happens here. `createLab` can succeed before your mint has been indexed, because it falls back to checking ownership onchain. The file mutations have no such fallback — they read the indexed record, and return `NOT_FOUND` until it arrives. -So wrap the first call in [`withIndexerLagRetry`](shared-setup.md). On staging the record usually appears within seconds, but one mint took **over four minutes**, which is why the helper keeps retrying that long instead of giving up after a few seconds. +So wrap the first call in [`withIndexerLagRetry`](shared-setup.md) so the helper will retry until the record arrives. {% endhint %} ```javascript @@ -388,7 +388,7 @@ Your file is in `dataRoom.files` with `accessLevel: "PUBLIC"` and `version: 1`. If `labWithDataRoomAndFiles` comes back `null`, the lab is not registered: Step 3 didn't complete. This is one of only two nullable queries on the Labs API, so a missing lab nulls the field instead of throwing. -The second check is visual. Once `shortname` is populated, the lab has a page at `${LAB_APP_URL}/projects/` — `https://testnet.labs.molecule.xyz/projects/` on staging. +The second check is visual. The lab has a page at `${LAB_APP_URL}/projects/`. Until the lab is renamed, that slug is `lab-` — the token id `createLab` returned in Step 3 — so a lab you have just created is at `https://testnet.labs.molecule.xyz/projects/lab-1274` on staging. Once the owner renames the lab, the slug becomes the `shortname` derived from the new name and the `lab-` form stops resolving. `oclId` does not work here. ## Complete script @@ -553,6 +553,7 @@ async function main() { { oclId }, ); assertOk(createLabResult.createLab, "createLab"); + const { labNftTokenId } = createLabResult.createLab.lab; console.log("3/5 Lab registered — TBA:", createLabResult.createLab.lab.labAccountAddress); // ---- Step 4: upload the file ---- @@ -614,7 +615,10 @@ async function main() { const file = lab.dataRoom.files.find((f) => f.path.endsWith(basename(filePath))); if (!file) throw new Error("File not found in the data room"); console.log("5/5 Verified:", file.path, file.accessLevel, "v" + file.version); - if (lab.shortname) console.log("Lab page:", `${LAB_APP_URL}/projects/${lab.shortname}`); + // Until the lab is renamed its page slug is `lab-`; after a rename it is + // the derived shortname, and the lab- form stops resolving. + const slug = lab.shortname ?? `lab-${labNftTokenId}`; + console.log("Lab page:", `${LAB_APP_URL}/projects/${slug}`); } main().catch((err) => { diff --git a/api-reference/getting-started/for-agents.md b/api-reference/getting-started/for-agents.md index da33658..39ee742 100644 --- a/api-reference/getting-started/for-agents.md +++ b/api-reference/getting-started/for-agents.md @@ -20,7 +20,7 @@ ONCHAIN_LAB_FACTORY 0xd629FE2310b4309a212495F10A47f8436dcEfD90 LABNFT 0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28 ACCESS_RESOLVER 0x5493F472602C87318EA5Eff753cDD593bf9bF559 ACCESS_CONDITION_CHAIN "baseSepolia" -LAB_PAGE https://testnet.labs.molecule.xyz/projects/ +LAB_PAGE https://testnet.labs.molecule.xyz/projects/ ``` Production (Base) — swap these in, nothing else changes: @@ -32,7 +32,7 @@ ONCHAIN_LAB_FACTORY 0xECdF4f05384056507485C90aeAb0a83268760D6E LABNFT 0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92 ACCESS_RESOLVER 0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B ACCESS_CONDITION_CHAIN "base" -LAB_PAGE https://labs.molecule.xyz/projects/ +LAB_PAGE https://labs.molecule.xyz/projects/ ``` ## Headers @@ -59,6 +59,7 @@ function parseDetails(d) { } ``` * Branch on `code`, never on `message`. Retry only when `retryable` is `true`, with exponential backoff. Quote `requestId` in any bug report. +* One exception to "retry when retryable": a malformed or out-of-bounds `expiresIn` on `generateServiceToken` is not pre-validated and comes back as `INTERNAL_ERROR` / `details.reason: TOKEN_GENERATION_FAILED`, which is flagged retryable but never will be. Validate `expiresIn` client-side (``, unit in `s m h d w M y`, 1 hour to 2 years) and cap retries on that reason. * Codes: `UNAUTHENTICATED`, `UNAUTHORIZED`, `NOT_FOUND`, `VALIDATION_FAILED`, `CONFLICT`, `FAILED_PRECONDITION`, `COMPLEXITY_LIMIT_EXCEEDED`, `RATE_LIMITED`*, `TIMEOUT`*, `UPSTREAM_UNAVAILABLE`*, `INTERNAL_ERROR`* (`*` = retryable). An unrecognised code: treat as non-retryable, preserve it, surface it. ## Step 1 — Self-issue a service token @@ -149,6 +150,8 @@ query Verify($oclId: String!) { Public query, `Authorization` only. Your `path` is in `dataRoom.files`. A `null` result means the lab is not registered — this query is nullable and does not throw for a missing lab. +The lab page slug is `lab-` until the lab is renamed, then the `shortname` derived from its new name; the `lab-` form stops resolving at that point. Never use `oclId` as a slug — it does not resolve. + ## Encrypted files, in four lines 1. `generateDataEncryptionKey` → `{ plaintextDEK, encryptedDek, encryptionSystem }`. diff --git a/api-reference/labs-api/browse-and-search.md b/api-reference/labs-api/browse-and-search.md index 4de58a8..2f7c847 100644 --- a/api-reference/labs-api/browse-and-search.md +++ b/api-reference/labs-api/browse-and-search.md @@ -84,7 +84,9 @@ curl -X POST https://production.graphql.api.molecule.xyz/graphql \ ### Project Activity Feed -Get the file-event timeline for a specific project. This is a **public endpoint** - no authentication required. +Get the activity timeline for a specific project. This is a **public endpoint** - no authentication required. + +Unfiltered, `nodes` is a `LabActivityNode` union that also includes `LabEventAnnouncement` entries. [Announcements are deprecated](../changelog.md#announcements-are-deprecated), but labs created before the deprecation still carry them, so **pass `filter: FILE` if you want a file-only feed** and handle `__typename` defensively if you do not. `LabActivityFilter` accepts `FILE` and `ANNOUNCEMENT`. > **🔓 Public Endpoint**: The `labActivity` query does not require authentication. You only need a consumer credential — `Authorization: mol__`, with **no `Bearer` prefix** — and no Service Token. @@ -317,6 +319,8 @@ query SearchLabs( | page | Int | No | Page number (default: 0) | | perPage | Int | No | Results per page (default: 10) | +`SearchLabsHit` is a union of `SearchLabsFileHit` **and** `SearchLabsAnnouncementHit`. The examples below match only the file arm; if you handle the union exhaustively, expect the announcement `__typename` too — [announcements are deprecated](../changelog.md#announcements-are-deprecated) but pre-existing ones are still indexed and still returned. + **Available Filters:** | Filter | Type | Description | diff --git a/api-reference/labs-api/example-workflow.md b/api-reference/labs-api/example-workflow.md new file mode 100644 index 0000000..15fb0dc --- /dev/null +++ b/api-reference/labs-api/example-workflow.md @@ -0,0 +1,19 @@ +--- +description: This page moved. The walkthrough is now three tutorials under Getting Started. +hidden: true +icon: signs-post +--- + +# Example Workflow (moved) + +This page has been replaced by three step-by-step tutorials under **[🚀 Getting Started](../getting-started/README.md)**, each one runnable end to end and each ending in a check that it worked. + +| Tutorial | What it covers | +| -------- | -------------- | +| [**Create a lab and upload a public file**](../getting-started/create-lab-and-upload-file.md) | The default path — issue a service token, mint the LabNFT, register the lab, upload, verify. **Start here.** | +| [**Upload an encrypted file**](../getting-started/upload-encrypted-file.md) | Local AES-256-GCM encryption, onchain access conditions, verified with a decrypt round trip | +| [**Agent access**](../getting-started/agent-as-a-lab-contributor.md) | A human owns the lab and never hands over a key; the agent gets its own role and its own token | + +The configuration constants and helper functions all three share are on [**Shared Setup**](../getting-started/shared-setup.md), and the staging → production swap table is on [Getting Started](../getting-started/README.md#running-in-production). + +If you arrived here from a bookmark or an old link, [Create a lab and upload a public file](../getting-started/create-lab-and-upload-file.md) is the direct replacement for what used to be on this page — minus the assignment-agreement step, which is [no longer a gate on anything](../changelog.md#assignment-agreement-is-no-longer-a-gate-and-is-out-of-the-api-docs). diff --git a/api-reference/labs-api/service-tokens.md b/api-reference/labs-api/service-tokens.md index ef19abb..e8f76b4 100644 --- a/api-reference/labs-api/service-tokens.md +++ b/api-reference/labs-api/service-tokens.md @@ -97,7 +97,7 @@ mutation GenerateServiceToken( | Minimum | 1 hour | | Maximum | 2 years | -`M` is a 30-day month and `y` is a 365-day year. Anything outside the bounds, or in another format, is rejected with `VALIDATION_FAILED`. Prefer a short lifetime matched to the caller's purpose — for an agent, match the expiry of its role grant — over the 180-day default. +`M` is a 30-day month and `y` is a 365-day year. **Validate this client-side.** `expiresIn` is not checked before use: anything outside the bounds, or in another format, fails deep in token generation and surfaces as `INTERNAL_ERROR` with `details.reason: TOKEN_GENERATION_FAILED` and a masked message — not as `VALIDATION_FAILED`. `INTERNAL_ERROR` carries `retryable: true`, but this particular one is permanent: retrying the same `expiresIn` will never succeed. Prefer a short lifetime matched to the caller's purpose — for an agent, match the expiry of its role grant — over the 180-day default. Success ⇔ `error == null`. On failure `error` carries the catalogue `code` (e.g. `UNAUTHENTICATED` when the signature does not verify), `message` mirrors `error.message`, and `token`, `tokenId`, `expiresAt` and `createdAt` are `null` (`serviceName` may echo the name you sent) — guard for `null`, not for empty strings, and branch on `error`, never on the token fields. @@ -107,10 +107,9 @@ Success ⇔ `error == null`. On failure `error` carries the catalogue `code` (e. | -------- | ------------- | --- | | `NONCE_NOT_FOUND` | No nonce record at all — you never called `getServiceSignInMessage` for this wallet + service, or the nonce was already consumed by an earlier token | Fetch a fresh message and sign it again. Do not retry the same signature | | `NONCE_EXPIRED` | The message is older than its 10-minute window | Fetch a fresh message and sign it again | -| `INVALID_SIGNATURE` | The signed bytes are not the string the backend recomposes. Either the message was altered (re-formatted, rebuilt client-side, typed-data signing, the retired nonce-free format) **or it was superseded** — a later `getServiceSignInMessage` call replaced the stored nonce, so an earlier message no longer matches | Sign the `message` from the most recent call, byte-for-byte, with `personal_sign` | -| `WALLET_MISMATCH` | `walletAddress` is not the address that produced the signature | Pass the signing address | +| `INVALID_SIGNATURE` | The signed bytes are not the string the backend recomposes. Either the message was altered (re-formatted, rebuilt client-side, typed-data signing, the retired nonce-free format), **it was superseded** — a later `getServiceSignInMessage` call replaced the stored nonce, so an earlier message no longer matches — **or the `walletAddress` you sent is not the address that produced the signature**: verification runs against the address you claim, so a wrong one simply fails to verify | Sign the `message` from the most recent call, byte-for-byte, with `personal_sign`, and send the signing address as `walletAddress` | -None of these are retryable as-is: every one of them means "get a new message and sign that". A `VALIDATION_FAILED` here refers to `walletAddress` format or `expiresIn` bounds instead. +None of these are retryable as-is: every one of them means "get a new message and sign that". Note there is no distinct wallet-mismatch reason on this path — the nonce is looked up under the `walletAddress` you send and the signature is verified against it, so a wrong address returns `NONCE_NOT_FOUND` (no message was ever issued for that address) or `INVALID_SIGNATURE` (one was, but a different key signed). A `VALIDATION_FAILED` here refers to `walletAddress` format, or to sending only one of `walletAddress` / `messageSignature` — both must be present together. ## Extending Token Expiration diff --git a/api-reference/x402-gateway.md b/api-reference/x402-gateway.md index c5e7f66..ca5221f 100644 --- a/api-reference/x402-gateway.md +++ b/api-reference/x402-gateway.md @@ -55,6 +55,7 @@ POST {base}/x402/labs/{mutation} | `/x402/labs/createLab` | `createLab` | Create a lab (data room) for an onchain lab (OCL) | | `/x402/labs/generateDataEncryptionKey` | `generateDataEncryptionKey` | Generate a data encryption key (DEK) for encrypted uploads | | `/x402/labs/decryptDataKey` | `decryptDataKey` | Decrypt a file's data key for an authorized caller | +| `/x402/labs/createAnnouncement` | `createAnnouncement` | **Deprecated** — announcements are no longer surfaced in the Molecule app. Still allow-listed and still charged; do not build on it | The path mutation must match the top-level GraphQL mutation field in the request body, otherwise the gateway returns `400`. The allow-list above is the single source of truth in `lambda/x402-gateway-lambda/mutations.ts` (`X402_WRITE_MUTATIONS`). diff --git a/references/glossary.md b/references/glossary.md index 3053f9e..efb104e 100644 --- a/references/glossary.md +++ b/references/glossary.md @@ -21,7 +21,7 @@ If a term in the tutorials is unfamiliar, it is defined here. Each entry is shor **OCL / `oclId`** — "onchain lab". `oclId` is the Lab's canonical identifier: a 32-byte hex string (`0x…`) emitted in the `OclIdentityCreated` event when the LabNFT is minted. Every Labs API call that targets a Lab takes this value. How it is derived: [Lab Management](../api-reference/labs-api/lab-management.md#how-oclid-is-derived). -**`shortname`** — a URL slug derived server-side from the Lab's name, used in the Lab's public page address. It is `null` until the server has derived it, so a freshly minted Lab is reachable by `oclId` before it is reachable by slug. +**`shortname`** — the slug in a Lab's public page address, `/projects/`. Until the Lab is renamed, the slug is `lab-`, built from the LabNFT's token id. Once it is renamed, `shortname` is derived server-side from the new name and the `lab-` form stops resolving. `oclId` is not a page slug and never resolves in this URL. *** diff --git a/release-notes/labs-api.md b/release-notes/labs-api.md index 5da6315..b8de5ad 100644 --- a/release-notes/labs-api.md +++ b/release-notes/labs-api.md @@ -17,7 +17,7 @@ _Released 2026-08-25_ #### Assignment Agreement no longer required before data-room writes Data-room write mutations — `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, -`deleteDataRoomFile`, `updateFileMetadata`, and `moveEntry` (see +`deleteDataRoomFile`, `updateFileMetadata`, `moveEntry`, and `createAnnouncement` (see [Files](../api-reference/labs-api/files.md)) — used to fail with `ASSIGNMENT_AGREEMENT_NOT_SIGNED` (`FAILED_PRECONDITION`) until a lab's Assignment Agreement was signed via `signLegalAgreement`. That gate is now disabled: these mutations succeed regardless of the agreement's sign state. diff --git a/user-guides/developers-ai-agents.md b/user-guides/developers-ai-agents.md index 30a9ac9..8ef680c 100644 --- a/user-guides/developers-ai-agents.md +++ b/user-guides/developers-ai-agents.md @@ -102,4 +102,4 @@ If you just want to give an AI assistant Molecule context, add the MCP server UR If you want an AI coding agent to run the whole Lab workflow for you, install the [Molecule Skill](../ai-tooling/molecule-skill.md) plugin — the skill plus MCP server that wraps every network, onchain and cryptographic step as one typed tool call. -Service tokens are self-issued, and every endpoint, gateway URL and contract address is published — see [Getting Started](../api-reference/getting-started/README.md). Reach out on the Molecule Discord for a consumer credential, a module attestation request, or any integration support. +Service tokens are self-issued, and every endpoint, gateway URL and contract address is published — see [Getting Started](../api-reference/getting-started/README.md). Reach out on the [Molecule Discord](https://t.co/L0VEiy4Bjk) for a consumer credential, a module attestation request, or any integration support — for a credential, post in [the API channel](https://discord.com/channels/608198475598790656/832947534983987281) and ping **@ella**. From c9d5b9e15a6afabc7835cbb3501036eb8661bc9b Mon Sep 17 00:00:00 2001 From: Nour KAROUI Date: Mon, 7 Sep 2026 17:32:43 +0200 Subject: [PATCH 16/16] chore(docs): remove ipnft deprecated page --- .github/prompts/docs-sync.md | 8 - .github/workflows/docs-sync.lock.yml | 4 +- .github/workflows/docs-sync.md | 2 +- SUMMARY.md | 95 +- api-reference/README.md | 107 +- api-reference/authentication.md | 3 +- api-reference/changelog.md | 6 +- api-reference/getting-started/README.md | 2 +- .../create-lab-and-upload-file.md | 6 +- api-reference/getting-started/shared-setup.md | 2 +- api-reference/ipnft-api-deprecated.md | 1438 ----------------- references/contracts/ipt.md | 1 - user-guides/developers-ai-agents.md | 2 +- 13 files changed, 103 insertions(+), 1573 deletions(-) delete mode 100644 api-reference/ipnft-api-deprecated.md diff --git a/.github/prompts/docs-sync.md b/.github/prompts/docs-sync.md index 7aa057f..fb446d8 100644 --- a/.github/prompts/docs-sync.md +++ b/.github/prompts/docs-sync.md @@ -68,7 +68,6 @@ touches one of its source paths. | `api-reference/labs-api/service-tokens.md` | `generateServiceToken`, `extendServiceToken`, `revokeServiceToken` resolvers in `lambda/appsync-resolver-labs-lambda/**` (`services/token-manager-service.ts`); `utils/service-auth-message.ts` and the sign-in nonce constants (`SIGNIN_NONCE_VALIDITY_MS` — the documented 10-minute window) in `services/token-manager-service.ts`; `lambda/appsync-authorizer-lambda/**`. **The documented `details.reason` values (`NONCE_NOT_FOUND`, `NONCE_EXPIRED`, `INVALID_SIGNATURE`) are the complete set the `generateServiceToken` signature path emits** — `WALLET_MISMATCH` is *not* one of them (it belongs to the legal-agreement resolvers' Privy path), and `expiresIn` is not validated in this resolver, so a bad value surfaces as `INTERNAL_ERROR` / `TOKEN_GENERATION_FAILED` — keep the table and the validity window in step with it | | `api-reference/tokenization-api.md` | `graphql/schemas/evm-tokenization.graphql`, `lambda/appsync-resolver-evm-tokenization/**`, `lib/evm-tokenization-service-stack.ts` | | `api-reference/x402-gateway.md` | `lambda/x402-gateway-lambda/**` | -| `api-reference/ipnft-api-deprecated.md` | `lambda/desci-api-lambda/**` (legacy IPNFT resolvers), `lambda/desci-ipnfts-processor/**`, `lambda/ipnft-events-lambda/**` — **deprecated: correct errors, never expand** | | `api-reference/changelog.md` | `graphql/schemas/**`, `prisma/schema.prisma` — breaking changes and migrations only. **The "Typical `details.reason`" column lists only values the backend actually emits today**, verified against `lambda/common/errors/legacy-mapping.ts` (`LEGACY_CODE_MAP`) and the `details: { reason: … }` literals in `lambda/**`. Do **not** copy aspirational reasons out of desci-infra's `docs/api-standards.md` or `docs/error-codes.md` — those spec pages list planned values (`FILTER_COMPLEXITY_LIMIT`, `RESULT_CARDINALITY_LIMIT`, `TOKEN_EXPIRED`, `NOT_CONTRIBUTOR`, `SERVICE_NOT_WHITELISTED`) that no code path emits. A reason belongs in this table only if you can point at the line that returns it | | `release-notes/*.md` | any consumer-visible change (see the release-notes step) | | `technical-deep-dive/data/data-api-and-integration.md` | `lambda/kamu-client-lambda/**`, `lambda/did-linking-worker/**` | @@ -80,7 +79,6 @@ touches one of its source paths. > **Triggering vs ride-along paths.** The relevance gate in > `.github/workflows/docs-sync.md` starts a run for a *subset* of the paths above. The deprecated -> IPNFT lambdas (`desci-api-lambda`, `desci-ipnfts-processor`, `ipnft-events-lambda`) > and `lib/*.ts` files beyond `shared-api-stack` / `evm-tokenization-service-stack` / > `encryption-stack` never start a run on their own — their pages update only when a triggering > path changed in the same release. That is deliberate; keep the gate small. @@ -93,10 +91,6 @@ not driven by a backend diff), `technical-deep-dive/onchain-lab.md` and `technical-deep-dive/module-registry/**` (source: the `onchainlabs` / `ocltokenizer` contracts), and `technical-deep-dive/data/README.md` (section landing page, narrative only). -The former `api-reference/IPNFT-api.md` — an orphan duplicate of `ipnft-api-deprecated.md`, never -in `SUMMARY.md`, still teaching the retired `x-api-key` header — was deleted under IP-3028. Do not -recreate it: `api-reference/ipnft-api-deprecated.md` is the only IPNFT page. - ## What is not source of truth - **`graphql/autogen/` and `prisma/generated/` are generated artefacts.** Never read them as the @@ -149,8 +143,6 @@ These exist because of the July 2026 docs audit. They are not optional. — do not create it. The one exception is a new file under `release-notes/`, which is expected. 5. **Do not restate internal work.** Refactors, test changes, dependency bumps, infrastructure and CI changes are invisible to consumers and must not reach a page. -6. **Deprecated surfaces are frozen.** On `api-reference/ipnft-api-deprecated.md`, correct outright - errors only. Never document new capability there. 7. **Scope discipline.** Only edit pages the map connects to paths in this diff. A tempting unrelated improvement belongs in the PR body as a suggestion, not in the diff. diff --git a/.github/workflows/docs-sync.lock.yml b/.github/workflows/docs-sync.lock.yml index 9f3ea71..7c4c959 100644 --- a/.github/workflows/docs-sync.lock.yml +++ b/.github/workflows/docs-sync.lock.yml @@ -93,7 +93,7 @@ on: # SRC_SHA: ${{ github.event.client_payload.sha }} # id: relevance # name: Docs relevance gate -# run: "set -euo pipefail\n\n# Two distinct failure shapes, deliberately:\n# - quiet skip (plain exit 1): the release genuinely touched no\n# documented surface, or there is nothing to diff against yet.\n# Expected and common; the run stays green.\n# - hard failure (hard_failure=true): the gate could not do its job\n# at all — bad credentials, API failure, unexpected dispatch. The\n# follow-up step turns this into a red job, because a swallowed\n# diff failure looks exactly like \"no docs-relevant releases\"\n# while the pipeline is in fact down.\nhard_fail() {\n echo \"::error::$1\"\n echo \"hard_failure=true\" >> \"$GITHUB_OUTPUT\"\n exit 1\n}\n# Any unanticipated failure (set -e) counts as hard too.\ntrap 'echo \"hard_failure=true\" >> \"$GITHUB_OUTPUT\"' ERR\n\n# The pilot is single-source: every credential and checkout in this\n# workflow is pinned to desci-infra, so reject any other spoke here,\n# loudly, instead of failing late in the agent job after the credit\n# gate has cleared. Revisit under DOCS-8 before adding a spoke.\nif [ \"$SRC_REPO\" != \"moleculeprotocol/desci-infra\" ]; then\n hard_fail \"Dispatch from unexpected source repo '${SRC_REPO}' — this workflow is pinned to moleculeprotocol/desci-infra.\"\nfi\n\nBASE=\"${BASE_SHA:-$PREVIOUS_VERSION}\"\nif [ -z \"$BASE\" ]; then\n echo \"::warning::No base reference in the payload; cannot diff. Stopping.\"\n exit 1\nfi\n\n# One compare call, no clone. Paths are the TRIGGERING SUBSET of the\n# source-of-truth map in .github/prompts/docs-sync.md: the map also\n# lists ride-along surfaces (the deprecated IPNFT lambdas, lib/ stacks\n# beyond the three named) that get documentation updates only when a\n# triggering path changed in the same release. Broaden here\n# deliberately — every addition buys agent runs.\ngh api \"repos/${SRC_REPO}/compare/${BASE}...${SRC_SHA}\" > /tmp/compare.json \\\n || hard_fail \"Compare API call failed for ${BASE}...${SRC_SHA} — the gate cannot tell whether this release is doc-relevant.\"\n\nCHANGED=$(jq -r '.files[].filename' /tmp/compare.json)\nFILE_COUNT=$(jq '.files | length' /tmp/compare.json)\n\n# The compare API caps .files at 300 entries regardless of pagination\n# (--paginate walks commits, not files), so at the cap fall back to\n# the union of per-commit file lists — otherwise the biggest releases\n# would be exactly the ones silently skipped.\nif [ \"$FILE_COUNT\" -ge 300 ]; then\n echo \"Compare returned ${FILE_COUNT} files (the API cap); unioning per-commit file lists instead.\"\n COMMITS=$(gh api --paginate \"repos/${SRC_REPO}/compare/${BASE}...${SRC_SHA}\" --jq '.commits[].sha') \\\n || hard_fail \"Could not list commits for ${BASE}...${SRC_SHA}.\"\n CHANGED=\"\"\n for c in $COMMITS; do\n FILES=$(gh api --paginate \"repos/${SRC_REPO}/commits/${c}\" --jq '.files[].filename') \\\n || hard_fail \"Could not list files for commit ${c}.\"\n CHANGED=\"${CHANGED}${FILES}\"$'\\n'\n done\n CHANGED=$(printf '%s' \"$CHANGED\" | sort -u)\nfi\n\n# Hand-authored contract surfaces only. merged-schema.graphql is\n# committed build output (assembled from the other schema files), so a\n# codegen-only refresh of it must never buy an agent run; the\n# hand-authored sources still match ^graphql/schemas/. graphql/autogen\n# and prisma/generated need no exclusion — no include pattern can\n# match them in the first place.\nRELEVANT=$(printf '%s\\n' \"$CHANGED\" | grep -E \\\n -e '^graphql/schemas/' \\\n -e '^prisma/schema\\.prisma$' \\\n -e '^docs/service-auth\\.md$' \\\n -e '^lambda/(appsync-resolver-labs-lambda|appsync-resolver-evm-tokenization|appsync-authorizer-lambda|x402-gateway-lambda|kamu-client-lambda|did-linking-worker|labnft-metadata-lambda|ocl-processor)/' \\\n -e '^lambda/common/services/kms-service\\.ts$' \\\n -e '^lib/(shared-api-stack|evm-tokenization-service-stack|encryption-stack)\\.ts$' \\\n | grep -v -e '^graphql/schemas/merged-schema\\.graphql$' || true)\n\nCOUNT=$(printf '%s' \"$RELEVANT\" | grep -c . || true)\nif [ \"${COUNT:-0}\" -eq 0 ]; then\n echo \"::notice::Release touched no documented surface; stopping before any AI spend.\"\n exit 1\nfi\n\necho \"Doc-relevant paths changed ($COUNT):\"\nprintf '%s\\n' \"$RELEVANT\"\n" +# run: "set -euo pipefail\n\n# Two distinct failure shapes, deliberately:\n# - quiet skip (plain exit 1): the release genuinely touched no\n# documented surface, or there is nothing to diff against yet.\n# Expected and common; the run stays green.\n# - hard failure (hard_failure=true): the gate could not do its job\n# at all — bad credentials, API failure, unexpected dispatch. The\n# follow-up step turns this into a red job, because a swallowed\n# diff failure looks exactly like \"no docs-relevant releases\"\n# while the pipeline is in fact down.\nhard_fail() {\n echo \"::error::$1\"\n echo \"hard_failure=true\" >> \"$GITHUB_OUTPUT\"\n exit 1\n}\n# Any unanticipated failure (set -e) counts as hard too.\ntrap 'echo \"hard_failure=true\" >> \"$GITHUB_OUTPUT\"' ERR\n\n# The pilot is single-source: every credential and checkout in this\n# workflow is pinned to desci-infra, so reject any other spoke here,\n# loudly, instead of failing late in the agent job after the credit\n# gate has cleared. Revisit under DOCS-8 before adding a spoke.\nif [ \"$SRC_REPO\" != \"moleculeprotocol/desci-infra\" ]; then\n hard_fail \"Dispatch from unexpected source repo '${SRC_REPO}' — this workflow is pinned to moleculeprotocol/desci-infra.\"\nfi\n\nBASE=\"${BASE_SHA:-$PREVIOUS_VERSION}\"\nif [ -z \"$BASE\" ]; then\n echo \"::warning::No base reference in the payload; cannot diff. Stopping.\"\n exit 1\nfi\n\n# One compare call, no clone. Paths are the TRIGGERING SUBSET of the\n# source-of-truth map in .github/prompts/docs-sync.md: the map also\n# lists ride-along surfaces (lib/ stacks\n# beyond the three named) that get documentation updates only when a\n# triggering path changed in the same release. Broaden here\n# deliberately — every addition buys agent runs.\ngh api \"repos/${SRC_REPO}/compare/${BASE}...${SRC_SHA}\" > /tmp/compare.json \\\n || hard_fail \"Compare API call failed for ${BASE}...${SRC_SHA} — the gate cannot tell whether this release is doc-relevant.\"\n\nCHANGED=$(jq -r '.files[].filename' /tmp/compare.json)\nFILE_COUNT=$(jq '.files | length' /tmp/compare.json)\n\n# The compare API caps .files at 300 entries regardless of pagination\n# (--paginate walks commits, not files), so at the cap fall back to\n# the union of per-commit file lists — otherwise the biggest releases\n# would be exactly the ones silently skipped.\nif [ \"$FILE_COUNT\" -ge 300 ]; then\n echo \"Compare returned ${FILE_COUNT} files (the API cap); unioning per-commit file lists instead.\"\n COMMITS=$(gh api --paginate \"repos/${SRC_REPO}/compare/${BASE}...${SRC_SHA}\" --jq '.commits[].sha') \\\n || hard_fail \"Could not list commits for ${BASE}...${SRC_SHA}.\"\n CHANGED=\"\"\n for c in $COMMITS; do\n FILES=$(gh api --paginate \"repos/${SRC_REPO}/commits/${c}\" --jq '.files[].filename') \\\n || hard_fail \"Could not list files for commit ${c}.\"\n CHANGED=\"${CHANGED}${FILES}\"$'\\n'\n done\n CHANGED=$(printf '%s' \"$CHANGED\" | sort -u)\nfi\n\n# Hand-authored contract surfaces only. merged-schema.graphql is\n# committed build output (assembled from the other schema files), so a\n# codegen-only refresh of it must never buy an agent run; the\n# hand-authored sources still match ^graphql/schemas/. graphql/autogen\n# and prisma/generated need no exclusion — no include pattern can\n# match them in the first place.\nRELEVANT=$(printf '%s\\n' \"$CHANGED\" | grep -E \\\n -e '^graphql/schemas/' \\\n -e '^prisma/schema\\.prisma$' \\\n -e '^docs/service-auth\\.md$' \\\n -e '^lambda/(appsync-resolver-labs-lambda|appsync-resolver-evm-tokenization|appsync-authorizer-lambda|x402-gateway-lambda|kamu-client-lambda|did-linking-worker|labnft-metadata-lambda|ocl-processor)/' \\\n -e '^lambda/common/services/kms-service\\.ts$' \\\n -e '^lib/(shared-api-stack|evm-tokenization-service-stack|encryption-stack)\\.ts$' \\\n | grep -v -e '^graphql/schemas/merged-schema\\.graphql$' || true)\n\nCOUNT=$(printf '%s' \"$RELEVANT\" | grep -c . || true)\nif [ \"${COUNT:-0}\" -eq 0 ]; then\n echo \"::notice::Release touched no documented surface; stopping before any AI spend.\"\n exit 1\nfi\n\necho \"Doc-relevant paths changed ($COUNT):\"\nprintf '%s\\n' \"$RELEVANT\"\n" # - if: steps.relevance.outputs.hard_failure == 'true' # name: Fail loudly if the gate could not diff # run: | @@ -1754,7 +1754,7 @@ jobs: # One compare call, no clone. Paths are the TRIGGERING SUBSET of the # source-of-truth map in .github/prompts/docs-sync.md: the map also - # lists ride-along surfaces (the deprecated IPNFT lambdas, lib/ stacks + # lists ride-along surfaces (lib/ stacks # beyond the three named) that get documentation updates only when a # triggering path changed in the same release. Broaden here # deliberately — every addition buys agent runs. diff --git a/.github/workflows/docs-sync.md b/.github/workflows/docs-sync.md index 5ade386..c9e7d04 100644 --- a/.github/workflows/docs-sync.md +++ b/.github/workflows/docs-sync.md @@ -105,7 +105,7 @@ on: # One compare call, no clone. Paths are the TRIGGERING SUBSET of the # source-of-truth map in .github/prompts/docs-sync.md: the map also - # lists ride-along surfaces (the deprecated IPNFT lambdas, lib/ stacks + # lists ride-along surfaces (lib/ stacks # beyond the three named) that get documentation updates only when a # triggering path changed in the same release. Broaden here # deliberately — every addition buys agent runs. diff --git a/SUMMARY.md b/SUMMARY.md index 4f8dc29..304bb30 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -2,75 +2,74 @@ ## INTRODUCTION -* [Why Molecule?](README.md) -* [Why Desci?](introduction/why-desci.md) +- [Why Molecule?](README.md) +- [Why Desci?](introduction/why-desci.md) ## USER GUIDES -* [Scientists](user-guides/scientists-researchers.md) -* [Funders](user-guides/investors.md) -* [Developers/AI Agents](user-guides/developers-ai-agents.md) +- [Scientists](user-guides/scientists-researchers.md) +- [Funders](user-guides/investors.md) +- [Developers/AI Agents](user-guides/developers-ai-agents.md) ## Technical Deep Dive -* [Architecture](technical-deep-dive/architecture.md) -* [Molecule Labs](technical-deep-dive/onchain-lab.md) -* [Roles & Permissions](technical-deep-dive/roles-and-permissions.md) -* [Module Registry](technical-deep-dive/module-registry/README.md) - * [Validator Module](technical-deep-dive/module-registry/validator-module.md) - * [Fallback Modules](technical-deep-dive/module-registry/fallback-modules.md) - * [Executor Modules](technical-deep-dive/module-registry/executor-modules.md) -* [Data](technical-deep-dive/data/README.md) - * [Data Storage](technical-deep-dive/data/data-storage.md) - * [Data Privacy & Access](technical-deep-dive/data/data-privacy-and-access.md) - * [Data Anchoring (DID Linking)](technical-deep-dive/data/data-module.md) - * [Data API & Integration](technical-deep-dive/data/data-api-and-integration.md) +- [Architecture](technical-deep-dive/architecture.md) +- [Molecule Labs](technical-deep-dive/onchain-lab.md) +- [Roles & Permissions](technical-deep-dive/roles-and-permissions.md) +- [Module Registry](technical-deep-dive/module-registry/README.md) + - [Validator Module](technical-deep-dive/module-registry/validator-module.md) + - [Fallback Modules](technical-deep-dive/module-registry/fallback-modules.md) + - [Executor Modules](technical-deep-dive/module-registry/executor-modules.md) +- [Data](technical-deep-dive/data/README.md) + - [Data Storage](technical-deep-dive/data/data-storage.md) + - [Data Privacy & Access](technical-deep-dive/data/data-privacy-and-access.md) + - [Data Anchoring (DID Linking)](technical-deep-dive/data/data-module.md) + - [Data API & Integration](technical-deep-dive/data/data-api-and-integration.md) ## Legal Framework -* [Coin-to-Company Model](legal-framework/rwa-equity.md) +- [Coin-to-Company Model](legal-framework/rwa-equity.md) ## API Reference -* [📡 API Overview](api-reference/README.md) -* [🚀 Getting Started](api-reference/getting-started/README.md) - * [Shared Setup](api-reference/getting-started/shared-setup.md) - * [Create a lab and upload a public file](api-reference/getting-started/create-lab-and-upload-file.md) - * [Upload an encrypted file](api-reference/getting-started/upload-encrypted-file.md) - * [Agent access](api-reference/getting-started/agent-as-a-lab-contributor.md) - * [🤖 For Agents: One-Pager](api-reference/getting-started/for-agents.md) -* [🔐 Authentication](api-reference/authentication.md) -* [⚙️ Labs API](api-reference/labs-api/README.md) - * [Lab Management](api-reference/labs-api/lab-management.md) - * [Files](api-reference/labs-api/files.md) - * [Browse & Search](api-reference/labs-api/browse-and-search.md) - * [Service Tokens](api-reference/labs-api/service-tokens.md) -* [🔐 Tokenization API](api-reference/tokenization-api.md) -* [x402 Gateway](api-reference/x402-gateway.md) -* [📊 IPNFT API (Deprecated)](api-reference/ipnft-api-deprecated.md) -* [API Changelog & Migration](api-reference/changelog.md) +- [📡 API Overview](api-reference/README.md) +- [🚀 Getting Started](api-reference/getting-started/README.md) + - [Shared Setup](api-reference/getting-started/shared-setup.md) + - [Create a lab and upload a public file](api-reference/getting-started/create-lab-and-upload-file.md) + - [Upload an encrypted file](api-reference/getting-started/upload-encrypted-file.md) + - [Agent access](api-reference/getting-started/agent-as-a-lab-contributor.md) + - [🤖 For Agents: One-Pager](api-reference/getting-started/for-agents.md) +- [🔐 Authentication](api-reference/authentication.md) +- [⚙️ Labs API](api-reference/labs-api/README.md) + - [Service Tokens](api-reference/labs-api/service-tokens.md) + - [Lab Management](api-reference/labs-api/lab-management.md) + - [Files](api-reference/labs-api/files.md) + - [Browse & Search](api-reference/labs-api/browse-and-search.md) +- [🔐 Tokenization API](api-reference/tokenization-api.md) +- [x402 Gateway](api-reference/x402-gateway.md) +- [API Changelog & Migration](api-reference/changelog.md) ## Release Notes -* [Overview](release-notes/README.md) - * [Labs API](release-notes/labs-api.md) - * [Tokenization API](release-notes/tokenization-api.md) - * [x402 Gateway](release-notes/x402-gateway.md) +- [Overview](release-notes/README.md) + - [Labs API](release-notes/labs-api.md) + - [Tokenization API](release-notes/tokenization-api.md) + - [x402 Gateway](release-notes/x402-gateway.md) ## AI Tooling -* [MIRA](ai-tooling/mira.md) -* [Molecule Skill](ai-tooling/molecule-skill.md) +- [MIRA](ai-tooling/mira.md) +- [Molecule Skill](ai-tooling/molecule-skill.md) ## REFERENCES -* [Glossary](references/glossary.md) -* [Contracts](references/contracts/README.md) - * [IPT](references/contracts/ipt.md) - * [Tokenizer](references/contracts/tokenizer.md) - * [AccessResolver](references/contracts/accessresolver.md) -* [MCP Tools](references/mcp-tools.md) +- [Glossary](references/glossary.md) +- [Contracts](references/contracts/README.md) + - [IPT](references/contracts/ipt.md) + - [Tokenizer](references/contracts/tokenizer.md) + - [AccessResolver](references/contracts/accessresolver.md) +- [MCP Tools](references/mcp-tools.md) ## Security -* [Audits](security/audits.md) +- [Audits](security/audits.md) diff --git a/api-reference/README.md b/api-reference/README.md index a5fb3fe..2c7ba0b 100644 --- a/api-reference/README.md +++ b/api-reference/README.md @@ -14,22 +14,22 @@ Upload files to lab datarooms for secure, decentralized research data storage, a **Purpose:** -* Create labs (datarooms) for onchain labs (OCLs) -* Automate file uploads to lab datarooms -* Integrate with data pipelines and CI/CD -* Batch upload research data -* Manage file versions, metadata, and LabNFT display metadata -* Query labs, files, members, activity, and onchain events (mostly public access) -* Manage service tokens +- Create labs (datarooms) for onchain labs (OCLs) +- Automate file uploads to lab datarooms +- Integrate with data pipelines and CI/CD +- Batch upload research data +- Manage file versions, metadata, and LabNFT display metadata +- Query labs, files, members, activity, and onchain events (mostly public access) +- Manage service tokens **Authentication:** -* **Queries** (read operations): consumer credential only — public. -* **Write mutations** (write operations): consumer credential plus **either** a Service Token (`X-Service-Token`) **or** a Privy user session (`Authorization` + `x-wallet-address`) — the two paths are interchangeable. Exceptions: `extendServiceToken` and `revokeServiceToken` are Service-Token-only, and `generateServiceToken` bootstraps a token from a Privy session or wallet signature. +- **Queries** (read operations): consumer credential only — public. +- **Write mutations** (write operations): consumer credential plus **either** a Service Token (`X-Service-Token`) **or** a Privy user session (`Authorization` + `x-wallet-address`) — the two paths are interchangeable. Exceptions: `extendServiceToken` and `revokeServiceToken` are Service-Token-only, and `generateServiceToken` bootstraps a token from a Privy session or wallet signature. [View Labs API Documentation →](labs-api/README.md) · [Tutorials →](getting-started/README.md) -*** +--- ### 🔐 Tokenization API @@ -37,15 +37,15 @@ Tokenize Labs into fungible IP Tokens (IPTs) on Base. **Purpose:** -* Tokenize Labs into tradeable ERC-20 tokens -* Generate Lab (OCL) membership agreements -* Manage the complete onchain tokenization workflow +- Tokenize Labs into tradeable ERC-20 tokens +- Generate Lab (OCL) membership agreements +- Manage the complete onchain tokenization workflow **Authentication:** Consumer credential required [View Tokenization API Documentation →](tokenization-api.md) -*** +--- ### 💳 x402 Gateway @@ -53,15 +53,15 @@ Pay-per-call HTTP 402 gateway that fronts a set of Labs API write mutations with **Purpose:** -* Give autonomous agents and third-party tools write access without a long-lived service token -* Pay per mutation call in USDC, settled on Base -* Mint short-lived, scoped service tokens on the fly after payment +- Give autonomous agents and third-party tools write access without a long-lived service token +- Pay per mutation call in USDC, settled on Base +- Mint short-lived, scoped service tokens on the fly after payment **Authentication:** Per-request stablecoin payment (no long-lived service token required) [View x402 Gateway Documentation →](x402-gateway.md) -*** +--- ### 🪄 Molecule Skill (agent plugin) @@ -69,38 +69,21 @@ Not an API surface of its own — the whole Labs workflow packaged as an agent s **Purpose:** -* Give Claude Code, Codex, or any MCP-capable harness the full Lab lifecycle in one plugin -* Wrap every network, onchain, and cryptographic step as a single typed tool call -* Settle paid mutations automatically through the x402 Gateway +- Give Claude Code, Codex, or any MCP-capable harness the full Lab lifecycle in one plugin +- Wrap every network, onchain, and cryptographic step as a single typed tool call +- Settle paid mutations automatically through the x402 Gateway **Authentication:** Your `mol_` consumer credential, plus a wallet the plugin operates (Privy agentic wallet or raw EOA) [View Molecule Skill Documentation →](../ai-tooling/molecule-skill.md) -*** - -### 📊 IPNFT API (Deprecated) - -Query and browse IP-NFTs, IP Tokens (IPTs), and market data across the Molecule ecosystem. - -**Purpose:** - -* Browse all IP-NFTs and IPTs on the platform -* Query metadata, ownership, and project details -* Access trading data and market metrics -* Build marketplace UIs and token screeners - -**Authentication:** Consumer credential required - -[View IPNFT API Documentation (Deprecated) →](ipnft-api-deprecated.md) - -*** +--- ## Authentication All Molecule APIs require a consumer credential; the Labs API additionally uses a Service Token for write operations, which callers **issue for themselves** by signing a message with their wallet — no manual provisioning. Obtaining credentials, the per-API header requirements, and the full Labs API authentication model (public queries vs. protected mutations) are documented on the dedicated [Authentication](authentication.md) page. -*** +--- ## API Endpoints @@ -111,22 +94,20 @@ Production: https://production.graphql.api.molecule.xyz/graphql Staging: https://staging.graphql.api.molecule.xyz/graphql ``` -*** +--- ## Quick Start The full quickstart — prerequisites, costs, and a ten-minute path to a lab with a file in it — is on **[🚀 Getting Started](getting-started/README.md)**. In short: -| If you want to... | Go to | -| --------------------------------------------- | ------------------------------------------------- | -| Get from zero to a lab with a file in it | [Getting Started](getting-started/README.md) | -| Point an AI agent at this API | [Agent one-pager](getting-started/for-agents.md) · [Molecule Skill](../ai-tooling/molecule-skill.md) | -| Upload files to a Lab dataroom | [Labs API](labs-api/README.md) · [Tutorials](getting-started/README.md) | -| Let an agent write into a lab someone else owns | [Agent access](getting-started/agent-as-a-lab-contributor.md) | -| Tokenize a Lab into IP Tokens (IPTs) | [Tokenization API](tokenization-api.md) | -| Pay per call without a long-lived token | [x402 Gateway](x402-gateway.md) | -| Browse IP-NFTs and IPTs (legacy) | [IPNFT API (Deprecated)](ipnft-api-deprecated.md) | -| Check market prices and trading data (legacy) | [IPNFT API (Deprecated)](ipnft-api-deprecated.md) | +| If you want to... | Go to | +| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Get from zero to a lab with a file in it | [Getting Started](getting-started/README.md) | +| Point an AI agent at this API | [Agent one-pager](getting-started/for-agents.md) · [Molecule Skill](../ai-tooling/molecule-skill.md) | +| Upload files to a Lab dataroom | [Labs API](labs-api/README.md) · [Tutorials](getting-started/README.md) | +| Let an agent write into a lab someone else owns | [Agent access](getting-started/agent-as-a-lab-contributor.md) | +| Tokenize a Lab into IP Tokens (IPTs) | [Tokenization API](tokenization-api.md) | +| Pay per call without a long-lived token | [x402 Gateway](x402-gateway.md) | ### Make your first request @@ -141,27 +122,27 @@ curl -X POST https://production.graphql.api.molecule.xyz/graphql \ }' ``` -*** +--- ## Getting Support If you encounter any issues or have questions about the APIs: -* **Discord**: Join our [Discord community](https://t.co/L0VEiy4Bjk) for support -* **Documentation**: Check the specific API documentation pages linked above -* **Contact**: Reach out to the Molecule development team +- **Discord**: Join our [Discord community](https://t.co/L0VEiy4Bjk) for support +- **Documentation**: Check the specific API documentation pages linked above +- **Contact**: Reach out to the Molecule development team -*** +--- ## Additional Resources -* [Glossary](../references/glossary.md) — every Molecule term used in these docs, defined in a sentence -* [Getting Started](getting-started/README.md) — prerequisites, costs, ten-minute quickstart -* [Agent one-pager](getting-started/for-agents.md) — the default flow, paste-ready -* [Getting the schema](getting-started/README.md#getting-the-schema) — staging introspection is enabled; production's is not -* [Molecule Skill](../ai-tooling/molecule-skill.md) — the agent plugin that drives this API -* [Smart Contract Addresses](../references/contracts/) +- [Glossary](../references/glossary.md) — every Molecule term used in these docs, defined in a sentence +- [Getting Started](getting-started/README.md) — prerequisites, costs, ten-minute quickstart +- [Agent one-pager](getting-started/for-agents.md) — the default flow, paste-ready +- [Getting the schema](getting-started/README.md#getting-the-schema) — staging introspection is enabled; production's is not +- [Molecule Skill](../ai-tooling/molecule-skill.md) — the agent plugin that drives this API +- [Smart Contract Addresses](../references/contracts/) -*** +--- _Last updated: July 2026_ diff --git a/api-reference/authentication.md b/api-reference/authentication.md index 26da735..c18ad73 100644 --- a/api-reference/authentication.md +++ b/api-reference/authentication.md @@ -11,7 +11,7 @@ There are two credentials, and they do different jobs. If any term on this page ## Obtaining API Access -Every request carries a **consumer credential** in the `Authorization` header. Request one on the [Molecule Discord](https://t.co/L0VEiy4Bjk): post in [the API channel](https://discord.com/channels/608198475598790656/832947534983987281) and ping **@ella**, using this template (the channel link needs you to be in the server — join with the invite first): +Every request carries a **consumer credential** in the `Authorization` header. Request one on the [Molecule Discord](https://t.co/L0VEiy4Bjk): post in [the general-chat channel](https://discord.com/channels/608198475598790656/832947534983987281) and ping **@ella**, using this template (the channel link needs you to be in the server — join with the invite first): ``` Consumer credential request @@ -39,7 +39,6 @@ You do **not** need to ask anyone for a Service Token. Write mutations need one, | **Labs API (mutations, service token)** |

Authorization
X-Service-Token

|

Authorization: mol_<consumerId>_<secret>
X-Service-Token: YOUR_SERVICE_TOKEN

| | **Labs API (mutations, Privy user)** |

Authorization
x-wallet-address

|

Authorization: Bearer PRIVY_TOKEN
x-wallet-address: 0x…

| | **Tokenization API** | `Authorization` | `Authorization: mol__` | -| **IPNFT API (Deprecated)** | `Authorization` | `Authorization: mol__` | > **No `Bearer` prefix on consumer credentials.** `mol__` goes directly in the `Authorization` header. Only a Privy user token uses `Authorization: Bearer `. diff --git a/api-reference/changelog.md b/api-reference/changelog.md index cc6f349..9bad805 100644 --- a/api-reference/changelog.md +++ b/api-reference/changelog.md @@ -49,7 +49,7 @@ The data stores behind API authentication — the consumer credential registry, ### `x-api-key` replaced by consumer credentials -All Molecule APIs (Labs, Tokenization, and IPNFT (Deprecated) — they share one GraphQL endpoint) now authenticate with a consumer credential instead of an `x-api-key` header. A consumer credential has the shape `mol__` and is sent directly as the `Authorization` header value, with **no `Bearer` prefix**. +All Molecule APIs (Labs, Tokenization — they share one GraphQL endpoint) now authenticate with a consumer credential instead of an `x-api-key` header. A consumer credential has the shape `mol__` and is sent directly as the `Authorization` header value, with **no `Bearer` prefix**. ```diff - x-api-key: YOUR_API_KEY @@ -248,10 +248,6 @@ Also worth knowing: payment buys a short-lived service token for the payer walle --- -## IPNFT API (Deprecated) - -> The IPNFT API is deprecated. The changes below are preserved for integrations that have not yet migrated. See the [IPNFT API reference](ipnft-api-deprecated.md). - ### February 2026 #### Breaking Changes diff --git a/api-reference/getting-started/README.md b/api-reference/getting-started/README.md index fab6ce7..02e75a3 100644 --- a/api-reference/getting-started/README.md +++ b/api-reference/getting-started/README.md @@ -59,7 +59,7 @@ Two things, and only one of them involves a human. Every request to the API carries a consumer credential in the `Authorization` header. There is no self-service issuance yet (coming soon), so you will need to request this from the Molecule team. -Request it on the [Molecule Discord](https://t.co/L0VEiy4Bjk): post in [the API channel](https://discord.com/channels/608198475598790656/832947534983987281) and ping **@ella**, using this template (the channel link needs you to be in the server — join with the invite first): +Request it on the [Molecule Discord](https://t.co/L0VEiy4Bjk): post in [the general-chat channel](https://discord.com/channels/608198475598790656/832947534983987281) and ping **@ella**, using this template (the channel link needs you to be in the server — join with the invite first): ``` Consumer credential request diff --git a/api-reference/getting-started/create-lab-and-upload-file.md b/api-reference/getting-started/create-lab-and-upload-file.md index 924f71d..732c127 100644 --- a/api-reference/getting-started/create-lab-and-upload-file.md +++ b/api-reference/getting-started/create-lab-and-upload-file.md @@ -187,7 +187,7 @@ assertOk(createLabResult.createLab, "createLab"); "error": null, "lab": { "oclId": "0x0101000000000000000000000000abc…", - "shortname": null, + "shortname": "lab-1274", "labAccountAddress": "0x…", "labNftTokenId": "1274" } @@ -196,7 +196,7 @@ assertOk(createLabResult.createLab, "createLab"); } ``` -`shortname` is `null` here, which is expected on a lab that has just been minted and not yet named — it is derived server-side once the lab is given one. Note `labNftTokenId` in the response: until the lab is renamed, that is what its page URL is built from (see [Step 5](#step-5-verify-it-worked)). +When a lab is minted `shortname` is `lab-` by default. Once the owner renames the lab, the slug becomes the `shortname` derived from the new name and the `lab-` form stops resolving (see [Step 5](#step-5-verify-it-worked)). **If it fails:** @@ -211,6 +211,8 @@ DID-linking for the new lab starts automatically in the background; [`getDidLink ## Step 4: Upload the file +This section is for uploading public files, if you are interested in uploading an encrypted file, please jump to this tutorial instead [uploading an encrypted file](./upload-encrypted-file.md). + Three calls: get a presigned URL, `PUT` the bytes, finalise with metadata. Full reference: [Files](../labs-api/files.md). {% hint style="warning" %} diff --git a/api-reference/getting-started/shared-setup.md b/api-reference/getting-started/shared-setup.md index 9243547..2fde1c9 100644 --- a/api-reference/getting-started/shared-setup.md +++ b/api-reference/getting-started/shared-setup.md @@ -124,7 +124,7 @@ async function withIndexerLagRetry( *** -## Then what +## Next | Next | Page | | ---- | ---- | diff --git a/api-reference/ipnft-api-deprecated.md b/api-reference/ipnft-api-deprecated.md deleted file mode 100644 index 7859f26..0000000 --- a/api-reference/ipnft-api-deprecated.md +++ /dev/null @@ -1,1438 +0,0 @@ -# 📊 IPNFT API (Deprecated) - -## Overview - -The IPNFT API provides read-only access to query and browse intellectual property assets across the Molecule Protocol. Use these queries to build marketplaces, token screeners, portfolio trackers, and discovery interfaces for decentralized science projects. - -**Features:** - -- Query IP-NFTs (Intellectual Property NFTs) and their project details -- Browse IP Tokens (IPTs) with market data -- Access trading metrics and liquidity information -- Query users, research leads, chains, and agreements -- Filter, sort, and paginate results -- Build data-driven applications - ---- - -## Authentication - -All IPNFT API requests require a consumer credential (see [Authentication](authentication.md)). - -### Obtaining a Consumer Credential - -One credential covers every Molecule API, this one included. Request it on the [Molecule Discord](https://t.co/L0VEiy4Bjk) with the [request template](getting-started/README.md#1-a-mol-consumer-credential-the-one-manual-step) on Getting Started; what comes back is a single `mol__` string per environment. - -### Using Your Consumer Credential - -Send it as the `Authorization` header value directly — **no `Bearer` prefix**: - -```bash -Authorization: mol__ -``` - ---- - -## API Endpoint - -``` -Production: https://production.graphql.api.molecule.xyz/graphql -Staging: https://staging.graphql.api.molecule.xyz/graphql -``` - ---- - -## Queries - -### List IP-NFTs - -Query and browse all IP-NFTs on the platform with filtering, sorting, and pagination. - -**GraphQL Query:** - -```graphql -query ListIPNFTs( - $limit: Int - $skip: Int - $sortBy: IPNFTSortBy - $sortOrder: SortOrder - $filterBy: IPNFTFilterBy -) { - ipnfts( - limit: $limit - skip: $skip - sortBy: $sortBy - sortOrder: $sortOrder - filterBy: $filterBy - ) { - id - createdAt - updatedAt - mintedAt - chainId - originalOwner - tokenUri - symbol - name - description - image - externalUrl - initialSymbol - organization - topic - trlValue - trlRationale - fundingAmountCurrency - fundingAmountValue - fundingAmountDecimals - fundingAmountCurrencyType - schemaVersion - owner { - id - address - } - researchLead { - name - email - } - agreements { - id - contentHash - mimeType - type - url - } - ipt { - id - symbol - totalIssued - } - } -} -``` - -**Parameters:** - -| Parameter | Type | Description | -| --------- | ------------- | --------------------------------------------------------------------------------------- | -| limit | Int | Maximum number of results (recommended: 20-50) | -| skip | Int | Number of results to skip (for pagination) | -| sortBy | IPNFTSortBy | Field to sort by (e.g., `createdAt`, `mintedAt`, `name`, `topic`, `fundingAmountValue`) | -| sortOrder | SortOrder | Sort direction: `asc` or `desc` | -| filterBy | IPNFTFilterBy | Filter criteria (owner, chainId, topic, etc.) | - -**Example Request (curl):** - -```bash -curl -X POST https://production.graphql.api.molecule.xyz/graphql \ - -H 'Content-Type: application/json' \ - -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \ - -d '{ - "query": "query ListIPNFTs($limit: Int, $skip: Int, $sortBy: IPNFTSortBy, $sortOrder: SortOrder) { ipnfts(limit: $limit, skip: $skip, sortBy: $sortBy, sortOrder: $sortOrder) { id createdAt owner { address } name description image topic organization ipt { id symbol } } }", - "variables": { - "limit": 20, - "skip": 0, - "sortBy": "createdAt", - "sortOrder": "desc" - } - }' -``` - -**Response Example:** - -```json -{ - "data": { - "ipnfts": [ - { - "id": "37", - "createdAt": "2024-01-15T10:30:00.000Z", - "owner": { - "address": "0x1234567890123456789012345678901234567890" - }, - "name": "Novel Cancer Immunotherapy Research", - "description": "Groundbreaking CAR-T cell therapy development", - "image": "ipfs://QmXnnyufdzAWL...", - "topic": "Oncology", - "organization": "Research Institute", - "ipt": { - "id": "0xabcd...", - "symbol": "CART-IPT" - } - } - ] - } -} -``` - ---- - -### Get Single IP-NFT - -Retrieve detailed information about a specific IP-NFT by its ID. - -**GraphQL Query:** - -```graphql -query GetIPNFT($id: ID!) { - ipnft(id: $id) { - id - createdAt - updatedAt - mintedAt - chainId - originalOwner - tokenUri - symbol - name - description - image - externalUrl - initialSymbol - organization - topic - trlValue - trlRationale - fundingAmountCurrency - fundingAmountValue - fundingAmountDecimals - fundingAmountCurrencyType - schemaVersion - userId - owner { - id - address - } - researchLead { - id - name - email - } - agreements { - id - contentHash - mimeType - type - url - } - ipt { - id - l2TokenAddress - holderCount - symbol - name - decimals - totalIssued - circulatingSupply - } - } -} -``` - -**Example Request:** - -```bash -curl -X POST https://production.graphql.api.molecule.xyz/graphql \ - -H 'Content-Type: application/json' \ - -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \ - -d '{ - "query": "query GetIPNFT($id: ID!) { ipnft(id: $id) { id name description topic symbol owner { address } ipt { symbol totalIssued } agreements { id type url } } }", - "variables": { - "id": "37" - } - }' -``` - ---- - -### List IP Tokens (IPTs) - -Query and browse all IP Tokens with their associated IP-NFTs and market data. - -**GraphQL Query:** - -```graphql -query ListIPTs( - $limit: Int - $skip: Int - $sortBy: IPTSortBy - $sortOrder: SortOrder - $filterBy: IPTFilterBy -) { - ipts( - limit: $limit - skip: $skip - sortBy: $sortBy - sortOrder: $sortOrder - filterBy: $filterBy - ) { - id - createdAt - updatedAt - mintedAt - l2TokenAddress - holderCount - symbol - name - decimals - totalIssued - circulatingSupply - agreementCid - agreementMimeType - image - links - capped - ipnftId - originalOwnerId - ipnft { - id - name - description - image - topic - organization - owner { - address - } - } - originalOwner { - id - address - } - markets { - id - name - chainId - pairAddress - liquidityUsd - tradingVolume24hr - usdPrice - usdPrice24hrPercentageChange - marketCapUsd - } - } -} -``` - -**Parameters:** - -| Parameter | Type | Description | -| --------- | ----------- | --------------------------------------------------------------------- | -| limit | Int | Maximum number of results | -| skip | Int | Number of results to skip (for pagination) | -| sortBy | IPTSortBy | Field to sort by (e.g., `createdAt`, `holderCount`, `name`, `symbol`) | -| sortOrder | SortOrder | Sort direction: `asc` or `desc` | -| filterBy | IPTFilterBy | Filter criteria (ipnftId, symbol, originalOwnerId, etc.) | - -**Example Request (curl):** - -```bash -curl -X POST https://production.graphql.api.molecule.xyz/graphql \ - -H 'Content-Type: application/json' \ - -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \ - -d '{ - "query": "query ListIPTs($limit: Int, $sortBy: IPTSortBy, $sortOrder: SortOrder) { ipts(limit: $limit, sortBy: $sortBy, sortOrder: $sortOrder) { id symbol name totalIssued markets { usdPrice liquidityUsd tradingVolume24hr } ipnft { name topic } } }", - "variables": { - "limit": 20, - "sortBy": "createdAt", - "sortOrder": "desc" - } - }' -``` - -**Response Example:** - -```json -{ - "data": { - "ipts": [ - { - "id": "0xabcdef...", - "symbol": "CART-IPT", - "name": "Cancer Research IP Token", - "totalIssued": "1000000000000000000000000", - "markets": [ - { - "usdPrice": 0.45, - "liquidityUsd": 125000.5, - "tradingVolume24hr": 8500.25 - } - ], - "ipnft": { - "name": "Novel Cancer Immunotherapy Research", - "topic": "Oncology" - } - } - ] - } -} -``` - ---- - -### Get Single IP Token - -Retrieve detailed information about a specific IPT by its ID. - -**GraphQL Query:** - -```graphql -query GetIPT($id: ID!) { - ipt(id: $id) { - id - createdAt - updatedAt - mintedAt - l2TokenAddress - holderCount - symbol - name - decimals - totalIssued - circulatingSupply - agreementCid - agreementMimeType - image - links - capped - ipnft { - id - name - description - topic - } - originalOwner { - id - address - } - markets { - chainId - name - pairAddress - liquidityUsd - usdPrice - marketCapUsd - } - } -} -``` - ---- - -### Query Markets - -Access trading and market data for IP Tokens. - -**GraphQL Query:** - -```graphql -query ListMarkets( - $limit: Int - $skip: Int - $sortBy: MarketSortBy - $sortOrder: SortOrder - $filterBy: MarketFilterBy -) { - markets( - limit: $limit - skip: $skip - sortBy: $sortBy - sortOrder: $sortOrder - filterBy: $filterBy - ) { - id - createdAt - updatedAt - name - pairAddress - chainId - liquidityUsd - tradingVolume24hr - usdPrice - usdPrice24hrPercentageChange - marketCapUsd - inverted - iptId - token { - id - symbol - name - ipnft { - name - topic - } - } - chain { - name - chainId - logoUrl - } - } -} -``` - -**Example - Get Markets by Trading Volume:** - -```bash -curl -X POST https://production.graphql.api.molecule.xyz/graphql \ - -H 'Content-Type: application/json' \ - -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \ - -d '{ - "query": "query ListMarkets($limit: Int, $sortBy: MarketSortBy, $sortOrder: SortOrder) { markets(limit: $limit, sortBy: $sortBy, sortOrder: $sortOrder) { name usdPrice liquidityUsd tradingVolume24hr token { symbol } chain { name logoUrl } } }", - "variables": { - "limit": 10, - "sortBy": "tradingVolume24hr", - "sortOrder": "desc" - } - }' -``` - -A single market can also be fetched by its id: - -```graphql -query GetMarket($id: ID!) { - market(id: $id) { - name - usdPrice - liquidityUsd - tradingVolume24hr - token { - symbol - } - } -} -``` - ---- - -### Query Users - -Query users and their associated IP-NFTs and IPTs. - -**GraphQL Query:** - -```graphql -query ListUsers( - $limit: Int - $skip: Int - $sortBy: UserSortBy - $sortOrder: SortOrder - $filterBy: UserFilterBy -) { - users( - limit: $limit - skip: $skip - sortBy: $sortBy - sortOrder: $sortOrder - filterBy: $filterBy - ) { - id - createdAt - updatedAt - address - ipnfts { - id - name - topic - } - ipts { - id - symbol - name - } - } -} -``` - -**Get Single User:** - -```graphql -query GetUser($id: ID!) { - user(id: $id) { - id - address - createdAt - updatedAt - ipnfts { - id - name - topic - organization - } - ipts { - id - symbol - name - totalIssued - } - } -} -``` - ---- - -### Query Research Leads - -Query research leads associated with IP-NFTs. - -**GraphQL Query:** - -```graphql -query ListResearchLeads( - $limit: Int - $skip: Int - $sortBy: ResearchLeadSortBy - $sortOrder: SortOrder - $filterBy: ResearchLeadFilterBy -) { - researchLeads( - limit: $limit - skip: $skip - sortBy: $sortBy - sortOrder: $sortOrder - filterBy: $filterBy - ) { - id - createdAt - updatedAt - name - email - ipnfts { - id - name - topic - } - } -} -``` - -**Get Single Research Lead:** - -```graphql -query GetResearchLead($id: ID!) { - researchLead(id: $id) { - id - name - email - createdAt - updatedAt - ipnfts { - id - name - topic - organization - } - } -} -``` - ---- - -### Query Chains - -Query blockchain networks where markets are deployed. - -**GraphQL Query:** - -```graphql -query ListChains( - $limit: Int - $skip: Int - $sortBy: ChainSortBy - $sortOrder: SortOrder - $filterBy: ChainFilterBy -) { - chains( - limit: $limit - skip: $skip - sortBy: $sortBy - sortOrder: $sortOrder - filterBy: $filterBy - ) { - id - createdAt - updatedAt - name - chainId - logoUrl - markets { - id - name - usdPrice - liquidityUsd - } - } -} -``` - -**Get Single Chain:** - -```graphql -query GetChain($id: ID!) { - chain(id: $id) { - id - name - chainId - logoUrl - createdAt - updatedAt - markets { - id - name - usdPrice - liquidityUsd - tradingVolume24hr - } - } -} -``` - ---- - -### Query Agreements - -Query legal agreements associated with IP-NFTs. - -**GraphQL Query:** - -```graphql -query ListAgreements( - $limit: Int - $skip: Int - $sortBy: AgreementSortBy - $sortOrder: SortOrder - $filterBy: AgreementFilterBy -) { - agreements( - limit: $limit - skip: $skip - sortBy: $sortBy - sortOrder: $sortOrder - filterBy: $filterBy - ) { - id - contentHash - mimeType - type - url - ipnftId - } -} -``` - -**Get Single Agreement:** - -```graphql -query GetAgreement($id: ID!) { - agreement(id: $id) { - id - contentHash - mimeType - type - url - ipnftId - } -} -``` - ---- - -## Common Patterns - -### Pagination - -Use `skip` and `limit` for pagination: - -```javascript -// Page 1 -{ "limit": 20, "skip": 0 } - -// Page 2 -{ "limit": 20, "skip": 20 } - -// Page 3 -{ "limit": 20, "skip": 40 } -``` - -### Sorting - -Sort results by any field: - -```javascript -{ - "sortBy": "createdAt", // or "mintedAt", "updatedAt", "name", "topic", etc. - "sortOrder": "desc" // or "asc" -} -``` - -### Filtering - -Filter results by specific criteria. The API supports both direct field filtering and nested relation filtering. - -#### Basic Filtering - -**Filter IP-NFTs by topic:** - -```javascript -{ - "filterBy": { - "topic": "Oncology" - } -} -``` - -**Filter by owner (using user ID):** - -```javascript -{ - "filterBy": { - "userId": "0x1234567890123456789012345678901234567890" - } -} -``` - -**Filter by chain:** - -```javascript -{ - "filterBy": { - "chainId": 1 // Ethereum mainnet - } -} -``` - -**Filter IPTs by symbol:** - -```javascript -{ - "filterBy": { - "symbol": "VITA" - } -} -``` - -**Filter IPTs by IPNFT:** - -```javascript -{ - "filterBy": { - "ipnftId": "37" - } -} -``` - -#### Nested Relation Filtering - -The API supports filtering by nested relation properties for more flexible queries. - -**Filter IP-NFTs by owner address:** - -```javascript -{ - "filterBy": { - "owner": { - "address": "0x1234567890123456789012345678901234567890" - } - } -} -``` - -**Filter IP-NFTs by owner ID:** - -```javascript -{ - "filterBy": { - "owner": { - "id": "0x1234567890123456789012345678901234567890" - } - } -} -``` - -**Filter IP-NFTs by research lead:** - -```javascript -{ - "filterBy": { - "researchLead": { - "email": "researcher@university.edu" - } - } -} -``` - -**Filter IP-NFTs by agreement properties:** - -```javascript -{ - "filterBy": { - "agreements": { - "mimeType": "application/pdf" - } - } -} -``` - -**Filter IPTs by original owner:** - -```javascript -{ - "filterBy": { - "originalOwner": { - "address": "0x1234567890123456789012345678901234567890" - } - } -} -``` - -**Filter IPTs by parent IPNFT properties:** - -```javascript -{ - "filterBy": { - "ipnft": { - "topic": "Oncology" - } - } -} -``` - -**Filter markets by chain properties:** - -```javascript -{ - "filterBy": { - "chain": { - "chainId": 1 // Ethereum mainnet - } - } -} -``` - -**Filter markets by token properties:** - -```javascript -{ - "filterBy": { - "token": { - "symbol": "VITA-IPT" - } - } -} -``` - -**Filter IPTs by IPNFT owner (deeply nested):** - -```javascript -{ - "filterBy": { - "ipnft": { - "owner": { - "address": "0x1234567890123456789012345678901234567890" - } - } - } -} -``` - -#### Combining Filters - -You can combine multiple filters in a single query. All filters are combined with AND logic - results must match all criteria. - -**Combine scalar and relation filters:** - -```javascript -{ - "filterBy": { - "chainId": 1, - "owner": { - "address": "0x1234567890123456789012345678901234567890" - } - } -} -``` - -**Combine multiple field filters:** - -```javascript -{ - "filterBy": { - "topic": "Oncology", - "organization": "University Lab", - "owner": { - "address": "0x1234567890123456789012345678901234567890" - } - } -} -``` - -**Combine nested relation filters (IPT query):** - -```javascript -{ - "filterBy": { - "symbol": "VITA", - "ipnft": { - "owner": { - "address": "0x1234567890123456789012345678901234567890" - }, - "topic": "Longevity" - } - } -} -``` - ---- - -## Response Types - -### IPNFT Type - -```typescript -{ - id: String // Unique identifier — the onchain tokenId as a string (e.g. "37") - oclId: String // Linked lab oclId, null when the IP-NFT has no linked lab - createdAt: DateTime // Creation timestamp - updatedAt: DateTime // Last update timestamp - mintedAt: DateTime // Minting timestamp - chainId: Int // Blockchain network ID - originalOwner: String // Original minter address - tokenUri: String // Token metadata URI - symbol: String // Token symbol - name: String // Project name - description: String // Project description - image: String // IPFS image URL - externalUrl: String // External project URL - initialSymbol: String // Initial token symbol - organization: String // Organization name - topic: String // Research topic - trlValue: String // Technology readiness levels value - trlRationale: String // Technology readiness levels rationale - fundingAmountCurrency: String // Funding currency code - fundingAmountValue: String // Funding amount value - fundingAmountDecimals: Int // Funding currency decimals - fundingAmountCurrencyType: String // Currency type (e.g., "ERC20", "native") - schemaVersion: String // Metadata schema version - userId: String // Owner user ID - researchLeadId: String // Research lead ID - owner: { // Current owner - id: String - address: String - createdAt: DateTime - updatedAt: DateTime - } - researchLead: { // Research lead - id: String - name: String - email: String - } - agreements: [{ // Legal agreements - id: String - contentHash: String - mimeType: String - type: String - url: String - ipnftId: String - }] - ipt: { // Associated IP Token (if tokenized) - id: String - symbol: String - totalIssued: String - } -} -``` - -### IPT Type - -```typescript -{ - id: String // Unique identifier - createdAt: DateTime // Creation timestamp - updatedAt: DateTime // Last update timestamp - mintedAt: DateTime // Minting timestamp - l2TokenAddress: String // ERC-20 contract address - holderCount: Int // Number of token holders - symbol: String // Token symbol - name: String // Token name - decimals: Int // Token decimals - totalIssued: String // Total supply (wei format) - circulatingSupply: String // Circulating supply - agreementCid: String // IPFS CID of membership agreement - agreementMimeType: String // Agreement file MIME type - image: String // Token image URL - links: [String] // Related links - capped: Boolean // Whether token issuance is capped - ipnftId: String // Parent IP-NFT ID - originalOwnerId: String // Original owner user ID - ipnft: { // Parent IP-NFT - id: String - name: String - description: String - topic: String - } - originalOwner: { // Original token owner - id: String - address: String - } - markets: [{ // Trading markets - chainId: Int - pairAddress: String - liquidityUsd: Float - usdPrice: Float - tradingVolume24hr: Float - marketCapUsd: Float - }] -} -``` - -### Market Type - -```typescript -{ - id: String; // Unique identifier - createdAt: DateTime; // Creation timestamp - updatedAt: DateTime; // Last update timestamp - name: String; // Market name - pairAddress: String; // DEX pair contract address - chainId: Int; // Blockchain network ID - liquidityUsd: Float; // Total liquidity in USD - tradingVolume24hr: Float; // 24h trading volume in USD - usdPrice: Float; // Current token price in USD - usdPrice24hrPercentageChange: Float; // 24h price change % - marketCapUsd: Float; // Market capitalization in USD - inverted: Boolean; // Whether the pair is inverted - iptId: String; // Associated IPT ID - token: { - // Associated IPT - id: String; - symbol: String; - name: String; - } - chain: { - // Blockchain info - id: Int; - name: String; - chainId: Int; - logoUrl: String; - } -} -``` - -### User Type - -```typescript -{ - id: String; // Unique identifier - createdAt: DateTime; // Creation timestamp - updatedAt: DateTime; // Last update timestamp - address: String; // Wallet address - ipnfts: [IPNFT]; // Owned IP-NFTs - ipts: [IPT]; // Owned IPTs -} -``` - -### ResearchLead Type - -```typescript -{ - id: String; // Unique identifier - createdAt: DateTime; // Creation timestamp - updatedAt: DateTime; // Last update timestamp - name: String; // Research lead name - email: String; // Research lead email - ipnfts: [IPNFT]; // Associated IP-NFTs -} -``` - -### Chain Type - -```typescript -{ - id: Int; // Unique identifier - createdAt: DateTime; // Creation timestamp - updatedAt: DateTime; // Last update timestamp - name: String; // Chain name - chainId: Int; // Blockchain network ID (e.g., 1 for Ethereum) - logoUrl: String; // Chain logo URL - markets: [Market]; // Markets on this chain -} -``` - -### Agreement Type - -```typescript -{ - id: String; // Unique identifier - contentHash: String; // Content hash - mimeType: String; // File MIME type - type: String; // Agreement type - url: String; // Agreement URL - ipnftId: String; // Parent IP-NFT ID -} -``` - ---- - -## Example Use Cases - -### Building a Marketplace UI - -```javascript -// Fetch recent IP-NFTs with full details -const response = await fetch( - "https://production.graphql.api.molecule.xyz/graphql", - { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": process.env.CONSUMER_CREDENTIAL, - }, - body: JSON.stringify({ - query: ` - query RecentIPNFTs { - ipnfts(limit: 20, sortBy: createdAt, sortOrder: desc) { - id - name - description - image - topic - organization - ipt { - id - symbol - } - } - } - `, - }), - }, -); - -const data = await response.json(); -// Display IP-NFTs in marketplace grid -``` - -### Token Screener / Price Tracker - -```javascript -// Get top IPTs by trading volume -const response = await fetch( - "https://production.graphql.api.molecule.xyz/graphql", - { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": process.env.CONSUMER_CREDENTIAL, - }, - body: JSON.stringify({ - query: ` - query TopIPTsByVolume { - ipts(limit: 10, sortBy: createdAt, sortOrder: desc) { - symbol - name - markets { - usdPrice - usdPrice24hrPercentageChange - tradingVolume24hr - liquidityUsd - marketCapUsd - } - } - } - `, - }), - }, -); - -const data = await response.json(); -// Display price table with 24h change indicators -``` - -### Portfolio Tracker - -```javascript -// Get all IP-NFTs owned by a specific wallet -const walletAddress = "0x1234567890123456789012345678901234567890"; - -const response = await fetch( - "https://production.graphql.api.molecule.xyz/graphql", - { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": process.env.CONSUMER_CREDENTIAL, - }, - body: JSON.stringify({ - query: ` - query UserPortfolio($filterBy: IPNFTFilterBy) { - ipnfts(filterBy: $filterBy) { - id - name - topic - ipt { - symbol - totalIssued - markets { - usdPrice - marketCapUsd - } - } - } - } - `, - variables: { - filterBy: { - owner: { - address: walletAddress, - }, - }, - }, - }), - }, -); - -const data = await response.json(); -// Calculate total portfolio value -``` - ---- - -## Advanced Filtering - -### Relation Filtering vs Direct Filtering - -The IPNFT API supports two approaches to filtering: - -1. **Direct Field Filtering**: Filter by the ID of a related entity -2. **Relation Filtering**: Filter by properties of related entities - -Both approaches work and can be used based on your needs. - -**Example - Finding IP-NFTs by Owner:** - -```javascript -// Approach 1: Direct field filtering (when you know the user ID) -{ - "filterBy": { - "userId": "0x1234..." - } -} - -// Approach 2: Relation filtering (when you want to filter by owner properties) -{ - "filterBy": { - "owner": { - "address": "0x1234..." - } - } -} -``` - -### Multi-Level Nested Filtering - -You can filter through multiple levels of relations: - -```javascript -// Find all IP Tokens whose parent IP-NFT is owned by a specific wallet -{ - "filterBy": { - "ipnft": { - "owner": { - "address": "0x1234567890123456789012345678901234567890" - } - } - } -} - -// Find markets for tokens with a specific symbol -{ - "filterBy": { - "token": { - "symbol": "VITA-IPT" - } - } -} -``` - -### Available Relation Filters - -| Query Type | Relation Field | Supported Filters | Example | -| ---------- | --------------- | ---------------------------------------------- | --------------------------------------------- | -| `ipnfts` | `owner` | `id`, `address` | `owner: { address: "0x..." }` | -| `ipnfts` | `researchLead` | `id`, `name`, `email` | `researchLead: { email: "..." }` | -| `ipnfts` | `agreements` | `id`, `contentHash`, `mimeType`, `type`, `url` | `agreements: { mimeType: "application/pdf" }` | -| `ipts` | `ipnft` | All IPNFT filter fields | `ipnft: { topic: "Oncology" }` | -| `ipts` | `originalOwner` | `id`, `address` | `originalOwner: { address: "0x..." }` | -| `markets` | `chain` | `id`, `chainId`, `name` | `chain: { chainId: 1 }` | -| `markets` | `token` | All IPT filter fields | `token: { symbol: "VITA" }` | - -### Filter Matching - -All filters use **exact equality matching** by default. For example: - -```javascript -{ - "filterBy": { - "topic": "Longevity" // Exact match only - } -} -``` - ---- - -## Error Handling - -### Common Errors - -| Status Code | Error | Description | -| ----------- | --------------------- | ---------------------------------- | -| 401 | Unauthorized | Missing or invalid consumer credential | -| 400 | Bad Request | Invalid query syntax or parameters | -| 500 | Internal Server Error | Server error - retry the request | - -Missing resources are **not** signalled with an HTTP 404. Single-item queries (`ipnft`, `ipt`, `user`, …) return HTTP 200 with a GraphQL error in the `errors[]` array carrying a machine-readable `code`: - -| GraphQL error `code` | Meaning | -| --------------------------- | ----------------------------------------------------------- | -| `NOT_FOUND` | Requested resource doesn't exist | -| `COMPLEXITY_LIMIT_EXCEEDED` | Query too complex — max depth **5**, max **100** selections | -| `INVALID_INPUT` | Malformed arguments | - -### Troubleshooting - -**401 Unauthorized Error:** - -- Verify the `Authorization` header is included, with no `Bearer` prefix -- Check that your consumer credential is valid and not expired -- Ensure no typos in the consumer credential - -**Empty Results:** - -- Check filter criteria - may be too restrictive -- Verify the chainId if filtering by chain -- Try removing filters to see all results - -**GraphQL Errors:** - -- Check query syntax is valid -- Ensure field names match the schema -- Verify variable types match parameter types - ---- - -## Getting Support - -For questions or issues with the IPNFT API: - -- Join our [Discord community](https://t.co/L0VEiy4Bjk) -- Check the [API Overview](README.md) for authentication help - ---- - -## Recent Updates - -The breaking changes, migration notes, and newly added fields for this API have moved to the [API Changelog & Migration](changelog.md#ipnft-api-deprecated) page (February 2026 changes). - ---- - -_Last updated: February 2026_ diff --git a/references/contracts/ipt.md b/references/contracts/ipt.md index 6906bb1..bfa173f 100644 --- a/references/contracts/ipt.md +++ b/references/contracts/ipt.md @@ -87,4 +87,3 @@ A Lab can attach a pre-existing ERC-20 as its IPT instead of minting a new one. ### Resources * **Tokenization flow & API**: [Tokenization API](../../api-reference/tokenization-api.md) -* **Query existing IPTs**: [IPNFT API (Deprecated)](../../api-reference/ipnft-api-deprecated.md) (`ipts`, `markets`) diff --git a/user-guides/developers-ai-agents.md b/user-guides/developers-ai-agents.md index 8ef680c..115efd2 100644 --- a/user-guides/developers-ai-agents.md +++ b/user-guides/developers-ai-agents.md @@ -102,4 +102,4 @@ If you just want to give an AI assistant Molecule context, add the MCP server UR If you want an AI coding agent to run the whole Lab workflow for you, install the [Molecule Skill](../ai-tooling/molecule-skill.md) plugin — the skill plus MCP server that wraps every network, onchain and cryptographic step as one typed tool call. -Service tokens are self-issued, and every endpoint, gateway URL and contract address is published — see [Getting Started](../api-reference/getting-started/README.md). Reach out on the [Molecule Discord](https://t.co/L0VEiy4Bjk) for a consumer credential, a module attestation request, or any integration support — for a credential, post in [the API channel](https://discord.com/channels/608198475598790656/832947534983987281) and ping **@ella**. +Service tokens are self-issued, and every endpoint, gateway URL and contract address is published — see [Getting Started](../api-reference/getting-started/README.md). Reach out on the [Molecule Discord](https://t.co/L0VEiy4Bjk) for a consumer credential, a module attestation request, or any integration support — for a credential, post in [the general-chat channel](https://discord.com/channels/608198475598790656/832947534983987281) and ping **@ella**.