Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,15 +215,16 @@ default here is 12) because Laravel's `hashed` cast refuses hashes above the con
| `GET /v1/catalog/extensions?q=&category=&pricing=&hosting=&console=&compatible=&sort=&page=&limit=` | Paged listings with publisher, category, latest release, install count, compat, price, package names. `console=<version>` adds `compatibility: {console, compatible, newest_compatible}` to each item (does the latest release work, and the newest published release that does); with `compatible=1` only listings that have such a release |
| `GET /v1/catalog/extensions/{slug}` | Full listing, screenshots, publisher, versions summary |
| `GET /v1/catalog/extensions/{slug}/releases?channel=` | Published (and yanked) releases: versions, channels, dates, sizes, notes, compat |

Catalog releases (`latest_release`, each `versions` entry, `/releases`) and the instance
`updates` releases carry `dist`: `null`, or `{size, sha256, integrity, built_against}` for
a release with a prebuilt engine dist, where `built_against` holds the
`@fleetbase/ember-ui` and `@fleetbase/ember-core` versions it was built with. The archive
is `GET /npm/{engine}/-/dist/{version}`, behind the licence gate.
| `GET /v1/catalog/publishers/{slug}` | Publisher profile and their published extensions |
| `GET /v1/catalog/categories` | Categories with published extension counts |

Catalog releases (`latest_release`, each `versions` entry, `/releases`) and the instance
`updates` releases carry `dist`: `null`, or `{url, size, sha256, integrity, built_against}`
for a release with a prebuilt engine dist, where `built_against` holds the
`@fleetbase/ember-ui` and `@fleetbase/ember-core` versions it was built with. `url` is
`GET /v1/instances/me/releases/{release}/dist`, which an instance fetches with its token;
the same archive is also served at `GET /npm/{engine}/-/dist/{version}`.

Filters: `pricing=free|paid`, `hosting=cloud|self_hosted|both` (listings marked `both`
match either side), `category=<slug or id>[,…]`, `publisher=<slug>`, `tag=<tag>`.
Sorts: `popular` (default, by installs), `newest`, `updated`, `name`, `price`.
Expand Down Expand Up @@ -420,6 +421,7 @@ disables a token.
| `POST /v1/instances/register` | No bearer token. Body: `code`, `name`, `kind` (`cloud` or `self_hosted`), optional `host`, `console_version`, `api_version`, `previous_token`. Redeems the code (single use, 15 minutes) and returns `{token, token_type, rotated, instance}`; the token is never shown again. 201 for a new instance, 200 when an existing one was rotated (see re-registration below) |
| `GET /v1/instances/me` | The instance, its organization, and the organization's licences with `seats_used` and `activated_here` |
| `GET /v1/instances/me/updates?channel=` | Every extension installed on the instance: the installed release, the newest compatible published release, `update_available`. `channel=beta` includes beta releases |
| `GET /v1/instances/me/releases/{release}/dist` | The release's prebuilt engine dist (`rel_` id), as `dist.url` names it. Free extensions: any active instance. Paid: 403 unless the instance's organization owns the extension or holds an active licence, checked on every request. `X-Checksum-Sha256`, ETag, 304; yanked releases stay downloadable; 404 when the release has no dist or the listing is suspended |
| `POST /v1/instances/me/installs` | Body: `extension` (slug, `ext_` id or an npm/Composer package name), `version` or `release` (`rel_` id), `status` (`installing`, `installed`, `failed`), optional `error`, `installed_by`. Returns `{install, activation}` |
| `DELETE /v1/instances/me/installs/{extension}` | Reports an uninstall (scoped npm names work in the path). Returns `{install, deactivated}` |

Expand Down
60 changes: 60 additions & 0 deletions app/Http/Controllers/Instance/ReleaseDistController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace App\Http\Controllers\Instance;

