diff --git a/concepts/security-portal.mdx b/concepts/security-portal.mdx index 6e0b50b..3ad5dcb 100644 --- a/concepts/security-portal.mdx +++ b/concepts/security-portal.mdx @@ -1,6 +1,6 @@ --- title: "Security Portal" -description: "Understanding your organization's public-facing threat reporting page" +description: "Configure your organization's public Security Portal at chainpatrol.io/ — visibility, public reports, metrics, brand metadata, and customization." --- ## Overview @@ -111,6 +111,18 @@ The report processing flow works as follows: From there, the report goes through the same review process as any other submission. +## Reading Portal Configuration via the API + +Each org's portal configuration is exposed on the [`POST /user/orgs`](/external-api/user-orgs-list) and [`GET /user/orgs/{slug}`](/external-api/user-orgs-get) endpoints (and on `UserOrg` in the JavaScript SDK). The response includes: + +- `portalVisibility` — `PUBLIC`, `PRIVATE`, or `DISABLED`, mirroring the three states above. +- `reportVisibility` — `PUBLIC` or `PRIVATE`, matching the **Public Reports** toggle. +- `metricsVisibility` — `PUBLIC`, `PRIVATE`, or `DISABLED`, matching the **Public Metrics** toggle. +- `brandMetadata` — the public brand-facing fields shown on the portal (`websiteUrl`, `twitterHandle`, `securityContactEmail`, `industry`). +- `portalCustomization` — the customer-configurable presentation (`useCustomDetails`, custom `name` / `slug` / `avatarUrl`, and `bannerEnabled` / `bannerContent`). + +Use these fields to audit portal setup across every org you administer without opening the dashboard. + ## Key Takeaways - Community reporting acts as an early warning system: Users often encounter threats before your internal team, so a public security portal captures intelligence from the front lines diff --git a/docs.json b/docs.json index 225a98f..f1d02af 100644 --- a/docs.json +++ b/docs.json @@ -163,6 +163,13 @@ "external-api/organization-metrics", "external-api/organization-metrics-list" ] + }, + { + "group": "User", + "pages": [ + "external-api/user-orgs-list", + "external-api/user-orgs-get" + ] } ] }, diff --git a/external-api/user-orgs-get.mdx b/external-api/user-orgs-get.mdx new file mode 100644 index 0000000..cdcb383 --- /dev/null +++ b/external-api/user-orgs-get.mdx @@ -0,0 +1,106 @@ +--- +title: Get User Organization +openapi: "GET /user/orgs/{slug}" +--- + +## Overview + +Fetch a single organization by slug. Returns the same per-organization shape as [`POST /user/orgs`](/external-api/user-orgs-list), including subscription status, enabled services, integrations, legal documents, trademark registrations, and the org's public [Security Portal](/concepts/security-portal) configuration. + +Use this endpoint when you already know the target slug and want a single record instead of paging the full list. + +Access requires the caller to have permission for the org: + +- **Org-scoped API keys** must match the requested slug exactly. +- **User sessions and user-scoped API keys** need an active membership on the target org (unless the caller is staff). + +Soft-deleted organizations are excluded. + +## Portal, brand, and customization fields + +The response includes the same portal-related fields as the list endpoint. See [`POST /user/orgs`](/external-api/user-orgs-list#portal-brand-and-customization-fields) for the full field reference and the [Security Portal concept](/concepts/security-portal) for what the visibility values mean end-to-end. + +At a glance: + +- `portalVisibility` (`PUBLIC` / `PRIVATE` / `DISABLED`) — whether the portal at `chainpatrol.io/` is discoverable, report-form-only, or 404s. +- `reportVisibility` (`PUBLIC` / `PRIVATE`) — whether the report list is shown on the portal. +- `metricsVisibility` (`PUBLIC` / `PRIVATE` / `DISABLED`) — whether the metrics tab is shown (and whether metrics are computed at all). +- `brandMetadata` — nullable `websiteUrl`, `twitterHandle`, `securityContactEmail`, `industry`. +- `portalCustomization` — `useCustomDetails`, custom `name` / `slug` / `avatarUrl`, and `bannerEnabled` / `bannerContent`. + +## Example request + +```bash cURL +curl -X GET 'https://app.chainpatrol.io/api/v2/user/orgs/acme' \ + -H 'X-API-KEY: YOUR_API_KEY_HERE' +``` + +```typescript TypeScript +const res = await fetch( + "https://app.chainpatrol.io/api/v2/user/orgs/acme", + { + method: "GET", + headers: { "X-API-KEY": "YOUR_API_KEY_HERE" }, + }, +); + +const org = await res.json(); +console.log(org.portalVisibility, org.brandMetadata.securityContactEmail); +``` + +## Example response + +```json +{ + "id": 42, + "name": "Acme Protocol", + "slug": "acme", + "avatarUrl": "https://…/acme.png", + "subscriptionStatus": "ACTIVE", + "portalVisibility": "PRIVATE", + "reportVisibility": "PRIVATE", + "metricsVisibility": "PUBLIC", + "brandMetadata": { + "websiteUrl": "https://acme.example", + "twitterHandle": "@acme", + "securityContactEmail": "security@acme.example", + "industry": "DeFi" + }, + "portalCustomization": { + "useCustomDetails": true, + "name": "Acme Security", + "slug": "acme-security", + "avatarUrl": "https://…/acme-security.png", + "bannerEnabled": false, + "bannerContent": null + }, + "services": { + "reporting": { "active": true }, + "reviewing": { "active": true }, + "protection": { "active": true }, + "takedowns": { "active": true, "automated": true }, + "detection": { "active": true }, + "darkWebMonitoring": { "active": false } + }, + "obligatoryAdminApproval": true, + "integrations": { + "slack": true, + "discord": true, + "telegram": { "connected": false, "groupCount": 0 }, + "vercel": false, + "intercom": false, + "moderation": false + }, + "legalDocuments": { + "letterOfAuthorization": { "present": true, "fileName": "loa.pdf", "fileUrl": "https://…" }, + "powerOfAttorney": { "present": true, "fileName": "poa.pdf", "fileUrl": "https://…" } + }, + "trademarkRegistrations": [] +} +``` + +## Notes + +- The `slug` in the URL path must be the org's own slug. Custom portal slugs from `portalCustomization.slug` do not resolve here. +- The response is additive — clients that only read the pre-existing fields are unaffected by the portal, brand-metadata, and customization additions. +- To iterate every org the caller can access, use [`POST /user/orgs`](/external-api/user-orgs-list) instead. diff --git a/external-api/user-orgs-list.mdx b/external-api/user-orgs-list.mdx new file mode 100644 index 0000000..0ca2b5a --- /dev/null +++ b/external-api/user-orgs-list.mdx @@ -0,0 +1,189 @@ +--- +title: List User Organizations +openapi: "POST /user/orgs" +--- + +## Overview + +List every organization the authenticated caller belongs to, along with each organization's subscription status, enabled services, integration connection status, legal documents, trademark registrations, and — most relevant to this page — the configuration for the org's public [Security Portal](/concepts/security-portal). + +Use this endpoint to: + +- Discover which organizations an API key or user session can act on. +- Audit portal visibility, brand-facing metadata, and portal customization across every org you administer. +- Filter the list server-side by subscription status, active services, or the "Obligatory Organization Admin Approval" setting. + +The organization identity is inferred from your API key or user session. User API keys and user sessions return every org the user is a member of; org-scoped API keys return the single matching org. Soft-deleted and `INACTIVE` organizations are always excluded. + +## Portal, brand, and customization fields + +Each entry in the response describes how the org's public Security Portal at `chainpatrol.io/` is configured. See the [Security Portal concept](/concepts/security-portal) for the visibility model and where these values are edited in the dashboard. + +### `portalVisibility` + +Top-level visibility of the portal. + +| Value | Behavior | +| ---------- | ----------------------------------------------------------------------------------------------- | +| `PUBLIC` | Portal is fully discoverable — metrics, listings, and the report form all render. | +| `PRIVATE` | Only the report form renders. Metrics and asset listings are hidden even to authenticated users. | +| `DISABLED` | `chainpatrol.io/` returns a 404. No portal is served. | + +### `reportVisibility` + +Whether the reports list on the portal is visible to visitors. `PUBLIC` shows the org's reports; `PRIVATE` hides the report list even when the portal itself is discoverable. + +### `metricsVisibility` + +Whether the metrics and threats-blocked stats render on the portal. `PUBLIC` shows the metrics tab; `PRIVATE` hides it from anonymous visitors; `DISABLED` means metrics are not computed for this org at all. + +### `brandMetadata` + +Public brand-facing metadata that renders on the portal header and is used by ChainPatrol staff and takedown providers for outbound contact. Every field is nullable — expect gaps for orgs still in onboarding. + +| Field | Type | Description | +| ---------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------- | +| `websiteUrl` | `string \| null` | Public website URL of the brand. | +| `twitterHandle` | `string \| null` | Public Twitter / X handle, stored with the leading `@` (for example `@acme`). | +| `securityContactEmail` | `string \| null` | Email address for security correspondence, distinct from the internal customer POC email. | +| `industry` | `string \| null` | Free-form industry label (for example `"DeFi"`, `"CEX"`, `"NFT"`, `"Wallet"`). | + +### `portalCustomization` + +Customer-configurable presentation of the portal. When `useCustomDetails` is `false` the portal uses the org's own name, slug, and avatar; when `true` the fields below override them. + +| Field | Type | Description | +| ------------------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `useCustomDetails` | `boolean` | When `true`, the portal uses `name`, `slug`, and `avatarUrl` from this object instead of the org's own values. | +| `name` | `string \| null` | Custom display name (used only when `useCustomDetails=true`). | +| `slug` | `string \| null` | Custom URL slug (`chainpatrol.io/`). Takes precedence over the org's own slug when set, regardless of `useCustomDetails`. | +| `avatarUrl` | `string \| null` | Custom avatar URL (used only when `useCustomDetails=true`). | +| `bannerEnabled` | `boolean` | When `true`, the portal renders `bannerContent` as a highlighted banner at the top of the page. | +| `bannerContent` | `string \| null` | Markdown body of the portal banner. Null or empty with `bannerEnabled=true` means the toggle is on but no content has been written yet. | + + + A `bannerEnabled=true` with a null or empty `bannerContent` is a common audit finding — the banner is toggled on but never populated. + + +## Example request + +```bash cURL +curl -X POST 'https://app.chainpatrol.io/api/v2/user/orgs' \ + -H 'X-API-KEY: YOUR_API_KEY_HERE' \ + -H 'Content-Type: application/json' \ + -d '{}' +``` + +## Example response + +```json +{ + "organizations": [ + { + "id": 42, + "name": "Acme Protocol", + "slug": "acme", + "avatarUrl": "https://…/acme.png", + "subscriptionStatus": "ACTIVE", + "portalVisibility": "PUBLIC", + "reportVisibility": "PUBLIC", + "metricsVisibility": "PUBLIC", + "brandMetadata": { + "websiteUrl": "https://acme.example", + "twitterHandle": "@acme", + "securityContactEmail": "security@acme.example", + "industry": "DeFi" + }, + "portalCustomization": { + "useCustomDetails": false, + "name": null, + "slug": null, + "avatarUrl": null, + "bannerEnabled": true, + "bannerContent": "Report suspicious Acme domains here — we act within 24h." + }, + "services": { + "reporting": { "active": true }, + "reviewing": { "active": true }, + "protection": { "active": true }, + "takedowns": { "active": true, "automated": false }, + "detection": { "active": true }, + "darkWebMonitoring": { "active": false } + }, + "obligatoryAdminApproval": false, + "integrations": { + "slack": true, + "discord": false, + "telegram": { "connected": true, "groupCount": 2 }, + "vercel": false, + "intercom": false, + "moderation": true + }, + "legalDocuments": { + "letterOfAuthorization": { "present": true, "fileName": "loa.pdf", "fileUrl": "https://…" }, + "powerOfAttorney": { "present": false, "fileName": null, "fileUrl": null } + }, + "trademarkRegistrations": [] + } + ] +} +``` + +## Use cases + +### Audit portal visibility across your orgs + +Fetch every org you have access to and group them by portal visibility to answer "which customers have a public portal?" and "which portals are turned off?". + +```typescript +const res = await fetch("https://app.chainpatrol.io/api/v2/user/orgs", { + method: "POST", + headers: { + "X-API-KEY": "YOUR_API_KEY_HERE", + "Content-Type": "application/json", + }, + body: JSON.stringify({}), +}); + +const { organizations } = await res.json(); + +const byVisibility = organizations.reduce((acc, org) => { + (acc[org.portalVisibility] ??= []).push(org.slug); + return acc; +}, {} as Record); + +console.log(byVisibility); +// { PUBLIC: ["acme", "beta"], PRIVATE: ["gamma"], DISABLED: ["delta"] } +``` + +### Find orgs missing a security contact + +`brandMetadata.securityContactEmail` is used by takedown providers and researchers, so surfacing empty values is a common quality check. + +```typescript +const missing = organizations.filter( + (org) => !org.brandMetadata.securityContactEmail, +); + +console.log( + "Orgs without a security contact:", + missing.map((o) => o.slug), +); +``` + +### Detect orgs with a banner toggled on but empty content + +```typescript +const emptyBanner = organizations.filter( + (org) => + org.portalCustomization.bannerEnabled && + !org.portalCustomization.bannerContent?.trim(), +); +``` + +## Notes + +- The response is additive — existing integrations that don't reference the new fields continue to work unchanged. +- `portalVisibility`, `reportVisibility`, and `metricsVisibility` are independent switches. A portal can be `PUBLIC` overall while individually hiding reports or metrics. +- `portalCustomization.slug` takes precedence over the org's own slug whenever it is set — even if `useCustomDetails` is `false`. +- Use [`GET /user/orgs/{slug}`](/external-api/user-orgs-get) to fetch a single org's record by slug when you already know which one you need.