From 4fc3e9c3863faa4526653dda89ff94ef9b43b963 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 6 May 2026 17:20:49 +1200 Subject: [PATCH] refactor(insights): manager-only Create endpoint + native categories array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insights are produced by internal Appwrite services (edge, executor, background analyzers) — never by user clients. Move the ingestion endpoint accordingly. - Move Http/Insights/Create.php → Http/Manager/Insights/Create.php. - Path: /v1/insights → /v1/manager/insights. SDK Method marked `hide: true` and namespaced under `manager` so generated SDKs don't expose it. Auth narrowed from [ADMIN, KEY] to [KEY] only. - New scope `insights.manager`. Not granted by any user role (app/config/roles.php) — Cloud/edge teams configure their internal key issuance to grant it. `insights.write` description trimmed to the user-facing surface (update/dismiss/delete) since create is now manager-only. - Reports, ListInsights, GetInsight, UpdateInsight, DeleteInsight remain at /v1/insights/*. Existing scopes unchanged. - Reports `categories` switched from JSON-encoded string to a native array column (size 64 per entry, up to 32 entries via the endpoint validator). MySQL JSON-array indexes are weak and we never query individual entries — read+rewrite only. - E2E test API key in tests/e2e/Scopes/ProjectCustom.php gains insights.read/write/manager + reports.read/write so the manager endpoint is reachable from the test harness. - E2E InsightsBase.createInsight() helper now POSTs /manager/insights. - New testCreateRequiresManagerScope verifies a key with insights.read/write but no insights.manager is rejected with 401. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/collections/platform.php | 10 ++++--- app/config/scopes/project.php | 6 +++- .../Http/{ => Manager}/Insights/Create.php | 30 ++++++++++++------- .../Modules/Insights/Services/Http.php | 4 ++- tests/e2e/Scopes/ProjectCustom.php | 5 ++++ tests/e2e/Services/Insights/InsightsBase.php | 22 +++++++++++++- 6 files changed, 60 insertions(+), 17 deletions(-) rename src/Appwrite/Platform/Modules/Insights/Http/{ => Manager}/Insights/Create.php (86%) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 58dc00bb07..8196189197 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -2022,16 +2022,18 @@ $platformCollections = [ 'filters' => [], ], [ - // JSON array of category strings, e.g. ['performance', 'accessibility']. + // Category strings, e.g. 'performance', 'accessibility'. Native array + // column — we never query on individual entries (MySQL JSON-array + // indexes are weak), this is read+rewrite only. '$id' => ID::custom('categories'), 'type' => Database::VAR_STRING, 'format' => '', - 'size' => 2048, + 'size' => 64, 'signed' => true, 'required' => false, 'default' => null, - 'array' => false, - 'filters' => ['json'], + 'array' => true, + 'filters' => [], ], [ '$id' => ID::custom('analyzedAt'), diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index f0bac03a0c..984510289a 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -344,7 +344,11 @@ return [ 'category' => 'Other', ], 'insights.write' => [ - 'description' => 'Access to create, update, dismiss, and delete insights.', + 'description' => 'Access to update, dismiss, and delete insights.', + 'category' => 'Other', + ], + 'insights.manager' => [ + 'description' => 'Internal-only: ingest insights produced by Appwrite analyzers (edge, executor, …). Not granted to user roles.', 'category' => 'Other', ], diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php similarity index 86% rename from src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php rename to src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php index e08f493db1..8ff67a9370 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Insights/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/insights') + ->setHttpPath('/v1/manager/insights') ->desc('Create insight') - ->groups(['api', 'insights']) - ->label('scope', 'insights.write') + ->groups(['api', 'manager', 'insights']) + ->label('scope', 'insights.manager') ->label('event', 'insights.[insightId].create') ->label('resourceType', RESOURCE_TYPE_INSIGHTS) ->label('audits.event', 'insight.create') @@ -48,19 +57,20 @@ class Create extends Action ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) ->label('sdk', new Method( - namespace: 'insights', + namespace: 'manager', group: 'insights', - name: 'create', + name: 'createInsight', description: <<param('insightId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID. Optional — leave empty for ad-hoc insights not attached to a report.', true, ['dbForPlatform']) @@ -72,7 +82,7 @@ class Create extends Action ->param('title', '', new Text(256), 'Short, human-readable title.') ->param('summary', '', new Text(4096, 0), 'Markdown summary describing the insight.', true) ->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true) - ->param('ctas', [], new CTAsValidator(), 'Array of call-to-action descriptors. Each must contain `id`, `label`, `action`, and optional `params`.', true) + ->param('ctas', [], new CTAsValidator(), 'Array of call-to-action descriptors. Each must contain `id`, `label`, `service`, `method`, and an optional `params` object.', true) ->param('analyzedAt', null, new Nullable(new DatetimeValidator()), 'Time the insight was analyzed in ISO 8601 format. Defaults to now.', true) ->inject('response') ->inject('project') diff --git a/src/Appwrite/Platform/Modules/Insights/Services/Http.php b/src/Appwrite/Platform/Modules/Insights/Services/Http.php index f51e1daa05..b2ca226cf0 100644 --- a/src/Appwrite/Platform/Modules/Insights/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Insights/Services/Http.php @@ -2,11 +2,11 @@ namespace Appwrite\Platform\Modules\Insights\Services; -use Appwrite\Platform\Modules\Insights\Http\Insights\Create as CreateInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\Delete as DeleteInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\Get as GetInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\Update as UpdateInsight; use Appwrite\Platform\Modules\Insights\Http\Insights\XList as ListInsights; +use Appwrite\Platform\Modules\Insights\Http\Manager\Insights\Create as CreateInsight; use Appwrite\Platform\Modules\Insights\Http\Reports\Create as CreateReport; use Appwrite\Platform\Modules\Insights\Http\Reports\Delete as DeleteReport; use Appwrite\Platform\Modules\Insights\Http\Reports\Get as GetReport; @@ -26,7 +26,9 @@ class Http extends Service $this->addAction(UpdateReport::getName(), new UpdateReport()); $this->addAction(DeleteReport::getName(), new DeleteReport()); + // Manager-only ingestion (hidden from SDKs, /v1/manager/insights). $this->addAction(CreateInsight::getName(), new CreateInsight()); + $this->addAction(GetInsight::getName(), new GetInsight()); $this->addAction(ListInsights::getName(), new ListInsights()); $this->addAction(UpdateInsight::getName(), new UpdateInsight()); diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 3071ddfa2a..7bb85b4731 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -177,6 +177,11 @@ trait ProjectCustom 'policies.write', 'templates.read', 'templates.write', + 'insights.read', + 'insights.write', + 'insights.manager', + 'reports.read', + 'reports.write', ], ]); diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index 8ff497086c..cb59df7fd5 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -51,7 +51,8 @@ trait InsightsBase protected function createInsight(array $body, array $headers = null): array { - return $this->client->call(Client::METHOD_POST, '/insights', $headers ?? $this->serverHeaders(), $body); + // Manager-only endpoint — internal Appwrite services ingest here, not user SDKs. + return $this->client->call(Client::METHOD_POST, '/manager/insights', $headers ?? $this->serverHeaders(), $body); } protected function getInsight(string $insightId, array $headers = null): array @@ -799,6 +800,25 @@ trait InsightsBase $this->assertSame(401, $unauthorized['headers']['status-code']); } + public function testCreateRequiresManagerScope(): void + { + // A server key with insights.read + insights.write but NOT insights.manager + // must be rejected — Create lives behind /v1/manager/insights and only + // internal Appwrite services hold the manager scope. + $userKey = $this->getNewKey([ + 'insights.read', + 'insights.write', + ]); + + $rejected = $this->createInsight($this->sampleInsight(), [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $userKey, + ]); + + $this->assertSame(401, $rejected['headers']['status-code']); + } + public function testListSurvivesEmptyDatabase(): void { $list = $this->listInsights([