refactor(insights): manager-only Create endpoint + native categories array

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<string> 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) <noreply@anthropic.com>
This commit is contained in:
Jake Barnby
2026-05-06 17:20:49 +12:00
co-authored by Claude Opus 4.7
parent a1f64c6f71
commit 4fc3e9c386
6 changed files with 60 additions and 17 deletions
+6 -4
View File
@@ -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'),
+5 -1
View File
@@ -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',
],
@@ -1,6 +1,6 @@
<?php
namespace Appwrite\Platform\Modules\Insights\Http\Insights;
namespace Appwrite\Platform\Modules\Insights\Http\Manager\Insights;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
@@ -23,6 +23,15 @@ use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
/**
* Manager-only endpoint for analyzer ingestion.
*
* Insights are produced by internal Appwrite services (edge, executor,
* background analyzers) — never by user clients. The endpoint lives under
* /v1/manager/* and is hidden from generated SDKs to keep that contract
* explicit. Internal services call it directly over HTTP using a server
* API key with the `insights.manager` scope.
*/
class Create extends Action
{
use HTTP;
@@ -36,10 +45,10 @@ class Create extends Action
{
$this
->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: <<<EOT
Create a new insight. Server-side only: insights are produced by analyzers and surfaced to project members.
Manager-only: ingest an insight produced by an internal analyzer (edge, executor, background worker, …). Not exposed to user-facing client or server SDKs.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
auth: [AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_INSIGHT,
),
]
],
hide: true,
))
->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')
@@ -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());
+5
View File
@@ -177,6 +177,11 @@ trait ProjectCustom
'policies.write',
'templates.read',
'templates.write',
'insights.read',
'insights.write',
'insights.manager',
'reports.read',
'reports.write',
],
]);
+21 -1
View File
@@ -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([