Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions BACKLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# CLI Backlog
Comment thread
chrisaddams marked this conversation as resolved.
Outdated

Outstanding work captured during the integrations + workflows step-delete development.
Items are split between things that live in this repo (CLI fixes / new commands) and
things that need server-side changes on the platform repo.

## CLI repo — fixes & new commands

- [ ] **Search commands** — wrap `SearchController` (authenticated search, rehydrate /
purge index, similar docs, faceted search, geo-filter). High value: index ops
are natural CLI tasks.
- [ ] **Email templates** — `email-templates list / get / update / preview` wrapping
`EmailTemplateController`. Useful for customisation and backup/restore.
- [ ] **`workflows trigger` payload bug** — current implementation wraps user JSON as
`{ data: parsed }` rather than building the proper `TriggerWorkflowRequest`
`{ entity_name, entity_id, data: <stringified> }`. Confirmed broken when we
tried to manually trigger workflow 43 during integrations testing.
- [ ] **`workflows step-add --interactive`** — prompt-driven step authoring; today
users have to know action types and write JSON parameters by hand.
- [ ] **`workflows clone`** — clone an existing workflow as a starting point.
- [ ] **`workflows step-delete --force-relink`** — auto-clear inbound `on_success_step_id`
/ `on_failure_step_id` references before deleting (currently the user has to
run `workflows step-link` manually for each inbound link).
- [ ] **Charts** — `charts list / create / update / delete` wrapping
`ChartConfigurationController`. Lower priority; mostly UI-driven.
- [ ] **Feature flags** — `feature-flags list` wrapping `FeatureFlagController`.
Niche; could just use `fetch` for ad-hoc reads.

## Platform repo (AnyAPI) — server-side changes

- [ ] **Allow manual triggering of `Event` workflows** —
`WorkflowRepository.TriggerWorkflow` rejects Event-typed workflows with
"Cannot trigger event workflows". Both breeze and CLI hit this wall, so
Event workflows can only be exercised by firing the actual entity event.
Worth adding a `?force=true` flag (or dropping the check entirely) so we
can test Event workflows from the dashboard / CLI.
- [ ] **API key controller hardening** — flagged in PR #10:
- No max `expiresInDays` cap on `CreateApiKeyRequest` (a key can be issued for
any number of days, including 36500).
- API-key-authed requests can call `POST /api-keys` to create more api-keys
(a leaked key could spawn more keys to extend its lifetime).
- [ ] **Apple sign-in `client_secret` JWT generation** — Apple expects a JWT
signed with a `.p8` key as the OAuth client secret, with a max 6-month
lifetime. The platform currently expects users to generate this JWT
externally and paste it into `client_secret`. Servers like Supabase and
Auth0 accept the `.p8` key once and generate the JWT internally per request
— big DX win when added.
- [ ] **`workflows step-delete` API error shape** — returns a 500 with a raw EF
Core message ("An error occurred while saving the entity changes") on FK
violation. Should return a structured 4xx with the offending step IDs so
clients can present a useful error without inferring it.
- [ ] **`/search/public` returns 500** on getahead-prod (org 37523255), with or
without auth. Verified via direct curl — empty 500 response body. Either
the route is broken on that tenant, or it requires a header we aren't
sending. Worth investigating because the CLI's `search audit` (and the
whole "what does an unauthenticated visitor see" use case) depends on it.

## Already done / merged-in-progress