use App\Http\Controllers\Controller;
use App\Models\Extension;
use App\Models\Instance;
use App\Models\Release;
use App\Services\LicenceGate;
use App\Services\ReleaseStorage;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class ReleaseDistController extends Controller
{
public function __construct(protected ReleaseStorage $storage, protected LicenceGate $gate) {}

/**
* GET /v1/instances/me/releases/{release}/dist — the release's prebuilt engine dist
* (`fleetbase-build-engine-release` output, uploaded with `flb publish`), which a console
* installs as a runtime engine without a rebuild. `dist.url` on catalog and update releases
* points here, next to the size, sha256 and builtAgainst versions to verify it with.
*
* Free extensions: any active instance. Paid: the instance's organization must own the
* extension or hold an active licence for it (403 otherwise, re-checked on every request so
* a revoked licence takes effect at once). Yanked releases stay downloadable for the
* instances that already run them; suspended listings and releases without a dist are 404.
*/
public function show(Request $request, string $release): Response
{
/** @var Instance $instance */
$instance = $request->user();

$model = Release::query()
->released()
->where(fn ($q) => $q->where('public_id', $release)->orWhere('uuid', $release))
->whereHas('extension', fn ($q) => $q->where('status', '!=', Extension::STATUS_SUSPENDED))
->with(['engineDist', 'extension.price'])
->first();

if ($model === null) {
return response()->error('Release not found.', 404);
}
if ($model->engineDist === null) {
return response()->error('This release has no prebuilt dist.', 404);
}

$paid = $model->extension->isPaid();
if ($paid && ! $this->gate->allows($instance, $model->extension)) {
return response()->error("Your organization does not hold an active licence for {$model->extension->name}.", 403)
->header('Cache-Control', 'private, no-store');
}

// Instance-authenticated either way, so never in a shared cache. A free dist is immutable
// per release; a paid one is revalidated so the licence check runs again.
return $this->storage->download($model->engineDist, $request, $paid
? ['Cache-Control' => 'private, no-cache', 'Vary' => 'Authorization']
: ['Cache-Control' => 'private, max-age=31536000, immutable']);
}
}
10 changes: 6 additions & 4 deletions app/Http/Resources/ReleaseResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ public function toArray($request): array
}

/**
* The prebuilt engine dist of a release (its `engineDist` loaded), or null: what an
* installer needs to fetch `GET /npm/{engine}/-/dist/{version}` and verify it, and the
* `@fleetbase/ember-ui` / `@fleetbase/ember-core` versions it was built against.
* The prebuilt engine dist of a release (its `engineDist` loaded), or null: where an
* instance downloads it (`url`, `GET /v1/instances/me/releases/{release}/dist` with its
* instance token), what to verify it with, and the `@fleetbase/ember-ui` /
* `@fleetbase/ember-core` versions it was built against.
*
* @return array{size: int, sha256: ?string, integrity: ?string, built_against: array<string, string>}|null
* @return array{url: string, size: int, sha256: ?string, integrity: ?string, built_against: array<string, string>}|null
*/
public static function dist(Release $release): ?array
{
Expand All @@ -41,6 +42,7 @@ public static function dist(Release $release): ?array
}

return [
'url' => route('v1.instances.me.releases.dist', ['release' => $release->public_id]),
'size' => (int) $file->size,
'sha256' => $file->checksum,
'integrity' => $file->integrity,
Expand Down
18 changes: 16 additions & 2 deletions app/Services/LicenceGate.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,28 @@ public function check(Request $request, Extension $extension): array
return $this->deny(self::STATUS_FORBIDDEN, "This token does not have the 'install' scope.");
}

$organizations = $this->organizationsFor($credential);
if (in_array($extension->organization_uuid, $organizations, true) || $this->hasActiveLicence($extension, $organizations)) {
if ($this->allows($credential, $extension)) {
return ['status' => self::STATUS_ALLOWED, 'message' => null];
}

return $this->deny(self::STATUS_FORBIDDEN, "Your organization does not hold an active licence for {$extension->name}.");
}

/**
* Whether an authenticated credential may read the extension: free extensions always; paid
* ones when an organization of the credential owns the extension or holds an active licence.
*/
public function allows(Token|Instance $credential, Extension $extension): bool
{
if (! $extension->isPaid()) {
return true;
}

$organizations = $this->organizationsFor($credential);

return in_array($extension->organization_uuid, $organizations, true) || $this->hasActiveLicence($extension, $organizations);
}

