diff --git a/.speakeasy/gen.lock b/.speakeasy/gen.lock index 1ed72d1..2fdc7b4 100644 --- a/.speakeasy/gen.lock +++ b/.speakeasy/gen.lock @@ -1,12 +1,12 @@ lockVersion: 2.0.0 id: f6fec850-ef66-45dd-9a18-cec7cc0801ce management: - docChecksum: a23b8f20e57fbf8dec16be6319a7398f + docChecksum: 1f41f7f3f1f9652138167d27f8b4518c docVersion: 1.1.0 speakeasyVersion: internal generationVersion: internal - releaseVersion: 0.3.3 - configChecksum: a95df217f2ce238441199a6ea3f715fe + releaseVersion: 0.4.0 + configChecksum: 4273d5885cbbeaedeb3ad7cdeb96d994 repoURL: https://github.com/moderation-api/cli installationURL: https://github.com/moderation-api/cli published: true @@ -96,6 +96,10 @@ trackedFiles: last_write_checksum: sha1:5db89edd5a5f6b7867090c973744f629e4498422 internal/cli/masking.go: last_write_checksum: sha1:5a69fa055d628c1b8507cd123f3cd18309aa9a53 + internal/cli/projects/apikeys/create.go: + last_write_checksum: sha1:535e92c12f0efd0833064bfb4ffb4600b0757c1b + internal/cli/projects/apikeys/root.go: + last_write_checksum: sha1:33a648830982cc6f4cfcc3a561c2848af3e336ff internal/cli/projects/create.go: last_write_checksum: sha1:2838c87019ac2cc223f135e088213eff20d185c9 internal/cli/projects/delete.go: @@ -107,7 +111,7 @@ trackedFiles: internal/cli/projects/retrieve.go: last_write_checksum: sha1:dc13d97de4946558f0675a5044cb0e243f22a4a5 internal/cli/projects/root.go: - last_write_checksum: sha1:643c0a49b24d09ea96931240c34e98daaa1b390d + last_write_checksum: sha1:4145379bf6cfa4963ef6b41beaf5c568333ce73f internal/cli/projects/update.go: last_write_checksum: sha1:86360b44e47dfff918db06784b723a60a2892fe7 internal/cli/queue/getstats.go: @@ -127,7 +131,7 @@ trackedFiles: internal/cli/root.go: last_write_checksum: sha1:d00799a3804f87479242f16bf520cb593f79ba8e internal/cli/version.go: - last_write_checksum: sha1:a77aa9a27f89f066ed45081e1f453d5f8780ac78 + last_write_checksum: sha1:a8ab87a20abe46d2c0f4928cafee3c713021c841 internal/cli/webhooks/create.go: last_write_checksum: sha1:98c73266c8954efcf0da2493ae41909df45a38f0 internal/cli/webhooks/delete.go: @@ -224,6 +228,8 @@ trackedFiles: last_write_checksum: sha1:c73d83f641b59a97479506597334a95539d34e16 internal/sdk/actions.go: last_write_checksum: sha1:d6f0d54d24673ae13bd908680018b71e7d520820 + internal/sdk/apikeys.go: + last_write_checksum: sha1:eb2143b8ecfa3044b599462841bd90d70671bf3b internal/sdk/authors.go: last_write_checksum: sha1:01eb0e9f6fa3fd9987edb54429293271203c7ec6 internal/sdk/channels.go: @@ -254,6 +260,8 @@ trackedFiles: last_write_checksum: sha1:99268e4c4433540e746d421b6a16be06e2458770 internal/sdk/models/components/project.go: last_write_checksum: sha1:be428edb928147e28c4c15c0ebdef472b6c25461 + internal/sdk/models/components/projectapikey.go: + last_write_checksum: sha1:4c561bd766b8db2042e9e6da609e3dccbfa75685 internal/sdk/models/components/projectwithkey.go: last_write_checksum: sha1:a8186b049b303d92ef01688eca3e05ea9dabef2b internal/sdk/models/components/publicauthor.go: @@ -272,6 +280,8 @@ trackedFiles: last_write_checksum: sha1:7418ba8327dcfd0855b6e1eb972bd8581a468635 internal/sdk/models/operations/createproject.go: last_write_checksum: sha1:1387c903814ffe16e3683916acff2216f912fa6a + internal/sdk/models/operations/createprojectapikey.go: + last_write_checksum: sha1:5a1c5f987689e312de108a7888b86c7353b0eec6 internal/sdk/models/operations/createwebhook.go: last_write_checksum: sha1:93c0a33438a7034b3a8f2d689b168ee254c42e79 internal/sdk/models/operations/deleteaction.go: @@ -367,13 +377,13 @@ trackedFiles: internal/sdk/models/sdkerrors/sdkdefaulterror.go: last_write_checksum: sha1:437b3a3eacb54d8648a68f80b3cd38fa5019f006 internal/sdk/moderationapi.go: - last_write_checksum: sha1:f1f147d2cf835423ff61ed5b03e4a8b2cd08a56c + last_write_checksum: sha1:dddc315a89c2907ff24d91c28784ed97fe738c5f internal/sdk/optionalnullable/optionalnullable.go: last_write_checksum: sha1:b828c94c45116e632925027e4e2c211e971e04e2 internal/sdk/optionalnullable/optionalnullable_test.go: last_write_checksum: sha1:d3991667754ed67db803aeab4a6239f6f9a1be37 internal/sdk/projects.go: - last_write_checksum: sha1:6c561630b561c50f8d0758c744f9006180e2e77b + last_write_checksum: sha1:77b021be8e1ea6b2d2aef13a536a922d0b614bb9 internal/sdk/queue.go: last_write_checksum: sha1:e7ad9e07ed4c6f302ac8b7123a7c4e8d9b4e034e internal/sdk/retry/config.go: @@ -427,9 +437,9 @@ trackedFiles: internal/testclient/testclient.go: last_write_checksum: sha1:7107e59d336235c44af152c5a61ee3896a7841fa internal/usage/bodyschemas.go: - last_write_checksum: sha1:06404c8b3b7c87ea76f8722c2aeaa1d17baac650 + last_write_checksum: sha1:f766420e385b255888deecba3146cc31d22dc3d3 internal/usage/schema.go: - last_write_checksum: sha1:7d18cd61a609d94a72ef1c29a10141425c2d2620 + last_write_checksum: sha1:04bfae1bd9a42fc9ffc14b31e09bff0d4c326b65 scripts/install.ps1: last_write_checksum: sha1:bd3a52c28f647ed881c6e8d72abf2d84216976c9 scripts/install.sh: @@ -1184,4 +1194,24 @@ examples: application/json: {"type": "not_found_error", "code": "resource_not_found", "message": "Not found", "request_id": "h8fkz6kkzoa446ffve37atys", "doc_url": "https://insecure-premium.com/"} "429": application/json: {"type": "rate_limit_error", "code": "rate_limit_exceeded", "message": "Rate limit exceeded", "request_id": "h8fkz6kkzoa446ffve37atys", "doc_url": "https://upset-hydrant.info/"} + createProjectApiKey: + speakeasy-default-create-project-api-key: + parameters: + path: + id: "" + requestBody: + application/json: {"name": "API key"} + responses: + "200": + application/json: {"id": "", "name": "", "projectId": "", "createdAt": "1711588645112", "secretKey": ""} + "400": + application/json: {"type": "invalid_request_error", "code": "invalid_request", "message": "Invalid input data", "request_id": "h8fkz6kkzoa446ffve37atys", "doc_url": "https://rusty-responsibility.com"} + "401": + application/json: {"type": "authentication_error", "code": "unauthorized", "message": "Authorization not provided", "request_id": "h8fkz6kkzoa446ffve37atys", "doc_url": "https://compassionate-chow.net"} + "403": + application/json: {"type": "permission_error", "code": "permission_denied", "message": "Insufficient access", "request_id": "h8fkz6kkzoa446ffve37atys", "doc_url": "https://buzzing-convection.com/"} + "404": + application/json: {"type": "not_found_error", "code": "resource_not_found", "message": "Not found", "request_id": "h8fkz6kkzoa446ffve37atys", "doc_url": "https://impractical-bin.info/"} + "429": + application/json: {"type": "rate_limit_error", "code": "rate_limit_exceeded", "message": "Rate limit exceeded", "request_id": "h8fkz6kkzoa446ffve37atys", "doc_url": "https://damaged-bandwidth.name"} examplesVersion: 1.0.2 diff --git a/.speakeasy/gen.yaml b/.speakeasy/gen.yaml index 343023a..0c9a6f8 100644 --- a/.speakeasy/gen.yaml +++ b/.speakeasy/gen.yaml @@ -32,7 +32,7 @@ generation: generateNewTests: true skipResponseBodyAssertions: false cli: - version: 0.3.3 + version: 0.4.0 additionalDependencies: {} agentEnvironmentDetection: true arrayFlagFormat: repeatable diff --git a/.speakeasy/openapi.json b/.speakeasy/openapi.json index e418a0c..db3f0fc 100644 --- a/.speakeasy/openapi.json +++ b/.speakeasy/openapi.json @@ -10002,6 +10002,124 @@ } } }, + "/projects/{id}/api-keys": { + "post": { + "operationId": "createProjectApiKey", + "summary": "Create a project API key", + "description": "Create a new secret API key for a project and return it. Give each app or computer its own key, so you can revoke one without breaking the others. The project's existing keys keep working. Needs the `projects:write` permission.", + "tags": [ + "Projects" + ], + "security": [ + { + "Authorization": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "description": "The ID of the project." + }, + "required": true, + "description": "The ID of the project." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "default": "API key", + "description": "A name for the key, shown on the API keys page of the dashboard. Name it after where the key is used, for example the app or the computer.", + "type": "string", + "minLength": 1, + "maxLength": 50 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "API key created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectApiKey" + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthenticationError" + } + } + } + }, + "403": { + "description": "Insufficient access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PermissionDeniedError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + }, + "429": { + "description": "Too many requests. Retry after the number of seconds in the `Retry-After` header.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitError" + } + } + } + } + }, + "x-speakeasy-group": "projects.apiKeys", + "x-speakeasy-name-override": "create", + "x-speakeasy-mcp": { + "title": "Create a project API key", + "scopes": [ + "write" + ], + "readOnlyHint": false, + "destructiveHint": false + } + } + }, "/projects/{projectId}/channels": { "get": { "operationId": "listChannels", @@ -11314,6 +11432,39 @@ ], "additionalProperties": false }, + "ProjectApiKey": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The ID of the API key." + }, + "name": { + "type": "string", + "description": "The key's name." + }, + "projectId": { + "type": "string", + "description": "The ID of the project the key belongs to." + }, + "createdAt": { + "description": "When the key was created.", + "type": "string" + }, + "secretKey": { + "type": "string", + "description": "The new secret API key. Use it to moderate content in this project. It is also shown in the project's settings." + } + }, + "required": [ + "id", + "name", + "projectId", + "createdAt", + "secretKey" + ], + "additionalProperties": false + }, "Channel": { "type": "object", "properties": { diff --git a/CHANGELOG.md b/CHANGELOG.md index 107fa49..60b7bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.4.0 (2026-10-23) + +Full Changelog: [v0.3.3...v0.4.0](https://github.com/moderation-api/cli/compare/v0.3.3...v0.4.0) + +### API changes + +- `POST /projects/{id}/api-keys`: endpoint added + ## 0.3.3 (2026-10-23) Full Changelog: [v0.3.2...v0.3.3](https://github.com/moderation-api/cli/compare/v0.3.2...v0.3.3) diff --git a/README.md b/README.md index d42c1d5..0b1ca4d 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,8 @@ modapi projects list --secret-key "$MODAPI_ORGANIZATION_KEY" * [`update`](docs/modapi_projects_update.md) - Update a project * [`delete`](docs/modapi_projects_delete.md) - Delete a project * [`duplicate`](docs/modapi_projects_duplicate.md) - Duplicate a project + * [`api-keys`](docs/modapi_projects_api-keys.md) - Operations for api-keys + * [`create`](docs/modapi_projects_api-keys_create.md) - Create a project API key * [`channels`](docs/modapi_channels.md) - Operations for channels * [`list`](docs/modapi_channels_list.md) - List channels * [`create`](docs/modapi_channels_create.md) - Create a channel diff --git a/internal/cli/projects/apikeys/create.go b/internal/cli/projects/apikeys/create.go new file mode 100644 index 0000000..599c01d --- /dev/null +++ b/internal/cli/projects/apikeys/create.go @@ -0,0 +1,105 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package apikeys + +import ( + "fmt" + "github.com/moderation-api/cli/internal/client" + "github.com/moderation-api/cli/internal/flagutil" + "github.com/moderation-api/cli/internal/interactive" + "github.com/moderation-api/cli/internal/output" + "github.com/moderation-api/cli/internal/sdk" + "github.com/moderation-api/cli/internal/sdk/models/operations" + "github.com/moderation-api/cli/internal/usage" + "github.com/spf13/cobra" +) + +var createCmdMeta = []flagutil.FlagMeta{ + {FlagName: "id", Shorthand: "i", FieldPath: "ID", Kind: flagutil.FlagKindString, Required: true, Description: "The ID of the project. [required]"}, + {FlagName: "name", Shorthand: "n", FieldPath: "Body.Name", Kind: flagutil.FlagKindString, Optional: true, HasDefault: true, DefaultStr: "API key", MinLength: 1, HasMaxLength: true, MaxLength: 50, Description: "A name for the key, shown on the API keys page of the dashboard. Name it after where the key is used, for example the app or the computer."}, +} + +// initCreateCmd initializes the create command. +func initCreateCmd(parent *cobra.Command) error { + var cmd = &cobra.Command{ + Use: "create [id]", + Short: "Create a project API key", + Long: "Create a new secret API key for a project and return it. Give each app or computer its own key, so you can revoke one without breaking the others. The project's existing keys keep working. Needs the `projects:write` permission.", + Example: " modapi projects api-keys create --id ", + Args: flagutil.PositionalFlagArgs, + RunE: runCreateCmd, + Annotations: map[string]string{ + "speakeasy_operation": "createProjectApiKey", + }, + } + flagutil.RegisterFlags(cmd, createCmdMeta) + if err := flagutil.ValidateMeta[operations.CreateProjectAPIKeyRequest](createCmdMeta); err != nil { + return fmt.Errorf("invalid metadata for create: %w", err) + } + cmd.Flags().String("body", "", "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.") + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: false, Kind: "json", BodyFlag: true}) + cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" + if err := flagutil.AnnotateBodyFields(cmd, createCmdMeta, "Body", "body"); err != nil { + return fmt.Errorf("annotate body fields for create: %w", err) + } + cmd.Flags().Bool("schema", false, "Print the exact JSON Schema of the request body and exit") + _ = flagutil.AnnotatePromptFlag(cmd, "schema", flagutil.PromptFlagSpec{Kind: "bool", DocSurface: true}) + if err := flagutil.DeclarePositionalFlag(cmd, "id", "The ID of the project. (or pass it as the [id] argument)", true); err != nil { + return err + } + if err := interactive.Declare(cmd, interactive.CommandSpec{Args: []interactive.ArgSpec{ + {Name: "id", Summary: "The ID of the project.", Required: true, SatisfiedBy: []string{"id"}}, + }}); err != nil { + return fmt.Errorf("declare interactive arguments for create: %w", err) + } + parent.AddCommand(cmd) + return nil +} + +// runCreateCmd executes the create command. +func runCreateCmd(cmd *cobra.Command, args []string) error { + if usage.UsageRequested(cmd) { + return usage.EmitSchema(cmd, cmd.OutOrStdout()) + } + if requested, _ := cmd.Flags().GetBool("schema"); requested { + return usage.EmitBodySchema(cmd.OutOrStdout(), "createProjectApiKey") + } + if err := flagutil.ResolvePositionalFlag(cmd, args); err != nil { + return err + } + req, err := flagutil.BuildRequest[operations.CreateProjectAPIKeyRequest](cmd, createCmdMeta, "Body", "body") + if err != nil { + return flagutil.WithCLIValidation(err) + } + s, err := client.NewClient(cmd) + if err != nil { + return err + } + sdkOpts, err := output.PrepareCallOpts(cmd) + if err != nil { + return err + } + // Dry-run: force skip deserialization so the synthetic empty response + // does not cause parse failures in typed response handling. + if client.IsDryRun(cmd) { + sdkOpts = append(sdkOpts, operations.WithSkipDeserialization()) + } + if err := output.ValidateGlobalServerIndex(cmd, len(sdk.ServerList)); err != nil { + return err + } + if output.WantsRawJSON(cmd) { + sdkOpts = append(sdkOpts, operations.WithSkipDeserialization()) + } + stopProgress := output.StartRequestProgress(cmd) + defer stopProgress() + res, err := s.Projects.APIKeys.Create(cmd.Context(), *req, sdkOpts...) + stopProgress() + if err != nil { + return output.Error(cmd, err) + } + + if err := output.Result(cmd, res); err != nil { + return err + } + return nil +} diff --git a/internal/cli/projects/apikeys/root.go b/internal/cli/projects/apikeys/root.go new file mode 100644 index 0000000..7e21ade --- /dev/null +++ b/internal/cli/projects/apikeys/root.go @@ -0,0 +1,32 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package apikeys + +import ( + "github.com/moderation-api/cli/internal/usage" + "github.com/spf13/cobra" +) + +func InitApiKeysRoot(parent *cobra.Command) error { + var ApiKeysCmd = &cobra.Command{ + Use: "api-keys", + Short: "Operations for api-keys", + Long: "Operations for api-keys", + Args: cobra.NoArgs, + Annotations: map[string]string{"speakeasy_cli_group": "true"}, + RunE: func(cmd *cobra.Command, args []string) error { + if usage.UsageRequested(cmd) { + return usage.EmitSchema(cmd, cmd.OutOrStdout()) + } + return cmd.Help() + }, + Aliases: []string{"ak"}, + } + + if err := initCreateCmd(ApiKeysCmd); err != nil { + return err + } + + parent.AddCommand(ApiKeysCmd) + return nil +} diff --git a/internal/cli/projects/root.go b/internal/cli/projects/root.go index 36639fe..5aeba64 100644 --- a/internal/cli/projects/root.go +++ b/internal/cli/projects/root.go @@ -3,6 +3,7 @@ package projects import ( + "github.com/moderation-api/cli/internal/cli/projects/apikeys" "github.com/moderation-api/cli/internal/usage" "github.com/spf13/cobra" ) @@ -22,6 +23,10 @@ func InitProjectsRoot(parent *cobra.Command) error { }, } + if err := apikeys.InitApiKeysRoot(ProjectsCmd); err != nil { + return err + } + if err := initListCmd(ProjectsCmd); err != nil { return err } diff --git a/internal/cli/version.go b/internal/cli/version.go index 616339e..5d086d0 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -14,7 +14,7 @@ import ( // which propagates the value here (see cmd/modapi/main.go): // // go build -ldflags "-X main.version=x.y.z" ./cmd/modapi -var Version = "0.3.3" +var Version = "0.4.0" // BuildTime is optionally set at build time via ldflags targeting the main package. var BuildTime string diff --git a/internal/custom/commands_test.go b/internal/custom/commands_test.go index 15c59df..7ac2deb 100644 --- a/internal/custom/commands_test.go +++ b/internal/custom/commands_test.go @@ -25,6 +25,8 @@ type fakeAPI struct { *httptest.Server mu sync.Mutex requests []recorded + // projects is the JSON array GET /projects returns (default: none). + projects string } type recorded struct { @@ -50,10 +52,24 @@ func newFakeAPI(t *testing.T) *fakeAPI { return } _, _ = io.WriteString(w, `{"id":"proj_1","name":"`+body["name"].(string)+`","description":null,"domain":"acme.com","context":"A marketplace","globalFlaggingMode":"ACTIVE","enableRequestLogging":true,"casebookEnabled":true,"createdAt":"2026-10-22T00:00:00Z","updatedAt":"2026-10-22T00:00:00Z","secretKey":"sk_proj_new"}`) + case r.Method == http.MethodGet && r.URL.Path == "/projects": + projects := f.projects + if projects == "" { + projects = "[]" + } + _, _ = io.WriteString(w, `{"data":`+projects+`,"pagination":{"page":1,"pageSize":100,"total":0,"totalPages":1,"hasMore":false}}`) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/api-keys"): + if auth != "Bearer sk_org_test" { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"message":"bad key","code":"unauthorized"}`) + return + } + id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/projects/"), "/api-keys") + _, _ = io.WriteString(w, `{"id":"key_1","name":"`+body["name"].(string)+`","projectId":"`+id+`","createdAt":"2026-10-23T00:00:00Z","secretKey":"sk_proj_linked"}`) case r.URL.Path == "/webhook-secret": _, _ = io.WriteString(w, `{"secret":"whsec_new"}`) case r.URL.Path == "/account": - if auth != "Bearer sk_proj_new" && auth != "Bearer sk_proj_existing" { + if auth != "Bearer sk_proj_new" && auth != "Bearer sk_proj_existing" && auth != "Bearer sk_proj_linked" { w.WriteHeader(http.StatusUnauthorized) _, _ = io.WriteString(w, `{"message":"bad key","code":"unauthorized"}`) return @@ -151,8 +167,11 @@ func TestInitCreatesProjectAndWritesEnv(t *testing.T) { t.Fatalf(".env.local = %q, want %q", env, want) } + if paths := api.paths(); len(paths) < 2 || paths[0] != "GET /projects" || paths[1] != "POST /projects" { + t.Fatalf("requests = %v, want GET /projects (none to link), then POST /projects", paths) + } api.mu.Lock() - create := api.requests[0] + create := api.requests[1] api.mu.Unlock() if create.Path != "/projects" || create.Body["name"] != "market" || create.Body["brief"] != "No scams" || create.Body["usedByMinors"] != false { t.Fatalf("create request = %+v", create) @@ -209,7 +228,7 @@ func TestInitDryRunTouchesNothing(t *testing.T) { dir := setup(t) api := newFakeAPI(t) t.Setenv("MODAPI_ORGANIZATION_KEY", "sk_org_test") - stdout, _, code := run(t, "init", "--dir", dir, "--server-url", api.URL, "--dry-run", "-o", "json") + stdout, _, code := run(t, "init", "--dir", dir, "--server-url", api.URL, "--new", "--dry-run", "-o", "json") if code != 0 || !strings.Contains(stdout, `"would_create_project":true`) { t.Fatalf("exit %d: %s", code, stdout) } @@ -396,3 +415,132 @@ func TestDoctorListsProblemsWithoutAnErrorBlock(t *testing.T) { t.Fatalf("doctor printed an error block: %s", stderr) } } + +const twoProjects = `[ + {"id":"proj_1","name":"Market","description":null,"domain":"acme.com","context":null,"globalFlaggingMode":"ACTIVE","enableRequestLogging":true,"casebookEnabled":true,"createdAt":"2026-10-22T00:00:00Z","updatedAt":"2026-10-22T00:00:00Z"}, + {"id":"proj_2","name":"Forum","description":null,"domain":null,"context":null,"globalFlaggingMode":"ACTIVE","enableRequestLogging":true,"casebookEnabled":true,"createdAt":"2026-10-22T00:00:00Z","updatedAt":"2026-10-22T00:00:00Z"} +]` + +func readEnv(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestInitWithProjectsAsksAnAgentToChoose(t *testing.T) { + dir := setup(t) + api := newFakeAPI(t) + api.projects = twoProjects + t.Setenv("MODAPI_ORGANIZATION_KEY", "sk_org_test") + + _, stderr, code := run(t, "init", "--dir", dir, "--server-url", api.URL, "-o", "json") + if code != 2 { + t.Fatalf("exit %d, want 2: %s", code, stderr) + } + for _, want := range []string{"Market (proj_1)", "Forum (proj_2)", "--project", "--new"} { + if !strings.Contains(stderr, want) { + t.Fatalf("stderr misses %q: %s", want, stderr) + } + } + if paths := api.paths(); len(paths) != 1 || paths[0] != "GET /projects" { + t.Fatalf("requests = %v, want only the project list", paths) + } +} + +func TestInitLinksAProjectByName(t *testing.T) { + dir := setup(t) + api := newFakeAPI(t) + api.projects = twoProjects + t.Setenv("MODAPI_ORGANIZATION_KEY", "sk_org_test") + + stdout, stderr, code := run(t, "init", "--dir", dir, "--server-url", api.URL, "--project", "forum", "-o", "json") + if code != 0 { + t.Fatalf("exit %d\nstdout: %s\nstderr: %s", code, stdout, stderr) + } + if !strings.Contains(stdout, `"status":"linked"`) || strings.Contains(stdout, "sk_proj_linked") { + t.Fatalf("result = %s", stdout) + } + if paths := api.paths(); !strings.Contains(strings.Join(paths, ","), "POST /projects/proj_2/api-keys") { + t.Fatalf("requests = %v", paths) + } + api.mu.Lock() + var name string + for _, r := range api.requests { + if strings.HasSuffix(r.Path, "/api-keys") { + name, _ = r.Body["name"].(string) + } + } + api.mu.Unlock() + if !strings.HasPrefix(name, "CLI · ") { + t.Fatalf("key name = %q", name) + } + if env := readEnv(t, filepath.Join(dir, ".env.local")); env != "MODAPI_SECRET_KEY=sk_proj_linked\nMODAPI_WEBHOOK_SECRET=whsec_new\n" { + t.Fatalf(".env.local = %q", env) + } +} + +func TestInitRefusesProjectAndNewTogether(t *testing.T) { + dir := setup(t) + _, _, code := run(t, "init", "--dir", dir, "--project", "x", "--new") + if code != 2 { + t.Fatalf("exit %d, want 2", code) + } +} + +func TestLinkRefusesAmbiguousName(t *testing.T) { + dir := setup(t) + api := newFakeAPI(t) + api.projects = strings.Replace(twoProjects, `"Forum"`, `"market"`, 1) + t.Setenv("MODAPI_ORGANIZATION_KEY", "sk_org_test") + + _, stderr, code := run(t, "link", "Market", "--dir", dir, "--server-url", api.URL) + if code != 2 || !strings.Contains(stderr, "more than one project") { + t.Fatalf("exit %d: %s", code, stderr) + } +} + +func TestLinkAsksBeforeReplacingAnotherProjectsKey(t *testing.T) { + dir := setup(t) + api := newFakeAPI(t) + api.projects = twoProjects + t.Setenv("MODAPI_ORGANIZATION_KEY", "sk_org_test") + // The account endpoint says sk_proj_existing belongs to proj_1. + writeEnv(t, dir, ".env.local", "MODAPI_SECRET_KEY=sk_proj_existing\n") + + _, stderr, code := run(t, "link", "proj_2", "--dir", dir, "--server-url", api.URL) + if code != 2 || !strings.Contains(stderr, "--force") { + t.Fatalf("exit %d: %s", code, stderr) + } + if env := readEnv(t, filepath.Join(dir, ".env.local")); !strings.Contains(env, "sk_proj_existing") { + t.Fatalf("the key was replaced without --force: %q", env) + } + + stdout, stderr, code := run(t, "link", "proj_2", "--dir", dir, "--server-url", api.URL, "--force", "-o", "json") + if code != 0 || !strings.Contains(stdout, `"status":"linked"`) { + t.Fatalf("exit %d\nstdout: %s\nstderr: %s", code, stdout, stderr) + } + if env := readEnv(t, filepath.Join(dir, ".env.local")); !strings.Contains(env, "MODAPI_SECRET_KEY=sk_proj_linked") { + t.Fatalf(".env.local = %q", env) + } +} + +func TestLinkSameProjectChangesNothing(t *testing.T) { + dir := setup(t) + api := newFakeAPI(t) + api.projects = twoProjects + t.Setenv("MODAPI_ORGANIZATION_KEY", "sk_org_test") + writeEnv(t, dir, ".env.local", "MODAPI_SECRET_KEY=sk_proj_existing\n") + + stdout, stderr, code := run(t, "link", "Market", "--dir", dir, "--server-url", api.URL, "-o", "json") + if code != 0 || !strings.Contains(stdout, `"status":"already_set_up"`) { + t.Fatalf("exit %d\nstdout: %s\nstderr: %s", code, stdout, stderr) + } + for _, p := range api.paths() { + if strings.HasSuffix(p, "/api-keys") { + t.Fatalf("made a key for the project it is already linked to: %v", api.paths()) + } + } +} diff --git a/internal/custom/common.go b/internal/custom/common.go index 1260d0a..7c5d8b7 100644 --- a/internal/custom/common.go +++ b/internal/custom/common.go @@ -24,7 +24,7 @@ import ( // Register adds the hand-written commands to the root command. func Register(root *cobra.Command) { - root.AddCommand(newLoginCmd(), newLogoutCmd(), newInitCmd(), newDoctorCmd()) + root.AddCommand(newLoginCmd(), newLogoutCmd(), newInitCmd(), newLinkCmd(), newDoctorCmd()) preRun := root.PersistentPreRunE root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { if preRun != nil { diff --git a/internal/custom/fallbackkey.go b/internal/custom/fallbackkey.go index b406a83..05f502e 100644 --- a/internal/custom/fallbackkey.go +++ b/internal/custom/fallbackkey.go @@ -12,7 +12,7 @@ import ( // commandsWithoutAPIKey are the top-level commands that do not call the API // with the secret key, or that find their own key. var commandsWithoutAPIKey = map[string]bool{ - "login": true, "logout": true, "init": true, "doctor": true, + "login": true, "logout": true, "init": true, "link": true, "doctor": true, "auth": true, "configure": true, "whoami": true, "help": true, "completion": true, "version": true, } diff --git a/internal/custom/init.go b/internal/custom/init.go index d1552cb..c66ccf3 100644 --- a/internal/custom/init.go +++ b/internal/custom/init.go @@ -25,19 +25,23 @@ func newInitCmd() *cobra.Command { Short: "Set up Moderation API in this project", Long: `Set up Moderation API in the project in the current directory. -init detects the language, framework and package manager, creates a -Moderation API project, and writes its secret key (MODAPI_SECRET_KEY) and -webhook signing secret (MODAPI_WEBHOOK_SECRET) to the project's env file. It -adds the env file to .gitignore when git would track it. +init detects the language, framework and package manager, links an existing +Moderation API project or creates a new one, and writes its secret key +(MODAPI_SECRET_KEY) and webhook signing secret (MODAPI_WEBHOOK_SECRET) to the +project's env file. It adds the env file to .gitignore when git would track it. + +When your organization already has projects, init asks whether to link one +or create a new one. Without a terminal (agents, scripts), choose with +--project or --new; init stops and lists the projects when +neither is given. Linking makes a new key for the project (see modapi link). Describe the platform with --domain, --context and --brief: the project uses them to suggest moderation policies. Coding agents can read these from the codebase and confirm them with the user before running init. -init needs an organization key to create the project: sign in with -"modapi login", or set MODAPI_ORGANIZATION_KEY. When the env file already has -MODAPI_SECRET_KEY, init checks it and creates nothing (use --force for a new -project). +init needs an organization key: sign in with "modapi login", or set +MODAPI_ORGANIZATION_KEY. When the env file already has MODAPI_SECRET_KEY, init +checks it and changes nothing (use --force to link or create anyway). init does not change your code. Use the moderation-api-setup agent skill, or the guide at https://docs.moderationapi.com, to add the moderation calls.`, @@ -45,6 +49,8 @@ the guide at https://docs.moderationapi.com, to add the moderation calls.`, modapi init --name "Acme Marketplace" --domain acme.com \ --context "Marketplace where buyers and sellers chat before a booking" \ --brief "Stop contact details and payment requests outside the platform, scams and harassment" + modapi init --project "Acme Marketplace" + modapi init --new --name "Acme Marketplace" --domain acme.com modapi init --dry-run -o json`, Args: cobra.NoArgs, RunE: runInit, @@ -58,7 +64,9 @@ the guide at https://docs.moderationapi.com, to add the moderation calls.`, f.String("dir", ".", "The project directory") f.String("env-file", "", "Env file to write the secrets to (default: .env.local for Next.js, else .env)") f.Bool("install", false, "Also install the SDK with the detected package manager") - f.Bool("force", false, "Create a new project even when the env file already has a key") + f.String("project", "", "Link this existing project (ID or name) instead of asking") + f.Bool("new", false, "Create a new project instead of asking") + f.Bool("force", false, "Link or create even when the env file already has a key") f.String("organization-key", "", "Organization key to create the project with. Prefer the MODAPI_ORGANIZATION_KEY env var: a flag value stays in shell history (default: the key from modapi login)") return cmd } @@ -71,7 +79,7 @@ type initProject struct { } type initResult struct { - Status string `json:"status"` // "created" or "already_set_up" + Status string `json:"status"` // "created", "linked" or "already_set_up" Project *initProject `json:"project,omitempty"` EnvFile string `json:"env_file"` VariablesSet []string `json:"variables_set"` @@ -95,13 +103,15 @@ func runInit(cmd *cobra.Command, _ []string) error { if usage.UsageRequested(cmd) { return usage.EmitSchema(cmd, cmd.OutOrStdout()) } - dir, _ := cmd.Flags().GetString("dir") - dir, err := filepath.Abs(dir) + dir, err := projectDir(cmd) if err != nil { - return failure(output.ReasonCLIValidation, "invalid --dir: %v", err) + return err } - if info, err := os.Stat(dir); err != nil || !info.IsDir() { - return failure(output.ReasonCLIValidation, "--dir %s is not a directory", dir) + projectRef, _ := cmd.Flags().GetString("project") + projectRef = strings.TrimSpace(projectRef) + createNew, _ := cmd.Flags().GetBool("new") + if projectRef != "" && createNew { + return failure(output.ReasonCLIValidation, "pass either --project or --new, not both") } detected := detectStack(dir) @@ -120,7 +130,10 @@ func runInit(cmd *cobra.Command, _ []string) error { "stack": detected, "env_file": envFile, "existing_key_found": existingKey != "", - "would_create_project": existingKey == "", + "would_link_project": existingKey == "" && projectRef != "", + "would_create_project": existingKey == "" && createNew, + "would_ask": existingKey == "" && projectRef == "" && !createNew, + "link_project": projectRef, "project": input, "would_install_sdk": install && detected != nil && !detected.SDKInstalled && detected.InstallCommand != "", "organization_key_source": organizationKeySource(cmd), @@ -134,10 +147,8 @@ func runInit(cmd *cobra.Command, _ []string) error { if err := checkExistingKey(cmd, existingKey, &result); err != nil { return err } - } else { - if err := createProject(cmd, dir, envFile, input, &result); err != nil { - return err - } + } else if err := linkOrCreate(cmd, dir, envFile, projectRef, createNew, input, &result); err != nil { + return err } if install && detected != nil && !detected.SDKInstalled && detected.InstallCommand != "" { @@ -253,7 +264,7 @@ func checkExistingKey(cmd *cobra.Command, key string, result *initResult) error if errors.As(err, &authErr) || errors.As(err, &permErr) { return withHints(failure(output.ReasonCLIAuthentication, "%s in %s was rejected by the API: %v", projectKeyEnv, result.EnvFile, err), "Replace it with a valid project key from the dashboard's API keys page", - "Or run `modapi init --force` to create a new project") + "Or run `modapi link` to use another project, or `modapi init --force --new` to create one") } result.Warnings = append(result.Warnings, fmt.Sprintf("Could not check %s in %s: %v. Run `modapi doctor` later.", projectKeyEnv, result.EnvFile, err)) return nil @@ -261,13 +272,64 @@ func checkExistingKey(cmd *cobra.Command, key string, result *initResult) error if res.Object != nil && res.Object.CurrentProject != nil { p := res.Object.CurrentProject result.Project = &initProject{ID: p.ID, Name: p.Name} - result.DashboardURL = fmt.Sprintf("%s/project/%s/policies", loadSettings().DashboardURL, p.ID) + result.DashboardURL = projectDashboardURL(p.ID) } return nil } -func createProject(cmd *cobra.Command, dir, envFile string, input initInput, result *initResult) error { - // Refuse before creating anything if the secrets would land in a committed file. +// linkOrCreate links the project named by projectRef, creates one with +// createNew, or else lets a person choose: link one of the organization's +// projects or create a new one. Without a terminal and with projects to +// choose from, it lists them instead of guessing. +func linkOrCreate(cmd *cobra.Command, dir, envFile, projectRef string, createNew bool, input initInput, result *initResult) error { + if err := checkEnvFileWritable(dir, envFile, result); err != nil { + return err + } + orgKey, err := requireOrganizationKey(cmd, "init needs an organization key to link or create a project") + if err != nil { + return withHints(err, + "Run `modapi login` to sign in in the browser (`modapi login --device` on a machine without a browser)", + "Or set "+organizationKeyEnv+" to an organization key from the dashboard's API keys page", + "To use a project key you already have, put it in the env file as "+projectKeyEnv+" and run `modapi init` again") + } + if createNew { + return createProject(cmd, dir, envFile, orgKey, input, result) + } + + projects, err := listProjects(cmd, orgKey) + if err != nil { + return err + } + if projectRef != "" { + project, err := findProject(projects, projectRef) + if err != nil { + return err + } + return linkProject(cmd, dir, envFile, orgKey, project, result) + } + if len(projects) == 0 { + return createProject(cmd, dir, envFile, orgKey, input, result) + } + if !canPrompt(cmd) { + return chooseProjectError(projects, true) + } + choice, err := pickProject(cmd, projects, true) + if err != nil { + return err + } + if choice == newProjectChoice { + return createProject(cmd, dir, envFile, orgKey, input, result) + } + project, err := findProject(projects, choice) + if err != nil { + return err + } + return linkProject(cmd, dir, envFile, orgKey, project, result) +} + +// checkEnvFileWritable refuses before anything is made if the secrets would +// land in a file git already tracks. +func checkEnvFileWritable(dir, envFile string, result *initResult) error { switch gitIgnoreState(dir, envFile) { case "tracked": name := relPath(dir, envFile) @@ -277,18 +339,10 @@ func createProject(cmd *cobra.Command, dir, envFile string, input initInput, res case "unknown": result.Warnings = append(result.Warnings, fmt.Sprintf("Could not tell whether git ignores %s. Make sure it is not committed.", relPath(dir, envFile))) } + return nil +} - orgKey, _, err := organizationKey(cmd) - if err != nil { - return failure(output.ReasonCLIAuthentication, "%v", err) - } - if orgKey == "" { - return withHints(failure(output.ReasonCLIAuthentication, "init needs an organization key to create a project"), - "Run `modapi login` to sign in in the browser (`modapi login --device` on a machine without a browser)", - "Or set "+organizationKeyEnv+" to an organization key from the dashboard's API keys page", - "To use a project you already have, put its key in the env file as "+projectKeyEnv+" and run `modapi init` again") - } - +func createProject(cmd *cobra.Command, dir, envFile, orgKey string, input initInput, result *initResult) error { if canPrompt(cmd) && !cmd.Flags().Changed("context") && !cmd.Flags().Changed("brief") { if err := promptProject(cmd, &input); err != nil { return err @@ -323,10 +377,22 @@ func createProject(cmd *cobra.Command, dir, envFile string, input initInput, res return failure(output.ReasonCLIProtocol, "the API created the project but returned no secret key") } project := res.ProjectWithKey + if err := writeProjectEnv(cmd, dir, envFile, project.SecretKey, result); err != nil { + return failure(output.ReasonCLIRuntime, "the project %s was created, but writing %s failed: %v. Find the key in the dashboard", project.ID, envFile, err) + } + + result.Status = "created" + result.Project = &initProject{ID: project.ID, Name: project.Name, Domain: deref(project.Domain), Context: deref(project.Context)} + result.DashboardURL = projectDashboardURL(project.ID) + return nil +} - values := map[string]string{projectKeyEnv: project.SecretKey} +// writeProjectEnv writes the project key and its webhook signing secret to +// the env file, and adds the file to .gitignore. +func writeProjectEnv(cmd *cobra.Command, dir, envFile, projectKey string, result *initResult) error { + values := map[string]string{projectKeyEnv: projectKey} order := []string{projectKeyEnv, webhookSecretEnv} - if secret, err := webhookSecret(cmd, project.SecretKey); err != nil { + if secret, err := webhookSecret(cmd, projectKey); err != nil { result.Warnings = append(result.Warnings, fmt.Sprintf("Could not get the webhook signing secret: %v. Run `modapi webhook-secret retrieve` later.", err)) } else { values[webhookSecretEnv] = secret @@ -337,17 +403,13 @@ func createProject(cmd *cobra.Command, dir, envFile string, input initInput, res } result.GitignoreAdded = added if err := setEnvValues(envFile, values, order); err != nil { - return failure(output.ReasonCLIRuntime, "the project %s was created, but writing %s failed: %v. Find the key in the dashboard", project.ID, envFile, err) + return err } for _, key := range order { if _, ok := values[key]; ok { result.VariablesSet = append(result.VariablesSet, key) } } - - result.Status = "created" - result.Project = &initProject{ID: project.ID, Name: project.Name, Domain: deref(project.Domain), Context: deref(project.Context)} - result.DashboardURL = fmt.Sprintf("%s/project/%s/policies", loadSettings().DashboardURL, project.ID) return nil } @@ -424,8 +486,12 @@ func printInitResult(w io.Writer, r *initResult) { fmt.Fprintf(w, "Detected %s\n", desc) } switch r.Status { - case "created": - fmt.Fprintf(w, "Created project %s (%s)\n", r.Project.Name, r.Project.ID) + case "created", "linked": + verb := "Created" + if r.Status == "linked" { + verb = "Linked" + } + fmt.Fprintf(w, "%s project %s (%s)\n", verb, r.Project.Name, r.Project.ID) fmt.Fprintf(w, "Wrote %s to %s\n", strings.Join(r.VariablesSet, " and "), r.EnvFile) if r.GitignoreAdded { fmt.Fprintf(w, "Added %s to .gitignore\n", r.EnvFile) diff --git a/internal/custom/link.go b/internal/custom/link.go new file mode 100644 index 0000000..c057aaa --- /dev/null +++ b/internal/custom/link.go @@ -0,0 +1,341 @@ +package custom + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/moderation-api/cli/internal/forms" + "github.com/moderation-api/cli/internal/output" + "github.com/moderation-api/cli/internal/sdk/models/components" + "github.com/moderation-api/cli/internal/sdk/models/operations" + "github.com/moderation-api/cli/internal/usage" +) + +func newLinkCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "link [project]", + Short: "Connect this directory to an existing Moderation API project", + Long: `Connect the project in the current directory to a Moderation API project you +already have. + +link makes a new secret key for the project, named after this computer and +directory, and writes it (MODAPI_SECRET_KEY) and the webhook signing secret +(MODAPI_WEBHOOK_SECRET) to the project's env file. The project's other keys +keep working, so you can revoke this one on its own. + +Name the project by ID or by name. Without one, link lists your projects to +pick from. It needs an organization key: sign in with "modapi login", or set +MODAPI_ORGANIZATION_KEY. + +When the env file already has a key for another project, link asks before +replacing it (use --force in scripts).`, + Example: ` modapi link + modapi link "Acme Marketplace" + modapi link proj_123 --env-file .env.local`, + Args: cobra.MaximumNArgs(1), + RunE: runLink, + } + f := cmd.Flags() + f.String("dir", ".", "The project directory") + f.String("env-file", "", "Env file to write the secrets to (default: .env.local for Next.js, else .env)") + f.Bool("force", false, "Replace a key for another project without asking") + f.String("organization-key", "", "Organization key to use. Prefer the MODAPI_ORGANIZATION_KEY env var: a flag value stays in shell history (default: the key from modapi login)") + return cmd +} + +func runLink(cmd *cobra.Command, args []string) error { + if usage.UsageRequested(cmd) { + return usage.EmitSchema(cmd, cmd.OutOrStdout()) + } + dir, err := projectDir(cmd) + if err != nil { + return err + } + detected := detectStack(dir) + envFile := chooseEnvFile(cmd, dir, detected) + existingKey, envFile := existingProjectKey(dir, detected, envFile) + ref := "" + if len(args) == 1 { + ref = strings.TrimSpace(args[0]) + } + + if dryRunPlan(cmd, "link makes no request and writes no files under --dry-run; this is what it would do.", map[string]any{ + "directory": dir, + "env_file": envFile, + "existing_key_found": existingKey != "", + "project": ref, + "organization_key_source": organizationKeySource(cmd), + }) { + return nil + } + + orgKey, err := requireOrganizationKey(cmd, "link needs an organization key to make a key for the project") + if err != nil { + return err + } + projects, err := listProjects(cmd, orgKey) + if err != nil { + return err + } + var project *components.Project + if ref != "" { + if project, err = findProject(projects, ref); err != nil { + return err + } + } else { + if len(projects) == 0 { + return withHints(failure(output.ReasonCLIValidation, "your organization has no projects yet"), + "Run `modapi init` to create one") + } + if !canPrompt(cmd) { + return chooseProjectError(projects, false) + } + id, err := pickProject(cmd, projects, false) + if err != nil { + return err + } + project, _ = findProject(projects, id) + } + + result := initResult{EnvFile: relPath(dir, envFile), Stack: detected, VariablesSet: []string{}} + if existingKey != "" { + current := currentProjectID(cmd, existingKey) + if current == project.ID { + result.Status = "already_set_up" + result.Project = &initProject{ID: project.ID, Name: project.Name} + result.DashboardURL = projectDashboardURL(project.ID) + result.NextSteps = []string{"Check the setup any time: modapi doctor"} + return render(cmd, result, func(w io.Writer) { printInitResult(w, &result) }) + } + if err := confirmReplace(cmd, result.EnvFile, project.Name); err != nil { + return err + } + } + if err := checkEnvFileWritable(dir, envFile, &result); err != nil { + return err + } + + if err := linkProject(cmd, dir, envFile, orgKey, project, &result); err != nil { + return err + } + result.NextSteps = nextSteps(detected, &result) + return render(cmd, result, func(w io.Writer) { printInitResult(w, &result) }) +} + +func projectDir(cmd *cobra.Command) (string, error) { + dir, _ := cmd.Flags().GetString("dir") + dir, err := filepath.Abs(dir) + if err != nil { + return "", failure(output.ReasonCLIValidation, "invalid --dir: %v", err) + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + return "", failure(output.ReasonCLIValidation, "--dir %s is not a directory", dir) + } + return dir, nil +} + +// requireOrganizationKey returns the organization key, or an error that says +// how to get one. +func requireOrganizationKey(cmd *cobra.Command, why string) (string, error) { + orgKey, _, err := organizationKey(cmd) + if err != nil { + return "", failure(output.ReasonCLIAuthentication, "%v", err) + } + if orgKey == "" { + return "", withHints(failure(output.ReasonCLIAuthentication, "%s", why), + "Run `modapi login` to sign in in the browser (`modapi login --device` on a machine without a browser)", + "Or set "+organizationKeyEnv+" to an organization key from the dashboard's API keys page") + } + return orgKey, nil +} + +// listProjects returns every project the organization key reaches. +func listProjects(cmd *cobra.Command, orgKey string) ([]components.Project, error) { + api, err := newAPIClient(cmd, orgKey) + if err != nil { + return nil, err + } + var projects []components.Project + pageSize := int64(100) + for page := int64(1); ; page++ { + res, err := api.Projects.List(cmd.Context(), &operations.ListProjectsRequest{Page: &page, PageSize: &pageSize}) + if err != nil { + return nil, output.Error(cmd, err) + } + if res.Object == nil { + return projects, nil + } + projects = append(projects, res.Object.Data...) + if !res.Object.Pagination.HasMore { + return projects, nil + } + } +} + +// findProject finds a project by ID, or by name regardless of case. +func findProject(projects []components.Project, ref string) (*components.Project, error) { + for i := range projects { + if projects[i].ID == ref { + return &projects[i], nil + } + } + var named []components.Project + for _, p := range projects { + if strings.EqualFold(strings.TrimSpace(p.Name), ref) { + named = append(named, p) + } + } + switch len(named) { + case 1: + return &named[0], nil + case 0: + return nil, withHints(failure(output.ReasonCLIValidation, "no project with the ID or name %q", ref), + "See your projects with `modapi projects list`") + default: + return nil, withHints(failure(output.ReasonCLIValidation, "more than one project is named %q: %s", ref, describeProjects(named)), + "Name the project by its ID instead") + } +} + +func describeProjects(projects []components.Project) string { + const shown = 10 + parts := make([]string, 0, shown) + for i, p := range projects { + if i == shown { + parts = append(parts, fmt.Sprintf("and %d more", len(projects)-shown)) + break + } + parts = append(parts, fmt.Sprintf("%s (%s)", p.Name, p.ID)) + } + return strings.Join(parts, ", ") +} + +// chooseProjectError tells an agent or script which projects it can pick. +func chooseProjectError(projects []components.Project, canCreate bool) error { + hints := []string{"Pass the project to link: --project , or `modapi link `"} + if canCreate { + hints = append(hints, "Or pass --new to create a new project") + } + hints = append(hints, "Ask the user which one to use") + return withHints(failure(output.ReasonCLIValidation, "your organization has %d project(s): %s", len(projects), describeProjects(projects)), hints...) +} + +// newProjectChoice is the value of the "Create a new project" option. +const newProjectChoice = "\x00new" + +// pickProject asks a person at a terminal which project to link. It returns +// the project ID, or newProjectChoice when canCreate and they pick that. +func pickProject(cmd *cobra.Command, projects []components.Project, canCreate bool) (string, error) { + options := make([]forms.Option, 0, len(projects)+1) + if canCreate { + options = append(options, forms.NewOption("Create a new project", newProjectChoice)) + } + for _, p := range projects { + options = append(options, forms.NewOption(fmt.Sprintf("%s (%s)", p.Name, p.ID), p.ID)) + } + choice := "" + title := "Link a project" + if canCreate { + title = "Link an existing project, or create a new one?" + } + field := forms.NewSelect(&choice, options...).Title(title) + form := forms.New(forms.NewPage("Set up Moderation API", field)).IO(cmd.InOrStdin(), cmd.ErrOrStderr()) + if err := form.Run(); err != nil { + return "", fmt.Errorf("choose a project: %w", err) + } + return choice, nil +} + +// currentProjectID is the project an existing key belongs to, or "" when the +// API does not say (an invalid key, or no network). +func currentProjectID(cmd *cobra.Command, key string) string { + api, err := newQuietAPIClient(cmd, key) + if err != nil { + return "" + } + res, err := api.Account.Retrieve(cmd.Context(), &operations.GetAccountRequest{}) + if err != nil || res.Object == nil || res.Object.CurrentProject == nil { + return "" + } + return res.Object.CurrentProject.ID +} + +func confirmReplace(cmd *cobra.Command, envFile, projectName string) error { + if force, _ := cmd.Flags().GetBool("force"); force { + return nil + } + if !canPrompt(cmd) { + return withHints(failure(output.ReasonCLIValidation, "%s in %s is for another project", projectKeyEnv, envFile), + "Pass --force to replace it with a key for "+projectName) + } + replace := false + field := forms.NewConfirm(&replace).Title(fmt.Sprintf("%s in %s is for another project. Replace it with a key for %s?", projectKeyEnv, envFile, projectName)) + if err := forms.New(forms.NewPage("Link a project", field)).IO(cmd.InOrStdin(), cmd.ErrOrStderr()).Run(); err != nil { + return fmt.Errorf("link: %w", err) + } + if !replace { + return failure(output.ReasonCLIValidation, "kept the key in %s", envFile) + } + return nil +} + +// linkProject makes a new key for project and writes it to the env file. +// The caller checks first that the env file is safe to write (checkEnvFileWritable). +func linkProject(cmd *cobra.Command, dir, envFile, orgKey string, project *components.Project, result *initResult) error { + key, err := createProjectKey(cmd, orgKey, project.ID, linkKeyName(dir)) + if err != nil { + return err + } + if err := writeProjectEnv(cmd, dir, envFile, key, result); err != nil { + return failure(output.ReasonCLIRuntime, "a key for project %s was made, but writing %s failed: %v. Revoke it on the dashboard's API keys page", project.ID, envFile, err) + } + result.Status = "linked" + result.Project = &initProject{ID: project.ID, Name: project.Name, Domain: deref(project.Domain), Context: deref(project.Context)} + result.DashboardURL = projectDashboardURL(project.ID) + return nil +} + +// linkKeyName names the key after where it is used: this computer and directory. +func linkKeyName(dir string) string { + host, _ := os.Hostname() + host = strings.TrimSuffix(host, ".local") + name := "CLI · " + filepath.Base(dir) + if host != "" { + name += " · " + host + } + if r := []rune(name); len(r) > 50 { + name = string(r[:50]) + } + return name +} + +func projectDashboardURL(projectID string) string { + return fmt.Sprintf("%s/project/%s/policies", loadSettings().DashboardURL, projectID) +} + +// createProjectKey makes a new secret key for a project with the organization key. +func createProjectKey(cmd *cobra.Command, orgKey, projectID, name string) (string, error) { + // Not the --debug client: it would log the new key. + api, err := newQuietAPIClient(cmd, orgKey) + if err != nil { + return "", err + } + stop := output.StartRequestProgress(cmd) + res, err := api.Projects.APIKeys.Create(cmd.Context(), operations.CreateProjectAPIKeyRequest{ + ID: projectID, + Body: operations.CreateProjectAPIKeyRequestBody{Name: &name}, + }) + stop() + if err != nil { + return "", output.Error(cmd, err) + } + if res.ProjectAPIKey == nil || res.ProjectAPIKey.SecretKey == "" { + return "", failure(output.ReasonCLIProtocol, "the API made a key for the project but did not return it") + } + return res.ProjectAPIKey.SecretKey, nil +} diff --git a/internal/sdk/apikeys.go b/internal/sdk/apikeys.go new file mode 100644 index 0000000..6a5fe5e --- /dev/null +++ b/internal/sdk/apikeys.go @@ -0,0 +1,317 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package sdk + +import ( + "bytes" + "context" + "fmt" + "github.com/moderation-api/cli/internal/sdk/models/components" + "github.com/moderation-api/cli/internal/sdk/models/operations" + "github.com/moderation-api/cli/internal/sdk/models/sdkerrors" + "github.com/moderation-api/cli/internal/sdk/sdkinternal/config" + "github.com/moderation-api/cli/internal/sdk/sdkinternal/hooks" + "github.com/moderation-api/cli/internal/sdk/sdkinternal/utils" + "net/http" +) + +type APIKeys struct { + rootSDK *ModerationAPI + sdkConfiguration config.SDKConfiguration + hooks *hooks.Hooks +} + +func newAPIKeys(rootSDK *ModerationAPI, sdkConfig config.SDKConfiguration, hooks *hooks.Hooks) *APIKeys { + return &APIKeys{ + rootSDK: rootSDK, + sdkConfiguration: sdkConfig, + hooks: hooks, + } +} + +// Create a project API key +// Create a new secret API key for a project and return it. Give each app or computer its own key, so you can revoke one without breaking the others. The project's existing keys keep working. Needs the `projects:write` permission. +func (s *APIKeys) Create(ctx context.Context, request operations.CreateProjectAPIKeyRequest, opts ...operations.Option) (*operations.CreateProjectAPIKeyResponse, error) { + o := operations.Options{} + supportedOptions := []string{ + operations.SupportedOptionTimeout, + operations.SupportedOptionSkipDeserialization, + } + + for _, opt := range opts { + if err := opt(&o, supportedOptions...); err != nil { + return nil, fmt.Errorf("error applying option: %w", err) + } + } + + var baseURL string + if o.ServerURL == nil { + baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) + } else { + baseURL = *o.ServerURL + } + opURL, err := utils.GenerateURL(ctx, baseURL, "/projects/{id}/api-keys", request, nil) + if err != nil { + return nil, fmt.Errorf("error generating URL: %w", err) + } + + hookCtx := hooks.HookContext{ + SDK: s.rootSDK, + SDKConfiguration: s.sdkConfiguration, + BaseURL: baseURL, + Context: ctx, + OperationID: "createProjectApiKey", + SecuritySource: s.sdkConfiguration.Security, + } + bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, false, "Body", "json", `request:"mediaType=application/json"`) + if err != nil { + return nil, err + } + + timeout := o.Timeout + if timeout == nil { + timeout = s.sdkConfiguration.Timeout + } + + var streamCancel context.CancelFunc + + if timeout != nil { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, *timeout) + streamCancel = cancel + defer func() { + if streamCancel != nil { + streamCancel() + } + }() + } + + req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) + if reqContentType != "" { + req.Header.Set("Content-Type", reqContentType) + } + + if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { + return nil, err + } + + for k, v := range o.SetHeaders { + req.Header.Set(k, v) + } + + req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) + if err != nil { + return nil, err + } + + httpRes, err := s.sdkConfiguration.Client.Do(req) + if err != nil || httpRes == nil { + if err != nil { + err = fmt.Errorf("error sending request: %w", err) + } else { + err = fmt.Errorf("error sending request: no response") + } + + _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) + return nil, err + } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { + _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) + if err != nil { + return nil, err + } else if _httpRes != nil { + httpRes = _httpRes + } + } else { + httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) + if err != nil { + return nil, err + } + } + + res := &operations.CreateProjectAPIKeyResponse{ + HTTPMeta: components.HTTPMetadata{ + Request: req, + Response: httpRes, + }, + } + + switch { + case httpRes.StatusCode == 200: + switch { + case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): + if o.SkipDeserialization != nil && *o.SkipDeserialization { + httpRes.Body = utils.BodyWithCancel(httpRes.Body, streamCancel) + streamCancel = nil + } else { + rawBody, err := utils.ConsumeRawBody(httpRes) + if err != nil { + return nil, err + } + + var out components.ProjectAPIKey + if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { + return nil, err + } + + res.ProjectAPIKey = &out + } + default: + rawBody, err := utils.ConsumeRawBody(httpRes) + if err != nil { + return nil, err + } + return nil, sdkerrors.NewSDKDefaultError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) + } + case httpRes.StatusCode == 400: + switch { + case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): + rawBody, err := utils.ConsumeRawBody(httpRes) + if err != nil { + return nil, err + } + + var out sdkerrors.BadRequestError + if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { + return nil, sdkerrors.NewResponseValidationError("response did not match the declared error schema", httpRes.StatusCode, string(rawBody), httpRes, err) + } + + out.HTTPMeta = components.HTTPMetadata{ + Request: req, + Response: httpRes, + } + return nil, &out + default: + rawBody, err := utils.ConsumeRawBody(httpRes) + if err != nil { + return nil, err + } + return nil, sdkerrors.NewSDKDefaultError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) + } + case httpRes.StatusCode == 401: + switch { + case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): + rawBody, err := utils.ConsumeRawBody(httpRes) + if err != nil { + return nil, err + } + + var out sdkerrors.AuthenticationError + if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { + return nil, sdkerrors.NewResponseValidationError("response did not match the declared error schema", httpRes.StatusCode, string(rawBody), httpRes, err) + } + + out.HTTPMeta = components.HTTPMetadata{ + Request: req, + Response: httpRes, + } + return nil, &out + default: + rawBody, err := utils.ConsumeRawBody(httpRes) + if err != nil { + return nil, err + } + return nil, sdkerrors.NewSDKDefaultError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) + } + case httpRes.StatusCode == 403: + switch { + case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): + rawBody, err := utils.ConsumeRawBody(httpRes) + if err != nil { + return nil, err + } + + var out sdkerrors.PermissionDeniedError + if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { + return nil, sdkerrors.NewResponseValidationError("response did not match the declared error schema", httpRes.StatusCode, string(rawBody), httpRes, err) + } + + out.HTTPMeta = components.HTTPMetadata{ + Request: req, + Response: httpRes, + } + return nil, &out + default: + rawBody, err := utils.ConsumeRawBody(httpRes) + if err != nil { + return nil, err + } + return nil, sdkerrors.NewSDKDefaultError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) + } + case httpRes.StatusCode == 404: + switch { + case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): + rawBody, err := utils.ConsumeRawBody(httpRes) + if err != nil { + return nil, err + } + + var out sdkerrors.NotFoundError + if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { + return nil, sdkerrors.NewResponseValidationError("response did not match the declared error schema", httpRes.StatusCode, string(rawBody), httpRes, err) + } + + out.HTTPMeta = components.HTTPMetadata{ + Request: req, + Response: httpRes, + } + return nil, &out + default: + rawBody, err := utils.ConsumeRawBody(httpRes) + if err != nil { + return nil, err + } + return nil, sdkerrors.NewSDKDefaultError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) + } + case httpRes.StatusCode == 429: + switch { + case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): + rawBody, err := utils.ConsumeRawBody(httpRes) + if err != nil { + return nil, err + } + + var out sdkerrors.RateLimitError + if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { + return nil, sdkerrors.NewResponseValidationError("response did not match the declared error schema", httpRes.StatusCode, string(rawBody), httpRes, err) + } + + out.HTTPMeta = components.HTTPMetadata{ + Request: req, + Response: httpRes, + } + return nil, &out + default: + rawBody, err := utils.ConsumeRawBody(httpRes) + if err != nil { + return nil, err + } + return nil, sdkerrors.NewSDKDefaultError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) + } + case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: + rawBody, err := utils.ConsumeRawBody(httpRes) + if err != nil { + return nil, err + } + return nil, sdkerrors.NewSDKDefaultError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) + case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: + rawBody, err := utils.ConsumeRawBody(httpRes) + if err != nil { + return nil, err + } + return nil, sdkerrors.NewSDKDefaultError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) + default: + rawBody, err := utils.ConsumeRawBody(httpRes) + if err != nil { + return nil, err + } + return nil, sdkerrors.NewSDKDefaultError("unknown status code returned", httpRes.StatusCode, string(rawBody), httpRes) + } + + return res, nil + +} diff --git a/internal/sdk/models/components/projectapikey.go b/internal/sdk/models/components/projectapikey.go new file mode 100644 index 0000000..9f94a3e --- /dev/null +++ b/internal/sdk/models/components/projectapikey.go @@ -0,0 +1,51 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package components + +type ProjectAPIKey struct { + // The ID of the API key. + ID string `json:"id"` + // The key's name. + Name string `json:"name"` + // The ID of the project the key belongs to. + ProjectID string `json:"projectId"` + // When the key was created. + CreatedAt string `json:"createdAt"` + // The new secret API key. Use it to moderate content in this project. It is also shown in the project's settings. + SecretKey string `json:"secretKey"` +} + +func (p *ProjectAPIKey) GetID() string { + if p == nil { + return "" + } + return p.ID +} + +func (p *ProjectAPIKey) GetName() string { + if p == nil { + return "" + } + return p.Name +} + +func (p *ProjectAPIKey) GetProjectID() string { + if p == nil { + return "" + } + return p.ProjectID +} + +func (p *ProjectAPIKey) GetCreatedAt() string { + if p == nil { + return "" + } + return p.CreatedAt +} + +func (p *ProjectAPIKey) GetSecretKey() string { + if p == nil { + return "" + } + return p.SecretKey +} diff --git a/internal/sdk/models/operations/createprojectapikey.go b/internal/sdk/models/operations/createprojectapikey.go new file mode 100644 index 0000000..e431157 --- /dev/null +++ b/internal/sdk/models/operations/createprojectapikey.go @@ -0,0 +1,82 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package operations + +import ( + "github.com/moderation-api/cli/internal/sdk/models/components" + "github.com/moderation-api/cli/internal/sdk/sdkinternal/utils" +) + +type CreateProjectAPIKeyRequestBody struct { + // A name for the key, shown on the API keys page of the dashboard. Name it after where the key is used, for example the app or the computer. + Name *string `default:"API key" json:"name"` +} + +func (c CreateProjectAPIKeyRequestBody) MarshalJSON() ([]byte, error) { + return utils.MarshalJSON(c, "", false) +} + +func (c *CreateProjectAPIKeyRequestBody) UnmarshalJSON(data []byte) error { + if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { + return err + } + return nil +} + +func (c *CreateProjectAPIKeyRequestBody) GetName() *string { + if c == nil { + return nil + } + return c.Name +} + +type CreateProjectAPIKeyRequest struct { + // The ID of the project. + ID string `pathParam:"style=simple,explode=false,name=id"` + Body CreateProjectAPIKeyRequestBody `request:"mediaType=application/json"` +} + +func (c *CreateProjectAPIKeyRequest) GetID() string { + if c == nil { + return "" + } + return c.ID +} + +func (c *CreateProjectAPIKeyRequest) GetBody() CreateProjectAPIKeyRequestBody { + if c == nil { + return CreateProjectAPIKeyRequestBody{} + } + return c.Body +} + +type CreateProjectAPIKeyResponse struct { + HTTPMeta components.HTTPMetadata `json:"-"` + // API key created successfully + ProjectAPIKey *components.ProjectAPIKey +} + +func (c CreateProjectAPIKeyResponse) MarshalJSON() ([]byte, error) { + return utils.MarshalJSON(c, "", false) +} + +func (c *CreateProjectAPIKeyResponse) UnmarshalJSON(data []byte) error { + if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { + return err + } + return nil +} + +func (c *CreateProjectAPIKeyResponse) GetHTTPMeta() components.HTTPMetadata { + if c == nil { + return components.HTTPMetadata{} + } + return c.HTTPMeta +} + +func (c *CreateProjectAPIKeyResponse) GetProjectAPIKey() *components.ProjectAPIKey { + if c == nil { + return nil + } + return c.ProjectAPIKey +} diff --git a/internal/sdk/moderationapi.go b/internal/sdk/moderationapi.go index b2b0e1d..94082a5 100644 --- a/internal/sdk/moderationapi.go +++ b/internal/sdk/moderationapi.go @@ -137,10 +137,10 @@ func WithTimeout(timeout time.Duration) SDKOption { // New creates a new instance of the SDK with the provided options func New(opts ...SDKOption) *ModerationAPI { sdk := &ModerationAPI{ - SDKVersion: "0.3.3", + SDKVersion: "0.4.0", sdkConfiguration: config.SDKConfiguration{ - UserAgent: "speakeasy-sdk/go 0.3.3 internal 1.1.0 github.com/moderation-api/cli/internal/sdk", - SDKVersion: "0.3.3", + UserAgent: "speakeasy-sdk/go 0.4.0 internal 1.1.0 github.com/moderation-api/cli/internal/sdk", + SDKVersion: "0.4.0", GenVersion: "internal", OpenAPIDocVersion: "1.1.0", ServerList: ServerList, diff --git a/internal/sdk/projects.go b/internal/sdk/projects.go index 3bf0809..577f3d8 100644 --- a/internal/sdk/projects.go +++ b/internal/sdk/projects.go @@ -18,6 +18,8 @@ import ( ) type Projects struct { + APIKeys *APIKeys + rootSDK *ModerationAPI sdkConfiguration config.SDKConfiguration hooks *hooks.Hooks @@ -28,6 +30,7 @@ func newProjects(rootSDK *ModerationAPI, sdkConfig config.SDKConfiguration, hook rootSDK: rootSDK, sdkConfiguration: sdkConfig, hooks: hooks, + APIKeys: newAPIKeys(rootSDK, sdkConfig, hooks), } } diff --git a/internal/usage/bodyschemas.go b/internal/usage/bodyschemas.go index a8cfb2a..4610907 100644 --- a/internal/usage/bodyschemas.go +++ b/internal/usage/bodyschemas.go @@ -8,24 +8,25 @@ import ( ) var bodySchemas = map[string]string{ - "addWordlistWords": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"words\":{\"description\":\"Array of words to add to the wordlist. Duplicate words will be ignored.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"\x7d\x7d,\"required\":[\"words\"],\"type\":\"object\"}", - "createAction": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"builtIn\":{\"default\":false,\"description\":\"Whether the action is a built-in action or a custom one.\",\"type\":[\"boolean\",\"null\"]},\"description\":{\"description\":\"The description of the action.\",\"type\":[\"string\",\"null\"]},\"filterInQueueIds\":{\"default\":[],\"description\":\"The IDs of the queues the action is available in.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"freeText\":{\"default\":false,\"description\":\"Whether the action allows any text to be entered as a value or if it must be one of the possible values.\",\"type\":\"boolean\"},\"key\":{\"description\":\"User defined key of the action.\",\"type\":[\"string\",\"null\"]},\"name\":{\"description\":\"The name of the action.\",\"type\":\"string\"},\"position\":{\"default\":\"ALL_QUEUES\",\"description\":\"Show the action in all queues, selected queues or no queues (to use via API only).\",\"enum\":[\"ALL_QUEUES\",\"SOME_QUEUES\",\"HIDDEN\"],\"type\":\"string\"},\"possibleValues\":{\"default\":[],\"description\":\"The possible values of the action. The user will be prompted to select one of these values when executing the action.\",\"items\":{\"properties\":{\"value\":{\"description\":\"The value of the action.\",\"type\":\"string\"\x7d\x7d,\"required\":[\"value\"],\"type\":\"object\"},\"type\":\"array\"},\"queueBehaviour\":{\"default\":\"NO_CHANGE\",\"description\":\"Whether the action resolves and removes the item, unresolves and re-add it to the queue, or does not change the resolve status.\",\"enum\":[\"REMOVE\",\"ADD\",\"NO_CHANGE\"],\"type\":\"string\"},\"type\":{\"anyOf\":[{\"enum\":[\"AUTHOR_BLOCK\",\"AUTHOR_BLOCK_TEMP\",\"AUTHOR_UNBLOCK\",\"AUTHOR_DELETE\",\"AUTHOR_REPORT\",\"AUTHOR_WARN\",\"AUTHOR_CUSTOM\",\"ITEM_REJECT\",\"ITEM_ALLOW\",\"ITEM_CUSTOM\"],\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"The type of the action.\"},\"valueRequired\":{\"default\":false,\"description\":\"Whether the action requires a value to be executed.\",\"type\":\"boolean\"\x7d\x7d,\"required\":[\"name\"],\"type\":\"object\"}", - "createAuthor": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"company\":{\"description\":\"The author's company or organization\",\"type\":[\"string\",\"null\"]},\"email\":{\"anyOf\":[{\"format\":\"email\",\"pattern\":\"^(?:[A-Za-z0-9_'+\\\\-]+\\\\.)*[A-Za-z0-9_'+\\\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\\\-]*\\\\.)+[A-Za-z]{2,}$\",\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"Author email address\"},\"external_id\":{\"description\":\"External ID of the user, typically the ID of the author in your database.\",\"type\":\"string\"},\"external_link\":{\"anyOf\":[{\"format\":\"uri\",\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"URL of the author's external profile\"},\"first_seen\":{\"description\":\"Timestamp when author first appeared\",\"type\":\"number\"},\"last_seen\":{\"description\":\"Timestamp of last activity\",\"type\":\"number\"},\"manual_trust_level\":{\"anyOf\":[{\"maximum\":4,\"minimum\":-1,\"type\":\"number\"},{\"type\":\"null\"}]},\"metadata\":{\"additionalProperties\":{},\"description\":\"Additional metadata provided by your system. We recommend including any relevant information that may assist in the moderation process.\",\"properties\":{\"email_verified\":{\"description\":\"Whether the author's email is verified\",\"type\":[\"boolean\",\"null\"]},\"identity_verified\":{\"description\":\"Whether the author's identity is verified\",\"type\":[\"boolean\",\"null\"]},\"is_paying_customer\":{\"description\":\"Whether the author is a paying customer\",\"type\":[\"boolean\",\"null\"]},\"phone_verified\":{\"description\":\"Whether the author's phone number is verified\",\"type\":[\"boolean\",\"null\"]\x7d\x7d,\"type\":\"object\"},\"name\":{\"description\":\"Author name or identifier\",\"type\":[\"string\",\"null\"]},\"profile_picture\":{\"anyOf\":[{\"format\":\"uri\",\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"URL of the author's profile picture\"\x7d\x7d,\"required\":[\"external_id\"],\"type\":\"object\"}", - "createChannel": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"contentType\":{\"description\":\"The kind of content the channel moderates.\",\"enum\":[\"profile\",\"message\",\"post\",\"comment\",\"event\",\"product\",\"review\",\"voice\",\"other\"],\"type\":\"string\"},\"description\":{\"description\":\"The channel's description.\",\"maxLength\":500,\"type\":\"string\"},\"key\":{\"description\":\"The key you pass as `channel` when you moderate content. Defaults to the name in lowercase with dashes. Must be unique in the project.\",\"maxLength\":100,\"minLength\":1,\"pattern\":\"^[A-Za-z0-9_-]+$\",\"type\":\"string\"},\"name\":{\"description\":\"The channel's name.\",\"maxLength\":100,\"minLength\":1,\"type\":\"string\"\x7d\x7d,\"required\":[\"name\"],\"type\":\"object\"}", - "createProject": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"brief\":{\"description\":\"What you moderate, the problems you see and your guidelines.\",\"maxLength\":2000,\"type\":\"string\"},\"context\":{\"description\":\"What the platform is and who uses it.\",\"maxLength\":2000,\"type\":\"string\"},\"domain\":{\"description\":\"The website or app the project moderates content for. Used to write the context when none is given.\",\"maxLength\":255,\"type\":\"string\"},\"name\":{\"description\":\"The project's name.\",\"maxLength\":35,\"minLength\":1,\"type\":\"string\"},\"usedByMinors\":{\"default\":false,\"description\":\"Whether children use the platform.\",\"type\":\"boolean\"\x7d\x7d,\"required\":[\"name\"],\"type\":\"object\"}", - "createWebhook": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"description\":{\"description\":\"The webhook's description\",\"type\":[\"string\",\"null\"]},\"eventTypes\":{\"description\":\"Event types this webhook subscribes to. One webhook URL receives all events you list here.\",\"items\":{\"enum\":[\"QUEUE_ITEM_NEW\",\"QUEUE_ITEM_COMPLETED\",\"QUEUE_ITEM_ACTION\",\"QUEUE_ITEM_REJECTED\",\"QUEUE_ITEM_ALLOWED\",\"AUTHOR_BLOCKED\",\"AUTHOR_UNBLOCKED\",\"AUTHOR_SUSPENDED\",\"AUTHOR_UPDATED\",\"AUTHOR_TRUST_LEVEL_CHANGED\",\"AUTHOR_ACTION\"],\"type\":\"string\"},\"minItems\":1,\"type\":\"array\"},\"name\":{\"description\":\"The webhook's name, used to identify it in the dashboard\",\"minLength\":1,\"type\":\"string\"},\"url\":{\"description\":\"The webhook's URL. We'll call this URL when an event occurs.\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"required\":[\"name\",\"url\",\"eventTypes\"],\"type\":\"object\"}", - "duplicateChannel": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"key\":{\"description\":\"Key of the copy. Defaults to the original key with a timestamp. Must be unique in the project.\",\"maxLength\":100,\"minLength\":1,\"pattern\":\"^[A-Za-z0-9_-]+$\",\"type\":\"string\"},\"name\":{\"description\":\"Name of the copy. Defaults to the original name with \\\"(copy)\\\".\",\"maxLength\":100,\"minLength\":1,\"type\":\"string\"\x7d\x7d,\"type\":\"object\"}", - "duplicateProject": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"name\":{\"description\":\"Name of the copy. Defaults to the original name with \\\"(copy)\\\".\",\"maxLength\":75,\"minLength\":1,\"type\":\"string\"\x7d\x7d,\"type\":\"object\"}", - "executeAction": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"description\":\"Input parameters\",\"properties\":{\"actionKey\":{\"description\":\"ID or key of the action to execute\",\"type\":\"string\"},\"authorIds\":{\"description\":\"IDs of the authors to apply the action to. Provide this or contentIds.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"contentIds\":{\"description\":\"IDs of the content items to apply the action to. Provide this or authorIds.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"duration\":{\"description\":\"Optional duration in milliseconds for actions with timeouts\",\"minimum\":0,\"type\":\"number\"},\"queueId\":{\"description\":\"Optional queue ID if the action is queue-specific\",\"type\":\"string\"},\"value\":{\"description\":\"Optional value to provide with the action\",\"type\":\"string\"\x7d\x7d,\"required\":[\"actionKey\"],\"type\":\"object\"}", - "resolveQueueItem": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"comment\":{\"description\":\"Optional comment\",\"type\":\"string\"\x7d\x7d,\"type\":\"object\"}", - "submitContent": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"authorId\":{\"description\":\"The author of the content.\",\"type\":\"string\"},\"channel\":{\"description\":\"Provide a channel ID or key. Will use the project's default channel if not provided.\",\"type\":\"string\"},\"clientAction\":{\"description\":\"A recommendation from your own client-side flagging (e.g. a banned-IP list or a third-party tool). Feeds the rules engine and can escalate or override the recommended action. Does not change whether our analysis flagged the content.\",\"properties\":{\"action\":{\"description\":\"Your recommendation for the content: allow, review, or reject.\",\"enum\":[\"review\",\"allow\",\"reject\"],\"type\":\"string\"},\"behavior\":{\"description\":\"How your recommendation combines with ours. Defaults to 'escalate', which only applies it when stricter than ours; 'override' replaces ours outright.\",\"enum\":[\"override\",\"escalate\"],\"type\":\"string\"},\"reason\":{\"description\":\"A human-readable explanation for your recommendation.\",\"maxLength\":500,\"type\":\"string\"},\"source\":{\"description\":\"Where your recommendation came from, e.g. \\\"banned-ip\\\".\",\"maxLength\":100,\"type\":\"string\"\x7d\x7d,\"required\":[\"action\"],\"type\":\"object\"},\"content\":{\"description\":\"The content sent for moderation\",\"oneOf\":[{\"description\":\"Text\",\"properties\":{\"text\":{\"description\":\"The content text\",\"maxLength\":100000,\"type\":\"string\"},\"type\":{\"const\":\"text\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\",\"text\"],\"title\":\"Text\",\"type\":\"object\"},{\"description\":\"Image\",\"properties\":{\"data\":{\"description\":\"Base64-encoded image data. Either url or data must be provided. Note: base64 images are not stored and will not appear in the review queue.\",\"maxLength\":5500000,\"type\":\"string\"},\"type\":{\"const\":\"image\",\"type\":\"string\"},\"url\":{\"description\":\"A public URL of the image content. Either url or data must be provided.\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\"],\"title\":\"Image\",\"type\":\"object\"},{\"description\":\"Video\",\"properties\":{\"type\":{\"const\":\"video\",\"type\":\"string\"},\"url\":{\"description\":\"A public URL of the video content\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\",\"url\"],\"title\":\"Video\",\"type\":\"object\"},{\"description\":\"Audio\",\"properties\":{\"type\":{\"const\":\"audio\",\"type\":\"string\"},\"url\":{\"description\":\"The URL of the audio content\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\",\"url\"],\"title\":\"Audio\",\"type\":\"object\"},{\"description\":\"Object\",\"properties\":{\"data\":{\"additionalProperties\":{\"oneOf\":[{\"description\":\"Text\",\"properties\":{\"text\":{\"description\":\"The content text\",\"maxLength\":100000,\"type\":\"string\"},\"type\":{\"const\":\"text\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\",\"text\"],\"title\":\"Text\",\"type\":\"object\"},{\"description\":\"Image\",\"properties\":{\"data\":{\"description\":\"Base64-encoded image data. Either url or data must be provided. Note: base64 images are not stored and will not appear in the review queue.\",\"maxLength\":5500000,\"type\":\"string\"},\"type\":{\"const\":\"image\",\"type\":\"string\"},\"url\":{\"description\":\"A public URL of the image content. Either url or data must be provided.\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\"],\"title\":\"Image\",\"type\":\"object\"},{\"description\":\"Video\",\"properties\":{\"type\":{\"const\":\"video\",\"type\":\"string\"},\"url\":{\"description\":\"A public URL of the video content\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\",\"url\"],\"title\":\"Video\",\"type\":\"object\"},{\"description\":\"Audio\",\"properties\":{\"type\":{\"const\":\"audio\",\"type\":\"string\"},\"url\":{\"description\":\"The URL of the audio content\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\",\"url\"],\"title\":\"Audio\",\"type\":\"object\"}],\"title\":\"Content leaf\",\"type\":\"object\"},\"description\":\"Values in the object. Can be mixed content types.\",\"propertyNames\":{\"description\":\"The key of the content node\",\"type\":\"string\"},\"type\":\"object\"},\"type\":{\"const\":\"object\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\",\"data\"],\"title\":\"Object\",\"type\":\"object\"}],\"type\":\"object\"},\"contentId\":{\"description\":\"The unique ID of the content in your database.\",\"maxLength\":100,\"type\":\"string\"},\"conversationId\":{\"description\":\"For example the ID of a chat room or a post\",\"type\":\"string\"},\"doNotStore\":{\"description\":\"Process the content without saving it on our servers. The item is still created and appears in the review queue, analytics and author history with its moderation results, but its content is empty, so reviewers cannot see what was submitted.\",\"type\":\"boolean\"},\"metaType\":{\"description\":\"The meta type of content being moderated\",\"enum\":[\"profile\",\"message\",\"post\",\"comment\",\"event\",\"product\",\"review\",\"voice\",\"other\"],\"type\":\"string\"},\"metadata\":{\"additionalProperties\":{},\"description\":\"Any metadata you want to store with the content\",\"propertyNames\":{\"type\":\"string\"},\"type\":\"object\"},\"policies\":{\"description\":\"(Enterprise) override the channel policies for this moderation request only.\",\"items\":{\"oneOf\":[{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"toxicity\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Toxicity\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"personal_information\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Personal Information\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"hate\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Hate\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"illicit\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Illicit\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"illicit_drugs\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Illicit Drugs\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"illicit_alcohol\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Illicit Alcohol\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"illicit_firearms\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Illicit Firearms\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"illicit_tobacco\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Illicit Tobacco\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"illicit_gambling\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Illicit Gambling\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"cannabis\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Cannabis\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"adult\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Adult\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"crypto\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Crypto\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"sexual\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Sexual\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"sexual_minors\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Sexual Minors\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"profanity\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Profanity\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"violence\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Violence\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"self_harm\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Self Harm\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"spam\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Spam\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"low_quality\",\"type\":\"string\"},\"minWords\":{\"description\":\"Flag content with fewer than this many words as low-effort. Defaults to 3. Set to disable by omitting.\",\"minimum\":1,\"type\":\"integer\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Low-quality content\",\"type\":\"object\"},{\"properties\":{\"comparator\":{\"description\":\"Flag images that contain \\\"at least\\\" or \\\"fewer than\\\" the configured number of faces. Defaults to at_least.\",\"enum\":[\"at_least\",\"fewer_than\"],\"type\":\"string\"},\"count\":{\"description\":\"Number of faces the comparator applies to. Defaults to 1, so the default rule flags any image containing a face.\",\"minimum\":0,\"type\":\"integer\"},\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"face_detection\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Face detection\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"self_promotion\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Self Promotion\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"political\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Political\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"religion\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Religion\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"threat\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Threat\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"sexual_solicitation\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Sexual Solicitation\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"channel_circumvent\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Channel Circumvention\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"illegal_trade\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Illegal Trade\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"alcohol\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Alcohol\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"drugs\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Drugs\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"gambling\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Gambling\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"tobacco\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Tobacco\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"weapons\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Weapons\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"promotion\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Promotion\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"extremism\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Extremism\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"code_abuse\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Code Abuse\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"unicode_spoofing\",\"type\":\"string\"},\"signals\":{\"additionalProperties\":{\"properties\":{\"flag\":{\"type\":\"boolean\"\x7d\x7d,\"type\":\"object\"},\"description\":\"Per-signal flag toggles. Omitted spoofing signals are enabled; encoding_damage defaults to off because decode damage (U+FFFD) marks a broken pipeline, not an attack. A disabled signal is still detected and reported as a label, but does not by itself flag the policy.\",\"propertyNames\":{\"enum\":[\"mixed_script\",\"confusable_word\",\"invisible\",\"zalgo\",\"illegal\",\"encoding_damage\"],\"type\":\"string\"},\"type\":\"object\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Unicode Spoofing\",\"type\":\"object\"},{\"properties\":{\"entities\":{\"additionalProperties\":{\"additionalProperties\":{},\"properties\":{\"enable\":{\"type\":\"boolean\"},\"flag\":{\"type\":\"boolean\"},\"mask\":{\"type\":\"string\"},\"shouldMask\":{\"type\":\"boolean\"\x7d\x7d,\"type\":\"object\"},\"propertyNames\":{\"enum\":[\"email\",\"phone\",\"url\",\"address\",\"name\",\"username\",\"ip_address\",\"credit_card\",\"sensitive_other\"],\"type\":\"string\"},\"type\":\"object\"},\"id\":{\"const\":\"pii\",\"type\":\"string\"\x7d\x7d,\"required\":[\"id\",\"entities\"],\"title\":\"PII Masking\",\"type\":\"object\"},{\"properties\":{\"entities\":{\"additionalProperties\":{\"additionalProperties\":{},\"properties\":{\"enable\":{\"type\":\"boolean\"},\"flag\":{\"type\":\"boolean\"},\"mask\":{\"type\":\"string\"},\"shouldMask\":{\"type\":\"boolean\"\x7d\x7d,\"type\":\"object\"},\"propertyNames\":{\"enum\":[\"email\",\"phone\",\"url\",\"address\",\"name\",\"username\",\"ip_address\",\"credit_card\",\"sensitive_other\"],\"type\":\"string\"},\"type\":\"object\"},\"id\":{\"const\":\"url\",\"type\":\"string\"\x7d\x7d,\"required\":[\"id\",\"entities\"],\"title\":\"URL Masking\",\"type\":\"object\"},{\"properties\":{\"allowlistWordlistIds\":{\"description\":\"IDs of wordlists whose entries are treated as allowed URL domains. Matches short-circuit the risk model and are never flagged.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"blocklistWordlistIds\":{\"description\":\"IDs of wordlists whose entries are treated as blocked URL domains. Matches short-circuit the risk model and are always flagged. Blocklists take precedence over allowlists.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"flag\":{\"type\":\"boolean\"},\"flagLinkShorteners\":{\"description\":\"When true, any URL detected as a free link shortener (bit.ly, t.co, tinyurl, etc.) is always flagged regardless of risk score. Allowlist matches still win.\",\"type\":\"boolean\"},\"id\":{\"const\":\"url_risk\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"URL Risk\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"guidelineKey\":{\"type\":\"string\"},\"id\":{\"const\":\"guideline\",\"type\":\"string\"},\"instructions\":{\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\",\"instructions\",\"guidelineKey\"],\"title\":\"Guideline\",\"type\":\"object\"}],\"type\":\"object\"},\"type\":\"array\"},\"timestamp\":{\"description\":\"Unix timestamp (in milliseconds) of when the content was created. Use if content is not submitted in real-time.\",\"type\":\"number\"\x7d\x7d,\"required\":[\"content\"],\"type\":\"object\"}", - "unresolveQueueItem": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"comment\":{\"description\":\"Optional reason for unresolving the item\",\"type\":\"string\"\x7d\x7d,\"type\":\"object\"}", - "updateAction": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"builtIn\":{\"default\":false,\"description\":\"Whether the action is a built-in action or a custom one.\",\"type\":[\"boolean\",\"null\"]},\"description\":{\"description\":\"The description of the action.\",\"type\":[\"string\",\"null\"]},\"filterInQueueIds\":{\"default\":[],\"description\":\"The IDs of the queues the action is available in.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"freeText\":{\"default\":false,\"description\":\"Whether the action allows any text to be entered as a value or if it must be one of the possible values.\",\"type\":\"boolean\"},\"key\":{\"description\":\"User defined key of the action.\",\"type\":[\"string\",\"null\"]},\"name\":{\"description\":\"The name of the action.\",\"type\":\"string\"},\"position\":{\"default\":\"ALL_QUEUES\",\"description\":\"Show the action in all queues, selected queues or no queues (to use via API only).\",\"enum\":[\"ALL_QUEUES\",\"SOME_QUEUES\",\"HIDDEN\"],\"type\":\"string\"},\"possibleValues\":{\"default\":[],\"description\":\"The possible values of the action. The user will be prompted to select one of these values when executing the action.\",\"items\":{\"properties\":{\"value\":{\"description\":\"The value of the action.\",\"type\":\"string\"\x7d\x7d,\"required\":[\"value\"],\"type\":\"object\"},\"type\":\"array\"},\"queueBehaviour\":{\"default\":\"NO_CHANGE\",\"description\":\"Whether the action resolves and removes the item, unresolves and re-add it to the queue, or does not change the resolve status.\",\"enum\":[\"REMOVE\",\"ADD\",\"NO_CHANGE\"],\"type\":\"string\"},\"type\":{\"anyOf\":[{\"enum\":[\"AUTHOR_BLOCK\",\"AUTHOR_BLOCK_TEMP\",\"AUTHOR_UNBLOCK\",\"AUTHOR_DELETE\",\"AUTHOR_REPORT\",\"AUTHOR_WARN\",\"AUTHOR_CUSTOM\",\"ITEM_REJECT\",\"ITEM_ALLOW\",\"ITEM_CUSTOM\"],\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"The type of the action.\"},\"valueRequired\":{\"default\":false,\"description\":\"Whether the action requires a value to be executed.\",\"type\":\"boolean\"\x7d\x7d,\"type\":\"object\"}", - "updateAuthor": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"company\":{\"description\":\"The author's company or organization\",\"type\":[\"string\",\"null\"]},\"email\":{\"anyOf\":[{\"format\":\"email\",\"pattern\":\"^(?:[A-Za-z0-9_'+\\\\-]+\\\\.)*[A-Za-z0-9_'+\\\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\\\-]*\\\\.)+[A-Za-z]{2,}$\",\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"Author email address\"},\"external_link\":{\"anyOf\":[{\"format\":\"uri\",\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"URL of the author's external profile\"},\"first_seen\":{\"description\":\"Timestamp when author first appeared\",\"type\":\"number\"},\"last_seen\":{\"description\":\"Timestamp of last activity\",\"type\":\"number\"},\"manual_trust_level\":{\"anyOf\":[{\"maximum\":4,\"minimum\":-1,\"type\":\"number\"},{\"type\":\"null\"}]},\"metadata\":{\"additionalProperties\":{},\"description\":\"Additional metadata provided by your system. We recommend including any relevant information that may assist in the moderation process.\",\"properties\":{\"email_verified\":{\"description\":\"Whether the author's email is verified\",\"type\":[\"boolean\",\"null\"]},\"identity_verified\":{\"description\":\"Whether the author's identity is verified\",\"type\":[\"boolean\",\"null\"]},\"is_paying_customer\":{\"description\":\"Whether the author is a paying customer\",\"type\":[\"boolean\",\"null\"]},\"phone_verified\":{\"description\":\"Whether the author's phone number is verified\",\"type\":[\"boolean\",\"null\"]\x7d\x7d,\"type\":\"object\"},\"name\":{\"description\":\"Author name or identifier\",\"type\":[\"string\",\"null\"]},\"profile_picture\":{\"anyOf\":[{\"format\":\"uri\",\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"URL of the author's profile picture\"\x7d\x7d,\"type\":\"object\"}", - "updateChannel": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"contentType\":{\"description\":\"The kind of content the channel moderates.\",\"enum\":[\"profile\",\"message\",\"post\",\"comment\",\"event\",\"product\",\"review\",\"voice\",\"other\"],\"type\":\"string\"},\"contextEnabled\":{\"description\":\"Whether earlier messages in the conversation are read when moderating a message.\",\"type\":\"boolean\"},\"description\":{\"anyOf\":[{\"maxLength\":500,\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"The channel's description.\"},\"enableAudioModeration\":{\"description\":\"Whether audio is moderated.\",\"type\":\"boolean\"},\"enableImageModeration\":{\"description\":\"Whether images are moderated.\",\"type\":\"boolean\"},\"enableVideoModeration\":{\"description\":\"Whether videos are moderated.\",\"type\":\"boolean\"},\"enableVoiceModeration\":{\"description\":\"Whether real-time voice streams are moderated.\",\"type\":\"boolean\"},\"flaggingMode\":{\"description\":\"`ACTIVE` flags content. `DRY_RUN` evaluates content without flagging it.\",\"enum\":[\"DRY_RUN\",\"ACTIVE\"],\"type\":\"string\"},\"frameCaptureInterval\":{\"description\":\"Seconds between frames captured from a video.\",\"maximum\":60,\"minimum\":0.1,\"type\":\"number\"},\"includeTranscriptInResponse\":{\"description\":\"Whether audio and video transcripts are returned in responses.\",\"type\":\"boolean\"},\"key\":{\"description\":\"The key you pass as `channel` when you moderate content. Changing it breaks integrations that send the old key.\",\"maxLength\":100,\"minLength\":1,\"pattern\":\"^[A-Za-z0-9_-]+$\",\"type\":\"string\"},\"maxFramesPerVideo\":{\"description\":\"The most frames moderated per video.\",\"maximum\":1000,\"minimum\":1,\"type\":\"integer\"},\"name\":{\"description\":\"The channel's name.\",\"maxLength\":100,\"minLength\":1,\"type\":\"string\"},\"spreadFrames\":{\"description\":\"Whether frames are spread evenly across the whole video.\",\"type\":\"boolean\"},\"transcriptionQuality\":{\"description\":\"Trade-off between transcription speed and accuracy.\",\"enum\":[\"SPEED\",\"BALANCED\",\"ACCURACY\"],\"type\":\"string\"},\"unicodeSpoofDetectionEnabled\":{\"description\":\"Whether look-alike Unicode characters are normalized before moderation.\",\"type\":\"boolean\"\x7d\x7d,\"type\":\"object\"}", - "updateProject": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"casebookEnabled\":{\"description\":\"Whether review queue decisions are recorded in the casebook.\",\"type\":\"boolean\"},\"context\":{\"anyOf\":[{\"maxLength\":2000,\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"What the platform is and who uses it.\"},\"description\":{\"anyOf\":[{\"maxLength\":500,\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"The project's description.\"},\"domain\":{\"anyOf\":[{\"format\":\"uri\",\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"The website or app the project moderates content for. Setting it on a project without context writes one.\"},\"enableRequestLogging\":{\"description\":\"Whether moderated content is stored and shown in the dashboard.\",\"type\":\"boolean\"},\"globalFlaggingMode\":{\"description\":\"`ACTIVE` flags content. `DRY_RUN` evaluates without flagging.\",\"enum\":[\"DRY_RUN\",\"ACTIVE\"],\"type\":\"string\"},\"name\":{\"description\":\"The project's name.\",\"maxLength\":75,\"minLength\":1,\"type\":\"string\"\x7d\x7d,\"type\":\"object\"}", - "updateWebhook": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"description\":{\"description\":\"The webhook's description\",\"type\":[\"string\",\"null\"]},\"eventTypes\":{\"description\":\"Event types this webhook subscribes to. One webhook URL receives all events you list here.\",\"items\":{\"enum\":[\"QUEUE_ITEM_NEW\",\"QUEUE_ITEM_COMPLETED\",\"QUEUE_ITEM_ACTION\",\"QUEUE_ITEM_REJECTED\",\"QUEUE_ITEM_ALLOWED\",\"AUTHOR_BLOCKED\",\"AUTHOR_UNBLOCKED\",\"AUTHOR_SUSPENDED\",\"AUTHOR_UPDATED\",\"AUTHOR_TRUST_LEVEL_CHANGED\",\"AUTHOR_ACTION\"],\"type\":\"string\"},\"minItems\":1,\"type\":\"array\"},\"name\":{\"description\":\"The webhook's name, used to identify it in the dashboard\",\"minLength\":1,\"type\":\"string\"},\"url\":{\"description\":\"The webhook's URL. We'll call this URL when an event occurs.\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"type\":\"object\"}", - "updateWordlist": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"description\":{\"description\":\"New description for the wordlist\",\"type\":\"string\"},\"key\":{\"description\":\"New key for the wordlist\",\"type\":\"string\"},\"name\":{\"description\":\"New name for the wordlist\",\"type\":\"string\"},\"strict\":{\"description\":\"Deprecated. Now using threshold in project settings.\",\"type\":\"boolean\"},\"words\":{\"description\":\"New words for the wordlist. Replace the existing words with these new ones. Duplicate words will be ignored.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"\x7d\x7d,\"type\":\"object\"}", + "addWordlistWords": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"words\":{\"description\":\"Array of words to add to the wordlist. Duplicate words will be ignored.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"\x7d\x7d,\"required\":[\"words\"],\"type\":\"object\"}", + "createAction": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"builtIn\":{\"default\":false,\"description\":\"Whether the action is a built-in action or a custom one.\",\"type\":[\"boolean\",\"null\"]},\"description\":{\"description\":\"The description of the action.\",\"type\":[\"string\",\"null\"]},\"filterInQueueIds\":{\"default\":[],\"description\":\"The IDs of the queues the action is available in.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"freeText\":{\"default\":false,\"description\":\"Whether the action allows any text to be entered as a value or if it must be one of the possible values.\",\"type\":\"boolean\"},\"key\":{\"description\":\"User defined key of the action.\",\"type\":[\"string\",\"null\"]},\"name\":{\"description\":\"The name of the action.\",\"type\":\"string\"},\"position\":{\"default\":\"ALL_QUEUES\",\"description\":\"Show the action in all queues, selected queues or no queues (to use via API only).\",\"enum\":[\"ALL_QUEUES\",\"SOME_QUEUES\",\"HIDDEN\"],\"type\":\"string\"},\"possibleValues\":{\"default\":[],\"description\":\"The possible values of the action. The user will be prompted to select one of these values when executing the action.\",\"items\":{\"properties\":{\"value\":{\"description\":\"The value of the action.\",\"type\":\"string\"\x7d\x7d,\"required\":[\"value\"],\"type\":\"object\"},\"type\":\"array\"},\"queueBehaviour\":{\"default\":\"NO_CHANGE\",\"description\":\"Whether the action resolves and removes the item, unresolves and re-add it to the queue, or does not change the resolve status.\",\"enum\":[\"REMOVE\",\"ADD\",\"NO_CHANGE\"],\"type\":\"string\"},\"type\":{\"anyOf\":[{\"enum\":[\"AUTHOR_BLOCK\",\"AUTHOR_BLOCK_TEMP\",\"AUTHOR_UNBLOCK\",\"AUTHOR_DELETE\",\"AUTHOR_REPORT\",\"AUTHOR_WARN\",\"AUTHOR_CUSTOM\",\"ITEM_REJECT\",\"ITEM_ALLOW\",\"ITEM_CUSTOM\"],\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"The type of the action.\"},\"valueRequired\":{\"default\":false,\"description\":\"Whether the action requires a value to be executed.\",\"type\":\"boolean\"\x7d\x7d,\"required\":[\"name\"],\"type\":\"object\"}", + "createAuthor": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"company\":{\"description\":\"The author's company or organization\",\"type\":[\"string\",\"null\"]},\"email\":{\"anyOf\":[{\"format\":\"email\",\"pattern\":\"^(?:[A-Za-z0-9_'+\\\\-]+\\\\.)*[A-Za-z0-9_'+\\\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\\\-]*\\\\.)+[A-Za-z]{2,}$\",\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"Author email address\"},\"external_id\":{\"description\":\"External ID of the user, typically the ID of the author in your database.\",\"type\":\"string\"},\"external_link\":{\"anyOf\":[{\"format\":\"uri\",\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"URL of the author's external profile\"},\"first_seen\":{\"description\":\"Timestamp when author first appeared\",\"type\":\"number\"},\"last_seen\":{\"description\":\"Timestamp of last activity\",\"type\":\"number\"},\"manual_trust_level\":{\"anyOf\":[{\"maximum\":4,\"minimum\":-1,\"type\":\"number\"},{\"type\":\"null\"}]},\"metadata\":{\"additionalProperties\":{},\"description\":\"Additional metadata provided by your system. We recommend including any relevant information that may assist in the moderation process.\",\"properties\":{\"email_verified\":{\"description\":\"Whether the author's email is verified\",\"type\":[\"boolean\",\"null\"]},\"identity_verified\":{\"description\":\"Whether the author's identity is verified\",\"type\":[\"boolean\",\"null\"]},\"is_paying_customer\":{\"description\":\"Whether the author is a paying customer\",\"type\":[\"boolean\",\"null\"]},\"phone_verified\":{\"description\":\"Whether the author's phone number is verified\",\"type\":[\"boolean\",\"null\"]\x7d\x7d,\"type\":\"object\"},\"name\":{\"description\":\"Author name or identifier\",\"type\":[\"string\",\"null\"]},\"profile_picture\":{\"anyOf\":[{\"format\":\"uri\",\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"URL of the author's profile picture\"\x7d\x7d,\"required\":[\"external_id\"],\"type\":\"object\"}", + "createChannel": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"contentType\":{\"description\":\"The kind of content the channel moderates.\",\"enum\":[\"profile\",\"message\",\"post\",\"comment\",\"event\",\"product\",\"review\",\"voice\",\"other\"],\"type\":\"string\"},\"description\":{\"description\":\"The channel's description.\",\"maxLength\":500,\"type\":\"string\"},\"key\":{\"description\":\"The key you pass as `channel` when you moderate content. Defaults to the name in lowercase with dashes. Must be unique in the project.\",\"maxLength\":100,\"minLength\":1,\"pattern\":\"^[A-Za-z0-9_-]+$\",\"type\":\"string\"},\"name\":{\"description\":\"The channel's name.\",\"maxLength\":100,\"minLength\":1,\"type\":\"string\"\x7d\x7d,\"required\":[\"name\"],\"type\":\"object\"}", + "createProject": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"brief\":{\"description\":\"What you moderate, the problems you see and your guidelines.\",\"maxLength\":2000,\"type\":\"string\"},\"context\":{\"description\":\"What the platform is and who uses it.\",\"maxLength\":2000,\"type\":\"string\"},\"domain\":{\"description\":\"The website or app the project moderates content for. Used to write the context when none is given.\",\"maxLength\":255,\"type\":\"string\"},\"name\":{\"description\":\"The project's name.\",\"maxLength\":35,\"minLength\":1,\"type\":\"string\"},\"usedByMinors\":{\"default\":false,\"description\":\"Whether children use the platform.\",\"type\":\"boolean\"\x7d\x7d,\"required\":[\"name\"],\"type\":\"object\"}", + "createProjectApiKey": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"name\":{\"default\":\"API key\",\"description\":\"A name for the key, shown on the API keys page of the dashboard. Name it after where the key is used, for example the app or the computer.\",\"maxLength\":50,\"minLength\":1,\"type\":\"string\"\x7d\x7d,\"type\":\"object\"}", + "createWebhook": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"description\":{\"description\":\"The webhook's description\",\"type\":[\"string\",\"null\"]},\"eventTypes\":{\"description\":\"Event types this webhook subscribes to. One webhook URL receives all events you list here.\",\"items\":{\"enum\":[\"QUEUE_ITEM_NEW\",\"QUEUE_ITEM_COMPLETED\",\"QUEUE_ITEM_ACTION\",\"QUEUE_ITEM_REJECTED\",\"QUEUE_ITEM_ALLOWED\",\"AUTHOR_BLOCKED\",\"AUTHOR_UNBLOCKED\",\"AUTHOR_SUSPENDED\",\"AUTHOR_UPDATED\",\"AUTHOR_TRUST_LEVEL_CHANGED\",\"AUTHOR_ACTION\"],\"type\":\"string\"},\"minItems\":1,\"type\":\"array\"},\"name\":{\"description\":\"The webhook's name, used to identify it in the dashboard\",\"minLength\":1,\"type\":\"string\"},\"url\":{\"description\":\"The webhook's URL. We'll call this URL when an event occurs.\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"required\":[\"name\",\"url\",\"eventTypes\"],\"type\":\"object\"}", + "duplicateChannel": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"key\":{\"description\":\"Key of the copy. Defaults to the original key with a timestamp. Must be unique in the project.\",\"maxLength\":100,\"minLength\":1,\"pattern\":\"^[A-Za-z0-9_-]+$\",\"type\":\"string\"},\"name\":{\"description\":\"Name of the copy. Defaults to the original name with \\\"(copy)\\\".\",\"maxLength\":100,\"minLength\":1,\"type\":\"string\"\x7d\x7d,\"type\":\"object\"}", + "duplicateProject": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"name\":{\"description\":\"Name of the copy. Defaults to the original name with \\\"(copy)\\\".\",\"maxLength\":75,\"minLength\":1,\"type\":\"string\"\x7d\x7d,\"type\":\"object\"}", + "executeAction": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"description\":\"Input parameters\",\"properties\":{\"actionKey\":{\"description\":\"ID or key of the action to execute\",\"type\":\"string\"},\"authorIds\":{\"description\":\"IDs of the authors to apply the action to. Provide this or contentIds.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"contentIds\":{\"description\":\"IDs of the content items to apply the action to. Provide this or authorIds.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"duration\":{\"description\":\"Optional duration in milliseconds for actions with timeouts\",\"minimum\":0,\"type\":\"number\"},\"queueId\":{\"description\":\"Optional queue ID if the action is queue-specific\",\"type\":\"string\"},\"value\":{\"description\":\"Optional value to provide with the action\",\"type\":\"string\"\x7d\x7d,\"required\":[\"actionKey\"],\"type\":\"object\"}", + "resolveQueueItem": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"comment\":{\"description\":\"Optional comment\",\"type\":\"string\"\x7d\x7d,\"type\":\"object\"}", + "submitContent": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"authorId\":{\"description\":\"The author of the content.\",\"type\":\"string\"},\"channel\":{\"description\":\"Provide a channel ID or key. Will use the project's default channel if not provided.\",\"type\":\"string\"},\"clientAction\":{\"description\":\"A recommendation from your own client-side flagging (e.g. a banned-IP list or a third-party tool). Feeds the rules engine and can escalate or override the recommended action. Does not change whether our analysis flagged the content.\",\"properties\":{\"action\":{\"description\":\"Your recommendation for the content: allow, review, or reject.\",\"enum\":[\"review\",\"allow\",\"reject\"],\"type\":\"string\"},\"behavior\":{\"description\":\"How your recommendation combines with ours. Defaults to 'escalate', which only applies it when stricter than ours; 'override' replaces ours outright.\",\"enum\":[\"override\",\"escalate\"],\"type\":\"string\"},\"reason\":{\"description\":\"A human-readable explanation for your recommendation.\",\"maxLength\":500,\"type\":\"string\"},\"source\":{\"description\":\"Where your recommendation came from, e.g. \\\"banned-ip\\\".\",\"maxLength\":100,\"type\":\"string\"\x7d\x7d,\"required\":[\"action\"],\"type\":\"object\"},\"content\":{\"description\":\"The content sent for moderation\",\"oneOf\":[{\"description\":\"Text\",\"properties\":{\"text\":{\"description\":\"The content text\",\"maxLength\":100000,\"type\":\"string\"},\"type\":{\"const\":\"text\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\",\"text\"],\"title\":\"Text\",\"type\":\"object\"},{\"description\":\"Image\",\"properties\":{\"data\":{\"description\":\"Base64-encoded image data. Either url or data must be provided. Note: base64 images are not stored and will not appear in the review queue.\",\"maxLength\":5500000,\"type\":\"string\"},\"type\":{\"const\":\"image\",\"type\":\"string\"},\"url\":{\"description\":\"A public URL of the image content. Either url or data must be provided.\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\"],\"title\":\"Image\",\"type\":\"object\"},{\"description\":\"Video\",\"properties\":{\"type\":{\"const\":\"video\",\"type\":\"string\"},\"url\":{\"description\":\"A public URL of the video content\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\",\"url\"],\"title\":\"Video\",\"type\":\"object\"},{\"description\":\"Audio\",\"properties\":{\"type\":{\"const\":\"audio\",\"type\":\"string\"},\"url\":{\"description\":\"The URL of the audio content\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\",\"url\"],\"title\":\"Audio\",\"type\":\"object\"},{\"description\":\"Object\",\"properties\":{\"data\":{\"additionalProperties\":{\"oneOf\":[{\"description\":\"Text\",\"properties\":{\"text\":{\"description\":\"The content text\",\"maxLength\":100000,\"type\":\"string\"},\"type\":{\"const\":\"text\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\",\"text\"],\"title\":\"Text\",\"type\":\"object\"},{\"description\":\"Image\",\"properties\":{\"data\":{\"description\":\"Base64-encoded image data. Either url or data must be provided. Note: base64 images are not stored and will not appear in the review queue.\",\"maxLength\":5500000,\"type\":\"string\"},\"type\":{\"const\":\"image\",\"type\":\"string\"},\"url\":{\"description\":\"A public URL of the image content. Either url or data must be provided.\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\"],\"title\":\"Image\",\"type\":\"object\"},{\"description\":\"Video\",\"properties\":{\"type\":{\"const\":\"video\",\"type\":\"string\"},\"url\":{\"description\":\"A public URL of the video content\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\",\"url\"],\"title\":\"Video\",\"type\":\"object\"},{\"description\":\"Audio\",\"properties\":{\"type\":{\"const\":\"audio\",\"type\":\"string\"},\"url\":{\"description\":\"The URL of the audio content\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\",\"url\"],\"title\":\"Audio\",\"type\":\"object\"}],\"title\":\"Content leaf\",\"type\":\"object\"},\"description\":\"Values in the object. Can be mixed content types.\",\"propertyNames\":{\"description\":\"The key of the content node\",\"type\":\"string\"},\"type\":\"object\"},\"type\":{\"const\":\"object\",\"type\":\"string\"\x7d\x7d,\"required\":[\"type\",\"data\"],\"title\":\"Object\",\"type\":\"object\"}],\"type\":\"object\"},\"contentId\":{\"description\":\"The unique ID of the content in your database.\",\"maxLength\":100,\"type\":\"string\"},\"conversationId\":{\"description\":\"For example the ID of a chat room or a post\",\"type\":\"string\"},\"doNotStore\":{\"description\":\"Process the content without saving it on our servers. The item is still created and appears in the review queue, analytics and author history with its moderation results, but its content is empty, so reviewers cannot see what was submitted.\",\"type\":\"boolean\"},\"metaType\":{\"description\":\"The meta type of content being moderated\",\"enum\":[\"profile\",\"message\",\"post\",\"comment\",\"event\",\"product\",\"review\",\"voice\",\"other\"],\"type\":\"string\"},\"metadata\":{\"additionalProperties\":{},\"description\":\"Any metadata you want to store with the content\",\"propertyNames\":{\"type\":\"string\"},\"type\":\"object\"},\"policies\":{\"description\":\"(Enterprise) override the channel policies for this moderation request only.\",\"items\":{\"oneOf\":[{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"toxicity\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Toxicity\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"personal_information\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Personal Information\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"hate\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Hate\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"illicit\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Illicit\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"illicit_drugs\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Illicit Drugs\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"illicit_alcohol\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Illicit Alcohol\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"illicit_firearms\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Illicit Firearms\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"illicit_tobacco\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Illicit Tobacco\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"illicit_gambling\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Illicit Gambling\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"cannabis\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Cannabis\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"adult\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Adult\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"crypto\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Crypto\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"sexual\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Sexual\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"sexual_minors\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Sexual Minors\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"profanity\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Profanity\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"violence\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Violence\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"self_harm\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Self Harm\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"spam\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Spam\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"low_quality\",\"type\":\"string\"},\"minWords\":{\"description\":\"Flag content with fewer than this many words as low-effort. Defaults to 3. Set to disable by omitting.\",\"minimum\":1,\"type\":\"integer\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Low-quality content\",\"type\":\"object\"},{\"properties\":{\"comparator\":{\"description\":\"Flag images that contain \\\"at least\\\" or \\\"fewer than\\\" the configured number of faces. Defaults to at_least.\",\"enum\":[\"at_least\",\"fewer_than\"],\"type\":\"string\"},\"count\":{\"description\":\"Number of faces the comparator applies to. Defaults to 1, so the default rule flags any image containing a face.\",\"minimum\":0,\"type\":\"integer\"},\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"face_detection\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Face detection\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"self_promotion\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Self Promotion\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"political\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Political\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"religion\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Religion\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"threat\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Threat\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"sexual_solicitation\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Sexual Solicitation\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"channel_circumvent\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Channel Circumvention\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"illegal_trade\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Illegal Trade\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"alcohol\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Alcohol\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"drugs\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Drugs\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"gambling\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Gambling\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"tobacco\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Tobacco\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"weapons\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Weapons\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"promotion\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Promotion\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"extremism\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Extremism\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"code_abuse\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Code Abuse\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"id\":{\"const\":\"unicode_spoofing\",\"type\":\"string\"},\"signals\":{\"additionalProperties\":{\"properties\":{\"flag\":{\"type\":\"boolean\"\x7d\x7d,\"type\":\"object\"},\"description\":\"Per-signal flag toggles. Omitted spoofing signals are enabled; encoding_damage defaults to off because decode damage (U+FFFD) marks a broken pipeline, not an attack. A disabled signal is still detected and reported as a label, but does not by itself flag the policy.\",\"propertyNames\":{\"enum\":[\"mixed_script\",\"confusable_word\",\"invisible\",\"zalgo\",\"illegal\",\"encoding_damage\"],\"type\":\"string\"},\"type\":\"object\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"Unicode Spoofing\",\"type\":\"object\"},{\"properties\":{\"entities\":{\"additionalProperties\":{\"additionalProperties\":{},\"properties\":{\"enable\":{\"type\":\"boolean\"},\"flag\":{\"type\":\"boolean\"},\"mask\":{\"type\":\"string\"},\"shouldMask\":{\"type\":\"boolean\"\x7d\x7d,\"type\":\"object\"},\"propertyNames\":{\"enum\":[\"email\",\"phone\",\"url\",\"address\",\"name\",\"username\",\"ip_address\",\"credit_card\",\"sensitive_other\"],\"type\":\"string\"},\"type\":\"object\"},\"id\":{\"const\":\"pii\",\"type\":\"string\"\x7d\x7d,\"required\":[\"id\",\"entities\"],\"title\":\"PII Masking\",\"type\":\"object\"},{\"properties\":{\"entities\":{\"additionalProperties\":{\"additionalProperties\":{},\"properties\":{\"enable\":{\"type\":\"boolean\"},\"flag\":{\"type\":\"boolean\"},\"mask\":{\"type\":\"string\"},\"shouldMask\":{\"type\":\"boolean\"\x7d\x7d,\"type\":\"object\"},\"propertyNames\":{\"enum\":[\"email\",\"phone\",\"url\",\"address\",\"name\",\"username\",\"ip_address\",\"credit_card\",\"sensitive_other\"],\"type\":\"string\"},\"type\":\"object\"},\"id\":{\"const\":\"url\",\"type\":\"string\"\x7d\x7d,\"required\":[\"id\",\"entities\"],\"title\":\"URL Masking\",\"type\":\"object\"},{\"properties\":{\"allowlistWordlistIds\":{\"description\":\"IDs of wordlists whose entries are treated as allowed URL domains. Matches short-circuit the risk model and are never flagged.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"blocklistWordlistIds\":{\"description\":\"IDs of wordlists whose entries are treated as blocked URL domains. Matches short-circuit the risk model and are always flagged. Blocklists take precedence over allowlists.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"flag\":{\"type\":\"boolean\"},\"flagLinkShorteners\":{\"description\":\"When true, any URL detected as a free link shortener (bit.ly, t.co, tinyurl, etc.) is always flagged regardless of risk score. Allowlist matches still win.\",\"type\":\"boolean\"},\"id\":{\"const\":\"url_risk\",\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\"],\"title\":\"URL Risk\",\"type\":\"object\"},{\"properties\":{\"flag\":{\"type\":\"boolean\"},\"guidelineKey\":{\"type\":\"string\"},\"id\":{\"const\":\"guideline\",\"type\":\"string\"},\"instructions\":{\"type\":\"string\"},\"threshold\":{\"maximum\":1,\"minimum\":0,\"type\":\"number\"\x7d\x7d,\"required\":[\"flag\",\"id\",\"instructions\",\"guidelineKey\"],\"title\":\"Guideline\",\"type\":\"object\"}],\"type\":\"object\"},\"type\":\"array\"},\"timestamp\":{\"description\":\"Unix timestamp (in milliseconds) of when the content was created. Use if content is not submitted in real-time.\",\"type\":\"number\"\x7d\x7d,\"required\":[\"content\"],\"type\":\"object\"}", + "unresolveQueueItem": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"comment\":{\"description\":\"Optional reason for unresolving the item\",\"type\":\"string\"\x7d\x7d,\"type\":\"object\"}", + "updateAction": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"builtIn\":{\"default\":false,\"description\":\"Whether the action is a built-in action or a custom one.\",\"type\":[\"boolean\",\"null\"]},\"description\":{\"description\":\"The description of the action.\",\"type\":[\"string\",\"null\"]},\"filterInQueueIds\":{\"default\":[],\"description\":\"The IDs of the queues the action is available in.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"freeText\":{\"default\":false,\"description\":\"Whether the action allows any text to be entered as a value or if it must be one of the possible values.\",\"type\":\"boolean\"},\"key\":{\"description\":\"User defined key of the action.\",\"type\":[\"string\",\"null\"]},\"name\":{\"description\":\"The name of the action.\",\"type\":\"string\"},\"position\":{\"default\":\"ALL_QUEUES\",\"description\":\"Show the action in all queues, selected queues or no queues (to use via API only).\",\"enum\":[\"ALL_QUEUES\",\"SOME_QUEUES\",\"HIDDEN\"],\"type\":\"string\"},\"possibleValues\":{\"default\":[],\"description\":\"The possible values of the action. The user will be prompted to select one of these values when executing the action.\",\"items\":{\"properties\":{\"value\":{\"description\":\"The value of the action.\",\"type\":\"string\"\x7d\x7d,\"required\":[\"value\"],\"type\":\"object\"},\"type\":\"array\"},\"queueBehaviour\":{\"default\":\"NO_CHANGE\",\"description\":\"Whether the action resolves and removes the item, unresolves and re-add it to the queue, or does not change the resolve status.\",\"enum\":[\"REMOVE\",\"ADD\",\"NO_CHANGE\"],\"type\":\"string\"},\"type\":{\"anyOf\":[{\"enum\":[\"AUTHOR_BLOCK\",\"AUTHOR_BLOCK_TEMP\",\"AUTHOR_UNBLOCK\",\"AUTHOR_DELETE\",\"AUTHOR_REPORT\",\"AUTHOR_WARN\",\"AUTHOR_CUSTOM\",\"ITEM_REJECT\",\"ITEM_ALLOW\",\"ITEM_CUSTOM\"],\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"The type of the action.\"},\"valueRequired\":{\"default\":false,\"description\":\"Whether the action requires a value to be executed.\",\"type\":\"boolean\"\x7d\x7d,\"type\":\"object\"}", + "updateAuthor": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"company\":{\"description\":\"The author's company or organization\",\"type\":[\"string\",\"null\"]},\"email\":{\"anyOf\":[{\"format\":\"email\",\"pattern\":\"^(?:[A-Za-z0-9_'+\\\\-]+\\\\.)*[A-Za-z0-9_'+\\\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\\\-]*\\\\.)+[A-Za-z]{2,}$\",\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"Author email address\"},\"external_link\":{\"anyOf\":[{\"format\":\"uri\",\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"URL of the author's external profile\"},\"first_seen\":{\"description\":\"Timestamp when author first appeared\",\"type\":\"number\"},\"last_seen\":{\"description\":\"Timestamp of last activity\",\"type\":\"number\"},\"manual_trust_level\":{\"anyOf\":[{\"maximum\":4,\"minimum\":-1,\"type\":\"number\"},{\"type\":\"null\"}]},\"metadata\":{\"additionalProperties\":{},\"description\":\"Additional metadata provided by your system. We recommend including any relevant information that may assist in the moderation process.\",\"properties\":{\"email_verified\":{\"description\":\"Whether the author's email is verified\",\"type\":[\"boolean\",\"null\"]},\"identity_verified\":{\"description\":\"Whether the author's identity is verified\",\"type\":[\"boolean\",\"null\"]},\"is_paying_customer\":{\"description\":\"Whether the author is a paying customer\",\"type\":[\"boolean\",\"null\"]},\"phone_verified\":{\"description\":\"Whether the author's phone number is verified\",\"type\":[\"boolean\",\"null\"]\x7d\x7d,\"type\":\"object\"},\"name\":{\"description\":\"Author name or identifier\",\"type\":[\"string\",\"null\"]},\"profile_picture\":{\"anyOf\":[{\"format\":\"uri\",\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"URL of the author's profile picture\"\x7d\x7d,\"type\":\"object\"}", + "updateChannel": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"contentType\":{\"description\":\"The kind of content the channel moderates.\",\"enum\":[\"profile\",\"message\",\"post\",\"comment\",\"event\",\"product\",\"review\",\"voice\",\"other\"],\"type\":\"string\"},\"contextEnabled\":{\"description\":\"Whether earlier messages in the conversation are read when moderating a message.\",\"type\":\"boolean\"},\"description\":{\"anyOf\":[{\"maxLength\":500,\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"The channel's description.\"},\"enableAudioModeration\":{\"description\":\"Whether audio is moderated.\",\"type\":\"boolean\"},\"enableImageModeration\":{\"description\":\"Whether images are moderated.\",\"type\":\"boolean\"},\"enableVideoModeration\":{\"description\":\"Whether videos are moderated.\",\"type\":\"boolean\"},\"enableVoiceModeration\":{\"description\":\"Whether real-time voice streams are moderated.\",\"type\":\"boolean\"},\"flaggingMode\":{\"description\":\"`ACTIVE` flags content. `DRY_RUN` evaluates content without flagging it.\",\"enum\":[\"DRY_RUN\",\"ACTIVE\"],\"type\":\"string\"},\"frameCaptureInterval\":{\"description\":\"Seconds between frames captured from a video.\",\"maximum\":60,\"minimum\":0.1,\"type\":\"number\"},\"includeTranscriptInResponse\":{\"description\":\"Whether audio and video transcripts are returned in responses.\",\"type\":\"boolean\"},\"key\":{\"description\":\"The key you pass as `channel` when you moderate content. Changing it breaks integrations that send the old key.\",\"maxLength\":100,\"minLength\":1,\"pattern\":\"^[A-Za-z0-9_-]+$\",\"type\":\"string\"},\"maxFramesPerVideo\":{\"description\":\"The most frames moderated per video.\",\"maximum\":1000,\"minimum\":1,\"type\":\"integer\"},\"name\":{\"description\":\"The channel's name.\",\"maxLength\":100,\"minLength\":1,\"type\":\"string\"},\"spreadFrames\":{\"description\":\"Whether frames are spread evenly across the whole video.\",\"type\":\"boolean\"},\"transcriptionQuality\":{\"description\":\"Trade-off between transcription speed and accuracy.\",\"enum\":[\"SPEED\",\"BALANCED\",\"ACCURACY\"],\"type\":\"string\"},\"unicodeSpoofDetectionEnabled\":{\"description\":\"Whether look-alike Unicode characters are normalized before moderation.\",\"type\":\"boolean\"\x7d\x7d,\"type\":\"object\"}", + "updateProject": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"casebookEnabled\":{\"description\":\"Whether review queue decisions are recorded in the casebook.\",\"type\":\"boolean\"},\"context\":{\"anyOf\":[{\"maxLength\":2000,\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"What the platform is and who uses it.\"},\"description\":{\"anyOf\":[{\"maxLength\":500,\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"The project's description.\"},\"domain\":{\"anyOf\":[{\"format\":\"uri\",\"type\":\"string\"},{\"type\":\"null\"}],\"description\":\"The website or app the project moderates content for. Setting it on a project without context writes one.\"},\"enableRequestLogging\":{\"description\":\"Whether moderated content is stored and shown in the dashboard.\",\"type\":\"boolean\"},\"globalFlaggingMode\":{\"description\":\"`ACTIVE` flags content. `DRY_RUN` evaluates without flagging.\",\"enum\":[\"DRY_RUN\",\"ACTIVE\"],\"type\":\"string\"},\"name\":{\"description\":\"The project's name.\",\"maxLength\":75,\"minLength\":1,\"type\":\"string\"\x7d\x7d,\"type\":\"object\"}", + "updateWebhook": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"description\":{\"description\":\"The webhook's description\",\"type\":[\"string\",\"null\"]},\"eventTypes\":{\"description\":\"Event types this webhook subscribes to. One webhook URL receives all events you list here.\",\"items\":{\"enum\":[\"QUEUE_ITEM_NEW\",\"QUEUE_ITEM_COMPLETED\",\"QUEUE_ITEM_ACTION\",\"QUEUE_ITEM_REJECTED\",\"QUEUE_ITEM_ALLOWED\",\"AUTHOR_BLOCKED\",\"AUTHOR_UNBLOCKED\",\"AUTHOR_SUSPENDED\",\"AUTHOR_UPDATED\",\"AUTHOR_TRUST_LEVEL_CHANGED\",\"AUTHOR_ACTION\"],\"type\":\"string\"},\"minItems\":1,\"type\":\"array\"},\"name\":{\"description\":\"The webhook's name, used to identify it in the dashboard\",\"minLength\":1,\"type\":\"string\"},\"url\":{\"description\":\"The webhook's URL. We'll call this URL when an event occurs.\",\"format\":\"uri\",\"type\":\"string\"\x7d\x7d,\"type\":\"object\"}", + "updateWordlist": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"properties\":{\"description\":{\"description\":\"New description for the wordlist\",\"type\":\"string\"},\"key\":{\"description\":\"New key for the wordlist\",\"type\":\"string\"},\"name\":{\"description\":\"New name for the wordlist\",\"type\":\"string\"},\"strict\":{\"description\":\"Deprecated. Now using threshold in project settings.\",\"type\":\"boolean\"},\"words\":{\"description\":\"New words for the wordlist. Replace the existing words with these new ones. Duplicate words will be ignored.\",\"items\":{\"type\":\"string\"},\"type\":\"array\"\x7d\x7d,\"type\":\"object\"}", } func EmitBodySchema(w io.Writer, operationID string) error { diff --git a/internal/usage/schema.go b/internal/usage/schema.go index 97f53f8..644fdcc 100644 --- a/internal/usage/schema.go +++ b/internal/usage/schema.go @@ -28,7 +28,7 @@ type usageNode struct { } var usageNodes = map[string]usageNode{ - "": {head: "name \"modapi\"\nbin \"modapi\"\nabout \"Moderation API: Moderate text, images, video and audio in real time, manage authors, review queues and actions, and receive webhooks.\"\nversion \"0.3.3\"\nconfig {\n file \"~/.config/modapi/config.yaml\"\n}", flags: []usageItem{{key: "usage", text: "flag \"--usage\" help=\"Print the CLI Usage schema in KDL format\" global=#true"}, {key: "version", text: "flag \"-v --version\" help=\"Print the CLI version\""}, {key: "output-format", text: "flag \"-o --output-format \" help=\"Specify the output format. Options: pretty, json, yaml, table, toon.\" global=#true config=\"output_format\" default=\"pretty\""}, {key: "color", text: "flag \"--color \" help=\"Control colored output: auto (color when output is a TTY), always, or never. Respects NO_COLOR and FORCE_COLOR env vars.\" global=#true default=\"auto\""}, {key: "jq", text: "flag \"-q --jq \" help=\"Filter and transform output using a jq expression (e.g., '.name', '.items[] | .id')\" global=#true"}, {key: "raw-output", text: "flag \"--raw-output\" help=\"Write --jq string results as raw text instead of JSON strings (like jq -r); non-string results stay JSON\" global=#true default=#false"}, {key: "server-url", text: "flag \"--server-url \" help=\"Override the default server URL\" global=#true"}, {key: "server", text: "flag \"--server \" help=\"Select a server by index (for indexed servers) or name (for named servers)\" global=#true"}, {key: "header", text: "flag \"-H --header \" help=\"Set a custom HTTP request header (format: \\\"Key: Value\\\"). Can be specified multiple times.\" global=#true var=#true"}, {key: "include-headers", text: "flag \"--include-headers\" help=\"Include HTTP response headers in the output\" global=#true default=#false"}, {key: "timeout", text: "flag \"--timeout \" help=\"HTTP request timeout (e.g., 30s, 5m, 100ms)\" global=#true config=\"timeout\""}, {key: "interactive", text: "flag \"--interactive\" help=\"Prompt for missing inputs and open guided configure/auth forms (forms fall back to line prompts on stdin off-TTY)\" global=#true default=#true"}, {key: "no-interactive", text: "flag \"--no-interactive\" help=\"Disable all interactive features (auto-prompting, explorer auto-launch, TUI forms)\" global=#true default=#false"}, {key: "dry-run", text: "flag \"--dry-run\" help=\"Preview API requests without sending them (no network, no OS keychain). Human preview on stderr; with -o json or --jq, one JSON object per request on stdout. Local mutation commands (auth login, auth logout and configure) make no request: they skip prompts and writes and report a no-op (stderr, or one JSON object on stdout in the machine form)\" global=#true default=#false"}, {key: "debug", text: "flag \"-d --debug\" help=\"Log request and response diagnostics to stderr\" global=#true default=#false"}, {key: "agent-mode", text: "flag \"--agent-mode\" help=\"Enable structured errors and default TOON output for AI coding agents. Automatically enabled when a known agent environment is detected (CLAUDECODE, CURSOR_AGENT, etc.). Use --agent-mode=false to disable.\" global=#true default=#false"}, {key: "secret-key", text: "flag \"--secret-key \" help=\"HTTP Bearer\" global=#true env=\"MODAPI_SECRET_KEY\" config=\"security.secret_key\""}, {key: "no-keyring", text: "flag \"--no-keyring\" help=\"Never read or write the OS keychain; store secrets in the config file instead\" global=#true env=\"MODAPI_NO_KEYRING\" config=\"no_keyring\" default=#false"}}, children: []string{"account", "actions", "auth", "authors", "channels", "completion", "configure", "content", "explore", "help", "projects", "queue", "version", "webhook-secret", "webhooks", "whoami", "wordlist"}}, + "": {head: "name \"modapi\"\nbin \"modapi\"\nabout \"Moderation API: Moderate text, images, video and audio in real time, manage authors, review queues and actions, and receive webhooks.\"\nversion \"0.4.0\"\nconfig {\n file \"~/.config/modapi/config.yaml\"\n}", flags: []usageItem{{key: "usage", text: "flag \"--usage\" help=\"Print the CLI Usage schema in KDL format\" global=#true"}, {key: "version", text: "flag \"-v --version\" help=\"Print the CLI version\""}, {key: "output-format", text: "flag \"-o --output-format \" help=\"Specify the output format. Options: pretty, json, yaml, table, toon.\" global=#true config=\"output_format\" default=\"pretty\""}, {key: "color", text: "flag \"--color \" help=\"Control colored output: auto (color when output is a TTY), always, or never. Respects NO_COLOR and FORCE_COLOR env vars.\" global=#true default=\"auto\""}, {key: "jq", text: "flag \"-q --jq \" help=\"Filter and transform output using a jq expression (e.g., '.name', '.items[] | .id')\" global=#true"}, {key: "raw-output", text: "flag \"--raw-output\" help=\"Write --jq string results as raw text instead of JSON strings (like jq -r); non-string results stay JSON\" global=#true default=#false"}, {key: "server-url", text: "flag \"--server-url \" help=\"Override the default server URL\" global=#true"}, {key: "server", text: "flag \"--server \" help=\"Select a server by index (for indexed servers) or name (for named servers)\" global=#true"}, {key: "header", text: "flag \"-H --header \" help=\"Set a custom HTTP request header (format: \\\"Key: Value\\\"). Can be specified multiple times.\" global=#true var=#true"}, {key: "include-headers", text: "flag \"--include-headers\" help=\"Include HTTP response headers in the output\" global=#true default=#false"}, {key: "timeout", text: "flag \"--timeout \" help=\"HTTP request timeout (e.g., 30s, 5m, 100ms)\" global=#true config=\"timeout\""}, {key: "interactive", text: "flag \"--interactive\" help=\"Prompt for missing inputs and open guided configure/auth forms (forms fall back to line prompts on stdin off-TTY)\" global=#true default=#true"}, {key: "no-interactive", text: "flag \"--no-interactive\" help=\"Disable all interactive features (auto-prompting, explorer auto-launch, TUI forms)\" global=#true default=#false"}, {key: "dry-run", text: "flag \"--dry-run\" help=\"Preview API requests without sending them (no network, no OS keychain). Human preview on stderr; with -o json or --jq, one JSON object per request on stdout. Local mutation commands (auth login, auth logout and configure) make no request: they skip prompts and writes and report a no-op (stderr, or one JSON object on stdout in the machine form)\" global=#true default=#false"}, {key: "debug", text: "flag \"-d --debug\" help=\"Log request and response diagnostics to stderr\" global=#true default=#false"}, {key: "agent-mode", text: "flag \"--agent-mode\" help=\"Enable structured errors and default TOON output for AI coding agents. Automatically enabled when a known agent environment is detected (CLAUDECODE, CURSOR_AGENT, etc.). Use --agent-mode=false to disable.\" global=#true default=#false"}, {key: "secret-key", text: "flag \"--secret-key \" help=\"HTTP Bearer\" global=#true env=\"MODAPI_SECRET_KEY\" config=\"security.secret_key\""}, {key: "no-keyring", text: "flag \"--no-keyring\" help=\"Never read or write the OS keychain; store secrets in the config file instead\" global=#true env=\"MODAPI_NO_KEYRING\" config=\"no_keyring\" default=#false"}}, children: []string{"account", "actions", "auth", "authors", "channels", "completion", "configure", "content", "explore", "help", "projects", "queue", "version", "webhook-secret", "webhooks", "whoami", "wordlist"}}, "account": {head: "cmd \"account\" help=\"Operations for account\"", children: []string{"retrieve"}}, "account retrieve": {head: "cmd \"retrieve\" help=\"Get account details\"", flags: []usageItem{{key: "project-id", text: " flag \"-p --project-id \" help=\"The project to act on, by ID or name. Required with an organization key unless the key reaches only one project; list projects with GET /projects. A project key acts on its own project and needs no value: naming another one is an error.\""}}}, "actions": {head: "cmd \"actions\" help=\"Operations for actions\"", children: []string{"create", "delete", "execute", "list", "retrieve", "update"}}, @@ -65,7 +65,9 @@ var usageNodes = map[string]usageNode{ "content submit": {head: "cmd \"submit\" help=\"Submit content\"", flags: []usageItem{{key: "project-id", text: " flag \"--project-id \" help=\"The project to act on, by ID or name. Required with an organization key unless the key reaches only one project; list projects with GET /projects. A project key acts on its own project and needs no value: naming another one is an error.\""}, {key: "content", text: " flag \"--content \" help=\"{ \\\"text\\\": string } | { \\\"url\\\": string, \\\"data\\\": string } | { \\\"url\\\": string } | { \\\"data\\\": object }\""}, {key: "content.text", text: " flag \"--content.text \" help=\"Text_request variant as JSON\""}, {key: "content.text.text", text: " flag \"--content.text.text \" help=\"The content text [required]\""}, {key: "content.image", text: " flag \"--content.image \" help=\"Image_request variant as JSON\""}, {key: "content.image.url", text: " flag \"--content.image.url \" help=\"A public URL of the image content. Either url or data must be provided.\""}, {key: "content.image.data", text: " flag \"--content.image.data \" help=\"Base64-encoded image data. Either url or data must be provided. Note: base64 images are not stored and will not appear in the review queue.\""}, {key: "content.video", text: " flag \"--content.video