- [x] Integrations CLI (catalog, connections, OAuth, execute) — PR #11
- [x] `workflows step-delete` with inbound-link warnings + FK-error translation — PR #11
- [x] API keys CLI — PR #10
- [x] Field options + jsonb stringify — PR #9
- [x] Menus list/add-item — PR #8
- [x] Fields system relations fix — PR #6
- [x] Workflow step parameter normalisation — PR #4
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ The official command-line interface for [Anythink](https://anythink.cloud) — t
- [entities](#entities)
- [fields](#fields)
- [data](#data)
- [search](#search)
- [workflows](#workflows)
- [users](#users)
- [files](#files)
Expand Down Expand Up @@ -297,6 +298,71 @@ anythink data delete blog_posts 42 --yes

---

### search

Full-text search across your entities, plus index lifecycle management.

```
anythink search query <text> Run a search
anythink search similar <entity> <id> Find similar documents
anythink search rehydrate [<entity>] Rebuild the search index (admin)
anythink search purge [<entity>] Wipe the search index (admin)
anythink search audit <entity> Compare configured public-searchable fields
with what public search actually returns
```

**Options — `search query`**

| Flag | Description |
| --------------------- | -------------------------------------------------------------------------------------- |
| `--entities <list>` | Comma-separated entity names. Default: all indexed entities. |
| `--filter <expr>` | Filter expression, e.g. `"status=published AND category=news"`. Supports `_geoRadius`. |
| `--sort <list>` | Comma-separated sort fields, e.g. `"created_at:desc,id:asc"`. |
| `--facet <fields>` | Comma-separated fields to compute facet counts on. |
| `--highlight` | Highlight matched terms in results. |
| `--page N` | Page number (default: 1). |
| `--limit N` | Results per page (1-100, default: 20). |
| `--public` | Use the unauthenticated `/search/public` endpoint (only public-marked fields). |
| `--json` | Print the raw response JSON. |

**Index lifecycle**

`rehydrate` and `purge` are admin operations on the search index:

- `search rehydrate` — rebuilds the index from the database (no data loss; just resyncs)
- `search purge` — deletes the index (run `rehydrate` after to repopulate)

Both confirm by default; pass `-y` / `--yes` to skip the prompt for automation.

**`search audit` — public-search data leak check**

Compares what the entity's schema *says* should be public-searchable (fields with `publicly_searchable=true` and the entity's own `is_public=true`) against what `/search/public` actually returns. Any field appearing in public results that isn't on the allowlist is reported as a leak.

Exits with code 1 if a leak is detected — useful for CI/CD.

**Examples**

```bash
# Browse everything
anythink search query "*"

# Filtered search with sorting
anythink search query "anythink" --filter "status=published" --sort "created_at:desc"

# Compare what public visitors see vs what's in the database
anythink search audit posts
anythink search audit users --query "alice" --sample 10

# Reindex after a schema change
anythink search rehydrate posts
anythink search rehydrate --yes # everything (admin)

# Geo search (radius in metres)
anythink search query "*" --filter "_geoRadius(51.5074,-0.1278,5000)"
```

---

### workflows

Manage automation workflows. Workflows can be triggered on a cron schedule, when entities are created or updated, or manually.
Expand Down
62 changes: 59 additions & 3 deletions src/Client/AnythinkClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,21 @@ public class AnythinkClient : HttpApiClient
public string BaseUrl { get; }
private string _org;

/// <summary>
/// HttpClient with no auth headers, for genuinely anonymous calls (e.g. /search/public).
/// Without this, the user's bearer token would leak into supposedly-public requests
/// and skew the results — defeating the audit's whole purpose. In tests we share the
/// mocked client so URL/body assertions still work.
/// </summary>
private readonly HttpClient _anonymousHttp;

public AnythinkClient(string orgId, string baseUrl, string? token = null, string? apiKey = null)
: base(token, apiKey)
{
OrgId = orgId;
BaseUrl = baseUrl.TrimEnd('/');
_org = $"{BaseUrl}/org/{OrgId}";
_anonymousHttp = new HttpClient();
}

public AnythinkClient(Profile p) : this(p.OrgId, p.InstanceApiUrl, p.AccessToken, p.ApiKey) { }
Expand All @@ -28,6 +37,20 @@ internal AnythinkClient(string orgId, string baseUrl, HttpClient http) : base(ht
OrgId = orgId;
BaseUrl = baseUrl.TrimEnd('/');
_org = $"{BaseUrl}/org/{OrgId}";
_anonymousHttp = http;
}

private async Task<T?> GetAnonymousAsync<T>(string url)
{
var response = await _anonymousHttp.GetAsync(url);
var raw = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new AnythinkException(raw, (int)response.StatusCode);
if (string.IsNullOrWhiteSpace(raw)) return default;
return JsonSerializer.Deserialize<T>(raw, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
}

// ── Raw fetch (for CLI `fetch` command) ────────────────────────────────────
Expand Down Expand Up @@ -263,7 +286,43 @@ public Task<Permission> CreatePermissionAsync(CreatePermissionRequest req)

public async Task<List<Permission>> GetPermissionsAsync()
=> (await GetAsync<List<Permission>>(_org + "/permissions")) ?? [];

public Task<RoleResponse?> UpdateRoleWithPermissionsAsync(int roleId, UpdateRolePermissionsRequest req)
=> PutAsync<RoleResponse>(_org + $"/roles/{roleId}", req);

// ── Search ────────────────────────────────────────────────────────────────

/// <summary>
/// Run a search. When isPublic=true the request is sent anonymously (no bearer
/// token) — matters for the audit use case, otherwise the response would reflect
/// what the authenticated user can see, not what an unauthenticated visitor sees.
/// Caller assembles the query-string parameters externally.
/// </summary>
public async Task<SearchResult> SearchAsync(string queryString, bool isPublic = false)
{
var path = isPublic ? "/search/public" : "/search";
var url = _org + path + (string.IsNullOrEmpty(queryString) ? "" : "?" + queryString);
var result = isPublic
? await GetAnonymousAsync<SearchResult>(url)
: await GetAsync<SearchResult>(url);
return result ?? new SearchResult([], 1, 0, 0, 0, false, false, null, null);
}

public async Task<List<JsonObject>> SearchSimilarAsync(string entityName, int id, int limit = 10, bool isPublic = false)
{
var path = isPublic ? "/search/public/similar" : "/search/similar";
var url = _org + path + $"?e={Uri.EscapeDataString(entityName)}&id={id}&limit={limit}";
var result = isPublic
? await GetAnonymousAsync<List<JsonObject>>(url)
: await GetAsync<List<JsonObject>>(url);
return result ?? [];
}

public Task RehydrateSearchIndexAsync(string? entityName = null)
=> PostVoidAsync(_org + "/search/rehydrate" + (string.IsNullOrEmpty(entityName) ? "" : $"/{entityName}"));

public Task PurgeSearchIndexAsync(string? entityName = null)
=> DeleteAsync(_org + "/search/purge" + (string.IsNullOrEmpty(entityName) ? "" : $"/{entityName}"));
// ── API Keys ──────────────────────────────────────────────────────────────

public async Task<List<ApiKeyResponse>> GetApiKeysAsync()
Expand All @@ -275,9 +334,6 @@ public Task<ApiKeyResponse> CreateApiKeyAsync(CreateApiKeyRequest req)
public Task RevokeApiKeyAsync(int apiKeyId)
=> DeleteAsync(_org + $"/api-keys/{apiKeyId}");

public Task<RoleResponse?> UpdateRoleWithPermissionsAsync(int roleId, UpdateRolePermissionsRequest req)
=> PutAsync<RoleResponse>(_org + $"/roles/{roleId}", req);

// ── Pay ───────────────────────────────────────────────────────────────────

private string _pay => _org + "/integrations/anythinkpay";
Expand Down
Loading