/**
* @param list<string> $organizationUuids
*/
Expand Down
5 changes: 4 additions & 1 deletion routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use App\Http\Controllers\Instance\InstallController;
use App\Http\Controllers\Instance\InstanceController;
use App\Http\Controllers\Instance\PurchaseController;
use App\Http\Controllers\Instance\ReleaseDistController;
use App\Http\Controllers\Organization\BillingController;
use App\Http\Controllers\Organization\OrganizationController;
use App\Http\Controllers\Organization\PayoutController;
Expand All @@ -41,7 +42,8 @@
| Package routes (/npm, /composer) sit outside /v1 because npm and Composer
| clients address them as registry roots.
| Instance routes: Authorization: Bearer flbi_… (instance token), except
| `instances/register`, which takes a registration code in the body.
| `instances/register`, which takes a registration code in the body. A release's prebuilt
| dist downloads from `instances/me/releases/{release}/dist` behind the licence check.
| Publisher routes: personal token (or website session) with the `publish` scope and the
| owner, admin or publisher role in the listing's organization. That includes a listing's
| analytics (`extensions/{slug}/stats`); `organizations/{slug}/stats` sums every listing of
Expand Down Expand Up @@ -172,6 +174,7 @@
Route::middleware(['auth:instance', 'throttle:instances'])->group(function () {
Route::get('me', [InstanceController::class, 'me'])->name('me');
Route::get('me/updates', [InstanceController::class, 'updates'])->name('me.updates');
Route::get('me/releases/{release}/dist', [ReleaseDistController::class, 'show'])->name('me.releases.dist');
Route::post('me/installs', [InstallController::class, 'store'])->name('me.installs.store');
Route::delete('me/installs/{extension}', [InstallController::class, 'destroy'])->where('extension', '.+')->name('me.installs.destroy');
Route::post('me/purchases/checkout', [PurchaseController::class, 'checkout'])->name('me.purchases.checkout');
Expand Down
2 changes: 1 addition & 1 deletion tests/Feature/Catalog/CatalogExtensionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ function catalogExtension(array $attributes = [], array $options = []): Extensio
$engine = $extension->packages()->where('kind', 'npm')->value('name');
$latest = Release::factory()->published()->create(['extension_uuid' => $extension->uuid, 'version' => '1.1.0', 'published_at' => now()->addMinute()]);
$file = attachDist($latest, $engine, ['@fleetbase/ember-ui' => '0.4.6', '@fleetbase/ember-core' => '0.3.26']);
$dist = ['size' => (int) $file->size, 'sha256' => $file->checksum, 'integrity' => $file->integrity, 'built_against' => ['@fleetbase/ember-ui' => '0.4.6', '@fleetbase/ember-core' => '0.3.26']];
$dist = ['url' => route('v1.instances.me.releases.dist', ['release' => $latest->public_id]), 'size' => (int) $file->size, 'sha256' => $file->checksum, 'integrity' => $file->integrity, 'built_against' => ['@fleetbase/ember-ui' => '0.4.6', '@fleetbase/ember-core' => '0.3.26']];

$this->getJson('/v1/catalog/extensions')->assertOk()->assertJsonPath('data.0.latest_release.dist', $dist);

Expand Down
123 changes: 123 additions & 0 deletions tests/Feature/Instance/InstanceReleaseDistTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

use App\Models\Extension;
use App\Models\ExtensionPackage;
use App\Models\Install;
use App\Models\Instance;
use App\Models\Licence;
use App\Models\Release;

function distLicence(Extension $extension, Instance $instance, array $state = []): Licence
{
return Licence::create(array_merge([
'extension_uuid' => $extension->uuid, 'organization_uuid' => $instance->organization_uuid,
'kind' => Licence::KIND_PAID, 'max_activations' => 5, 'status' => Licence::STATUS_ACTIVE,
], $state));
}

it('streams the prebuilt dist of a free release to any instance, verified by its sha256', function () {
$package = makePackage(ExtensionPackage::KIND_NPM, '@acme/dock-notes-engine');
$release = makeRelease($package, '1.0.0');
$file = attachDist($release, $package->name);
actingAsInstance(Instance::factory()->create());

$response = $this->get('/v1/instances/me/releases/'.$release->public_id.'/dist')->assertOk()
->assertHeader('X-Checksum-Sha256', $file->checksum)
->assertHeader('ETag', '"'.$file->checksum.'"');

expect(hash('sha256', $response->streamedContent()))->toBe($file->checksum)
->and($response->headers->get('Cache-Control'))->toContain('private')
->and($response->headers->get('Cache-Control'))->toContain('immutable');

$this->withHeader('If-None-Match', '"'.$file->checksum.'"')
->get('/v1/instances/me/releases/'.$release->public_id.'/dist')->assertStatus(304);
});

it('names the download in dist.url on update releases, with size, sha256 and builtAgainst', function () {
$package = makePackage(ExtensionPackage::KIND_NPM, '@acme/dock-notes-engine');
$installed = makeRelease($package, '1.0.0');
$latest = makeRelease($package, '1.1.0', publishedAt: now()->addMinute()->toDateTimeString());
$file = attachDist($latest, $package->name, ['@fleetbase/ember-ui' => '0.4.6', '@fleetbase/ember-core' => '0.4.0']);
$instance = Instance::factory()->create();
Install::factory()->create(['instance_uuid' => $instance->uuid, 'extension_uuid' => $package->extension_uuid, 'release_uuid' => $installed->uuid, 'status' => Install::STATUS_INSTALLED]);
actingAsInstance($instance);

$row = $this->getJson('/v1/instances/me/updates')->assertOk()->json('data.0');

expect($row['installed']['dist'])->toBeNull()
->and($row['latest']['version'])->toBe('1.1.0')
->and($row['latest']['dist'])->toBe([
'url' => route('v1.instances.me.releases.dist', ['release' => $latest->public_id]),
'size' => (int) $file->size,
'sha256' => $file->checksum,
'integrity' => $file->integrity,
'built_against' => ['@fleetbase/ember-ui' => '0.4.6', '@fleetbase/ember-core' => '0.4.0'],
]);

$download = $this->get($row['latest']['dist']['url'])->assertOk();
expect(hash('sha256', $download->streamedContent()))->toBe($row['latest']['dist']['sha256']);
});

it('serves a paid dist only to instances whose organization holds an active licence or owns the extension', function () {
$package = makePackage(ExtensionPackage::KIND_NPM, '@acme/pro-engine', paid: true);
$extension = $package->extension;
$release = makeRelease($package, '1.0.0');
attachDist($release, $package->name);
$url = '/v1/instances/me/releases/'.$release->public_id.'/dist';

$instance = Instance::factory()->create();
actingAsInstance($instance);
$refused = $this->getJson($url)->assertForbidden()
->assertJsonPath('errors.0', "Your organization does not hold an active licence for {$extension->name}.");
expect($refused->headers->get('Cache-Control'))->toContain('no-store');

$licence = distLicence($extension, $instance);
$response = $this->get($url)->assertOk();
expect($response->headers->get('Cache-Control'))->toContain('no-cache')
->and($response->headers->get('Cache-Control'))->not->toContain('immutable');

$licence->forceFill(['status' => Licence::STATUS_REVOKED])->save();
$this->getJson($url)->assertForbidden();

$licence->forceFill(['status' => Licence::STATUS_ACTIVE, 'expires_at' => now()->subDay()])->save();
$this->getJson($url)->assertForbidden();

// A licence held by another organization does not count; the publisher's own instance does.
distLicence($extension, Instance::factory()->create());
$this->getJson($url)->assertForbidden();

actingAsInstance(Instance::factory()->create(['organization_uuid' => $extension->organization_uuid]));
$this->get($url)->assertOk();
});

it('keeps yanked dists downloadable and 404s unreleased, dist-less, suspended and unknown releases', function () {
$package = makePackage(ExtensionPackage::KIND_NPM, '@acme/dock-notes-engine');
$yanked = makeRelease($package, '1.0.0', status: Release::STATUS_YANKED);
attachDist($yanked, $package->name);
$plain = makeRelease($package, '1.1.0');
$review = makeRelease($package, '2.0.0', status: Release::STATUS_IN_REVIEW);
attachDist($review, $package->name);
actingAsInstance(Instance::factory()->create());

$this->get('/v1/instances/me/releases/'.$yanked->public_id.'/dist')->assertOk();
$this->getJson('/v1/instances/me/releases/'.$plain->public_id.'/dist')->assertNotFound()->assertJsonPath('errors.0', 'This release has no prebuilt dist.');
$this->getJson('/v1/instances/me/releases/'.$review->public_id.'/dist')->assertNotFound()->assertJsonPath('errors.0', 'Release not found.');
$this->getJson('/v1/instances/me/releases/rel_missing/dist')->assertNotFound();

$package->extension->forceFill(['status' => Extension::STATUS_SUSPENDED])->save();
$this->getJson('/v1/instances/me/releases/'.$yanked->public_id.'/dist')->assertNotFound();
});

it('requires an active instance token', function () {
$package = makePackage(ExtensionPackage::KIND_NPM, '@acme/dock-notes-engine');
$release = makeRelease($package, '1.0.0');
attachDist($release, $package->name);
$url = '/v1/instances/me/releases/'.$release->public_id.'/dist';

$this->getJson($url)->assertUnauthorized();

$instance = Instance::factory()->create();
actingAsInstance($instance);
$instance->forceFill(['status' => Instance::STATUS_REVOKED])->save();
$this->getJson($url)->assertUnauthorized();
});
Loading