diff --git a/.github/prompts/docs-sync.md b/.github/prompts/docs-sync.md index 96a2d48..fb446d8 100644 --- a/.github/prompts/docs-sync.md +++ b/.github/prompts/docs-sync.md @@ -52,17 +52,23 @@ 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/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 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.** | +| `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 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/**` | | `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/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/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`) 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` | @@ -73,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. @@ -84,9 +89,7 @@ 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). ## What is not source of truth @@ -140,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 c6c5e1c..304bb30 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -2,70 +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) -* [🔐 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) - * [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) -* [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 -* [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/ai-tooling/molecule-skill.md b/ai-tooling/molecule-skill.md index 996e8c6..ca900fe 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 @@ -74,13 +73,30 @@ 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 | | ------------------------------------------------------------ | -------------------------------------------------------------------------------------- | | `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) | @@ -127,7 +143,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/ @@ -138,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. --- ``` @@ -202,7 +218,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 +249,9 @@ 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 * [Data Privacy & Access](../technical-deep-dive/data/data-privacy-and-access.md) — encryption and access evaluation in depth 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/README.md b/api-reference/README.md index 7f6de94..2c7ba0b 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 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 ### 📁 Labs API @@ -12,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 and legal-agreement signing +- 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:** -* **Most queries** (read operations): consumer credential only — public. One exception, `legalAgreementTemplate`, needs a Service Token or an authenticated session. -* **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) +[View Labs API Documentation →](labs-api/README.md) · [Tutorials →](getting-started/README.md) -*** +--- ### 🔐 Tokenization API @@ -35,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 @@ -51,38 +53,37 @@ 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) -*** +--- -### 📊 IPNFT API (Deprecated) +### 🪄 Molecule Skill (agent plugin) -Query and browse IP-NFTs, IP Tokens (IPTs), and market data across the Molecule ecosystem. +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:** -* 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 +- 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:** Consumer credential required +**Authentication:** Your `mol_` consumer credential, plus a wallet the plugin operates (Privy agentic wallet or raw EOA) -[View IPNFT API Documentation (Deprecated) →](ipnft-api-deprecated.md) +[View Molecule Skill Documentation →](../ai-tooling/molecule-skill.md) -*** +--- ## 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. -*** +--- ## API Endpoints @@ -93,25 +94,22 @@ Production: https://production.graphql.api.molecule.xyz/graphql 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) | -| 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) | -### 3. Make Your First Request +### Make your first request **Example (Labs API — public `labs` query, consumer credential only):** @@ -124,22 +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 -* [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 5218292..c18ad73 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. If any term on this page is unfamiliar, the [Glossary](../references/glossary.md) defines it in a sentence. + +| 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): 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 +- Who: +- What you're building: +- Environment: staging (add production if you need both) +- Contact: +``` + +What comes back is one opaque string per environment: + +``` +mol__ +``` -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) +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. -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. +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 @@ -22,7 +39,6 @@ Send the consumer credential as the `Authorization` header value directly — ** | **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 `. @@ -32,30 +48,29 @@ 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 -- `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, 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 -- `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 +78,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 +98,24 @@ 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 | | +| `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 +127,76 @@ 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: [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. +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` | +| Format | ``, unit one of `s` `m` `h` `d` `w` `M` `y` (e.g. `"30d"`, `"720h"`, `"6M"`) | +| Minimum | 1 hour | +| Maximum | 2 years | + +**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. -To obtain access credentials: +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**. 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 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. + +### 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/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: [Agent access](getting-started/agent-as-a-lab-contributor.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 000000000000000005f6 f923ca46329c8fcb2fcf8a03512f1483c52c63c5 + ^^ ^^ ^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + version namespace tokenId (1526) the OCL account address, verbatim +``` -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 +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 @@ -155,17 +223,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..9bad805 100644 --- a/api-reference/changelog.md +++ b/api-reference/changelog.md @@ -10,6 +10,37 @@ 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`, 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). + +### 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`, `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. + +**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 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. @@ -18,24 +49,56 @@ 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 + 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 +### 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. + +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](getting-started/README.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) @@ -69,25 +132,25 @@ 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 | 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 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 @@ -107,9 +170,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 } @@ -123,7 +186,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); + } ``` @@ -142,17 +205,17 @@ 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` | — | -| `projectAnnouncementsV2` / `projectAnnouncementV2` | `labActivity` / `activities` | Removed — use the `filter: ANNOUNCEMENT` argument | #### Renamed mutations | 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` | — | -| `createAnnouncementV2` | `createAnnouncement` | Takes `oclId`; the legacy `moleculeAccessLevel` param was removed | | `updateFileMetadataV2` | `updateFileMetadata` | — | | `deleteDataRoomFileV2` | `deleteDataRoomFile` | — | @@ -171,9 +234,19 @@ Top-level identifiers on `Lab` / `LabRef` were renamed away from the legacy IP-N --- -## IPNFT API (Deprecated) +## 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. -> 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). +**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. + +--- ### February 2026 @@ -348,13 +421,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/getting-started/README.md b/api-reference/getting-started/README.md new file mode 100644 index 0000000..02e75a3 --- /dev/null +++ b/api-reference/getting-started/README.md @@ -0,0 +1,228 @@ +--- +description: >- + 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 + +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. + +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. + +{% 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 how you'll call the API + +Three ways in. None is better than the others — pick by who is making the calls. + +| 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) | + +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 + +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. + +*** + +## 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 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 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. + +*** + +## 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 (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 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 +- 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 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. + +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). + +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. + +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). + +### What it costs + +| 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 + +```bash +npm install viem # Node 18+ has fetch and node:crypto built in +``` + +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 +``` + +*** + +## 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 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" +export WALLET_PRIVATE_KEY="0x…" # funded on Base Sepolia +``` + +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 })`. 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. + +The runnable version is the [complete script](create-lab-and-upload-file.md#complete-script): + +```bash +node create-lab-and-upload-file.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` comes back `null`, `createLab` did not complete — a missing lab nulls the field rather than throwing an error. + +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/` | + +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. + +*** + +## Running in Production + +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 | +| -------- | ------------------------- | ---------- | +| `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` | + +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. +* **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](#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: + +```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). 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. + +*** + +## Where to go next + +| 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 new file mode 100644 index 0000000..b4af052 --- /dev/null +++ b/api-reference/getting-started/agent-as-a-lab-contributor.md @@ -0,0 +1,434 @@ +--- +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 +--- + +# 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. + +{% 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. Unfamiliar with a term used here? See the [Glossary](../../references/glossary.md). +{% 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; 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. + +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: + +```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 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"`. `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 [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"; + +const signInMessage = await graphql( + `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { + getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message expiresAt } + }`, + { 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 + +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. + +{% 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` (`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( + 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)); + } + } +} + +// 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 [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( + `query Verify($oclId: String!) { + labWithDataRoomAndFiles(oclId: $oclId) { + shortname + dataRoom { files { path accessLevel version createdBy } } + } + }`, + { oclId }, +); +``` + +**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 + +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, 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; +} + +// `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; +} + +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 in Shared Setup, which retries NOT_FOUND after a mint. +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 sleep(delay); + } + } +} + +async function main() { + const filePath = process.argv[2]; + 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 ---- + 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/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) ---- + 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/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 + // 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 expiresAt } + }`, + { 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/5 Token issued, expires", tokenResult.generateServiceToken.expiresAt); + + // ---- 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); + const { datasetId } = await withIndexerLagRetry(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; + }, { codes: ["UNAUTHORIZED", "NOT_FOUND"] }); + 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 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( + "5/5 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 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 agent-as-a-lab-contributor.js ./findings.csv +``` + +*** + +## Next + +| | | +| --- | --- | +| 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 new file mode 100644 index 0000000..732c127 --- /dev/null +++ b/api-reference/getting-started/create-lab-and-upload-file.md @@ -0,0 +1,649 @@ +--- +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 +--- + +# 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 [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. Unfamiliar with a term used here? See the [Glossary](../../references/glossary.md). +{% 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 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"; +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() }); + +// 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 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, +}); + +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 — 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` | +| `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. + +## 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": "lab-1274", + "labAccountAddress": "0x…", + "labNftTokenId": "1274" + } + } + } +} +``` + +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:** + +| `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 + +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" %} +**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) so the helper will retry until the record arrives. +{% endhint %} + +```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. 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 = {}; +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` — it is the file's stable identifier for later reads and updates. + +**If it fails:** + +| 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 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 | +| `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 + +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. 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 + +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; +} + +// `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; +} + +// 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 = 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)); + } + } +} + +async function main() { + const filePath = process.argv[2]; + 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() }); + 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 expiresAt } + }`, + { 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"); + const { labNftTokenId } = createLabResult.createLab.lab; + console.log("3/5 Lab registered — TBA:", createLabResult.createLab.lab.labAccountAddress); + + // ---- Step 4: upload the file ---- + const bytes = readFileSync(filePath); + // 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)); + 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); + // 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) => { + console.error(err); + process.exit(1); +}); +``` + +**Usage:** + +```bash +WALLET_PRIVATE_KEY="0x..." \ +CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" \ +node create-lab-and-upload-file.js ./research-data.csv +``` + +*** + +## Next + +| | | +| --- | --- | +| Make the next file confidential | [Upload an encrypted file](upload-encrypted-file.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 new file mode 100644 index 0000000..39ee742 --- /dev/null +++ b/api-reference/getting-started/for-agents.md @@ -0,0 +1,185 @@ +--- +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](README.md). + +## Constants + +Staging (Base Sepolia) — everything on this page runs against these: + +``` +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 (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 + +``` +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. +* **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. +* 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 + +```graphql +query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { + 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). `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) { + 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. + +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 }`. +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`: [Upload an encrypted file](upload-encrypted-file.md). + +## 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. 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. 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 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. + +## 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) — 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 +* [x402 Gateway](../x402-gateway.md) — pay per call, no service token diff --git a/api-reference/getting-started/shared-setup.md b/api-reference/getting-started/shared-setup.md new file mode 100644 index 0000000..2fde1c9 --- /dev/null +++ b/api-reference/getting-started/shared-setup.md @@ -0,0 +1,136 @@ +--- +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 | [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) | + +*** + +## Next + +| Next | Page | +| ---- | ---- | +| Prerequisites, costs and the ten-minute quickstart | [Getting Started](README.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 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 new file mode 100644 index 0000000..cea9878 --- /dev/null +++ b/api-reference/getting-started/upload-encrypted-file.md @@ -0,0 +1,668 @@ +--- +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 +--- + +# 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 [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). + +{% 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. Unfamiliar with a term used here? See the [Glossary](../../references/glossary.md). +{% 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 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 = { + 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" }, + }, +]); +``` + +`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 + +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( + `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 | +| `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 public file](create-lab-and-upload-file.md#step-4-upload-the-file) | + +## 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 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 +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; +} + +// `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; +} + +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 upload-encrypted-file.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 ---- + // 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 expiresAt } + }`, + { 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 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 upload-encrypted-file.js ./confidential-results.csv +``` + +*** + +## Next + +| | | +| --- | --- | +| 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/ipnft-api-deprecated.md b/api-reference/ipnft-api-deprecated.md deleted file mode 100644 index 6d3e3ef..0000000 --- a/api-reference/ipnft-api-deprecated.md +++ /dev/null @@ -1,1443 +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 - -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__`) - -### 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/api-reference/labs-api/README.md b/api-reference/labs-api/README.md index 16e2fad..0832ffe 100644 --- a/api-reference/labs-api/README.md +++ b/api-reference/labs-api/README.md @@ -12,15 +12,27 @@ 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 | +| **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) | --- ## 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: [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. --- @@ -93,14 +105,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})`); } @@ -122,7 +149,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 @@ -130,13 +157,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, moves, deletes) need **Contributor**; `createLab` and the LabNFT-metadata mutations need **Owner** +- 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:** @@ -202,12 +231,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](../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/browse-and-search.md b/api-reference/labs-api/browse-and-search.md index e2d96fc..2f7c847 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 @@ -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:** @@ -84,11 +84,11 @@ 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 activity 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: Bearer`) - no Service Token is needed. +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`. -> **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. +> **🔓 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. **GraphQL Query:** @@ -159,31 +159,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 +171,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,18 +181,16 @@ 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 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. - -> **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. +> **🔓 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. **GraphQL Query:** @@ -272,24 +250,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 +261,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 +298,6 @@ query SearchLabs( } } } - ... on SearchLabsAnnouncementHit { - announcement { - id - headline - body - systemTime - attachments { - id - did - path - name - contentType - accessLevel - } - } - lab { - oclId - shortname - } - } } totalCount pageInfo { @@ -379,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 | @@ -394,9 +336,8 @@ 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 } } }", + "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, @@ -411,7 +352,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": { @@ -431,9 +371,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:** @@ -443,7 +380,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!) { @@ -456,17 +392,6 @@ const searchResults = await fetch(apiUrl, { file { description tags } } } - ... on SearchLabsAnnouncementHit { - announcement { - headline - attachments { - did - path - contentType - accessLevel - } - } - } } totalCount } @@ -480,13 +405,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); } }); ``` @@ -497,7 +416,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 +435,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 +459,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..15fb0dc 100644 --- a/api-reference/labs-api/example-workflow.md +++ b/api-reference/labs-api/example-workflow.md @@ -1,755 +1,19 @@ -# Example Workflow: Authenticate, Mint, Create, Sign, Upload +--- +description: This page moved. The walkthrough is now three tutorials under Getting Started. +hidden: true +icon: signs-post +--- -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. +# Example Workflow (moved) -> **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). +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. -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. +| 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 | -## Prerequisites +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). -* 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`) - -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). - -```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 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; -} -``` - -## 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 — Obtaining Tokens](service-tokens.md#obtaining-tokens). - -```javascript -import { createPublicClient, createWalletClient, http } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; - -const SERVICE_NAME = "example-workflow-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; -``` - -`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. - -## 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)", -]); - -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; -``` - -## Step 3: Create the Lab - -Register the Kamu-backed dataroom 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"); -const { labAccountAddress } = createLabResult.createLab.lab; -``` - -## Step 4: Sign the Assignment Agreement - -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). - -```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), - }, -}); - -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"); -``` - -## Step 5: Upload a File (Encrypted) - -> **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. - -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). - -```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" }, - }, -]); - -// 5d. Standard three-step upload, ciphertext in place of the raw file -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, 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 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 - error { code message requestId retryable details } - } - }`, - { - oclId, - uploadToken, - path: basename(filePath), - accessLevel: "HOLDERS", // DataRoomAccessLevel: PUBLIC | HOLDERS | ADMIN — encrypted files use HOLDERS or 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(), - }, - }, -); -assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); -console.log("Uploaded. datasetId:", finishResult.finishCreateOrUpdateFile.datasetId); -``` - -*** - -## Complete Script - -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. - -```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` - -// ---- 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 SERVICE_NAME = "example-workflow-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})`); - } - return result; -} - -async function main() { - const filePath = process.argv[2]; - if (!filePath) throw new Error("Usage: node workflow.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 ---- - 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 - message - 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 — oclId:", oclId); - - // ---- Step 3: Create the Lab ---- - const createLabResult = await graphql( - `mutation CreateLab($oclId: String!) { - createLab(input: { oclId: $oclId }) { - message - error { code message requestId retryable details } - lab { labAccountAddress } - } - }`, - { 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" }, - }, - ]); - - const initiateResult = await graphql( - `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) { - initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { - uploadToken - uploadUrl - headers { key value } - error { code message requestId retryable details } - } - }`, - { oclId, contentType: "application/octet-stream", contentLength: ciphertext.length }, - ); - assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile"); - const { uploadToken, uploadUrl, 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 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 - 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, - encryptedBy: account.address.toLowerCase(), - encryptedAt: new Date().toISOString(), - }, - }, - ); - assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile"); - console.log("5/5 File uploaded — datasetId:", finishResult.finishCreateOrUpdateFile.datasetId); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); -``` - -**Usage:** - -```bash -WALLET_PRIVATE_KEY="0x..." CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" node workflow.js ./research-data.csv -``` - -*** - -## 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: - -| 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"` | - -```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"; -``` - -A few things that follow automatically from that swap and don't need separate handling: - -* **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. - -What doesn't follow automatically, and is on you to handle: - -* **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. -* 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, [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/files.md b/api-reference/labs-api/files.md index c1a8689..b690713 100644 --- a/api-reference/labs-api/files.md +++ b/api-reference/labs-api/files.md @@ -1,6 +1,8 @@ # 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 [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. @@ -433,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. @@ -536,7 +478,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 | @@ -648,7 +591,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 +649,8 @@ Enhance file discoverability with optional metadata: ## Advanced: Encrypted File Upload +> **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. ### 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..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 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 [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 @@ -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 @@ -216,10 +216,12 @@ 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):** +> 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: [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. 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..f6b2f5c 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`. @@ -234,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 859de21..e8f76b4 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 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. **Step 1 — Get the sign-in message (`getServiceSignInMessage`):** @@ -15,6 +19,7 @@ query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { serviceName: $serviceName ) { message + expiresAt } } ``` @@ -26,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` 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 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( @@ -65,15 +84,36 @@ 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. **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. +**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 | +| -------- | ------------- | --- | +| `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), **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". 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 -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 +145,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 +160,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 +186,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..a7b0a65 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) @@ -196,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 | @@ -226,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 @@ -287,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 *** diff --git a/api-reference/x402-gateway.md b/api-reference/x402-gateway.md index 6c4ae2c..ca5221f 100644 --- a/api-reference/x402-gateway.md +++ b/api-reference/x402-gateway.md @@ -25,22 +25,37 @@ 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 | | -------------------------------------------- | ------------------------------ | -------------------------------------------------------- | | `/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 | +| `/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`). @@ -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,21 +114,117 @@ 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/createLab \ + -H 'Content-Type: application/json' \ + -d '{ + "query": "mutation CreateLab($oclId: String!) { createLab(input: { oclId: $oclId }) { message error { code message requestId retryable details } } }", + "variables": { "oclId": "0x0101…" } + }' +``` + +**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/createLab", + "description": "x402 payment for createLab", + "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 -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" } ``` @@ -133,20 +244,22 @@ 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 ``` -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 +282,23 @@ 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) — 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 - [x402 specification](https://www.x402.org/) 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/references/glossary.md b/references/glossary.md new file mode 100644 index 0000000..efb104e --- /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`** — 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. + +*** + +## 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/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 2f477f1..fdc3953 100644 --- a/technical-deep-dive/roles-and-permissions.md +++ b/technical-deep-dive/roles-and-permissions.md @@ -7,9 +7,13 @@ 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. +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. @@ -31,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 | ✓ | | | @@ -56,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 b0235c2..115efd2 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 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 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. @@ -18,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. @@ -56,8 +57,8 @@ 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. -* **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`. +* **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. @@ -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](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**.