From 0ec404900b850beef8ebf1caf1f2788560d6ce4b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 24 Oct 2026 17:07:24 +0800 Subject: [PATCH 1/2] Instance dist download: GET /v1/instances/me/releases/{release}/dist behind the licence check, and dist.url on catalog and update releases An instance fetches a release's prebuilt engine dist with its own token. Free extensions are open to any active instance; a paid one needs its organization to own the extension or hold an active licence, checked on every request. Yanked releases stay downloadable; suspended listings and releases without a dist are 404. Catalog and update releases name the download in dist.url next to size, sha256, integrity and built_against. --- README.md | 14 +- .../Instance/ReleaseDistController.php | 60 +++++++++ app/Http/Resources/ReleaseResource.php | 10 +- app/Services/LicenceGate.php | 18 ++- routes/api.php | 5 +- .../Feature/Catalog/CatalogExtensionsTest.php | 2 +- .../Instance/InstanceReleaseDistTest.php | 123 ++++++++++++++++++ 7 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 app/Http/Controllers/Instance/ReleaseDistController.php create mode 100644 tests/Feature/Instance/InstanceReleaseDistTest.php diff --git a/README.md b/README.md index 6527aa2..0fe7bd4 100644 --- a/README.md +++ b/README.md @@ -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=` 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=[,…]`, `publisher=`, `tag=`. Sorts: `popular` (default, by installs), `newest`, `updated`, `name`, `price`. @@ -412,6 +413,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`. Redeems the code (single use, 15 minutes) and returns `{token, token_type, instance}`; the token is never shown again | | `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}` | diff --git a/app/Http/Controllers/Instance/ReleaseDistController.php b/app/Http/Controllers/Instance/ReleaseDistController.php new file mode 100644 index 0000000..18b7eda --- /dev/null +++ b/app/Http/Controllers/Instance/ReleaseDistController.php @@ -0,0 +1,60 @@ +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']); + } +} diff --git a/app/Http/Resources/ReleaseResource.php b/app/Http/Resources/ReleaseResource.php index debbc3e..3d2dff3 100644 --- a/app/Http/Resources/ReleaseResource.php +++ b/app/Http/Resources/ReleaseResource.php @@ -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}|null + * @return array{url: string, size: int, sha256: ?string, integrity: ?string, built_against: array}|null */ public static function dist(Release $release): ?array { @@ -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, diff --git a/app/Services/LicenceGate.php b/app/Services/LicenceGate.php index d53b097..24f7f9d 100644 --- a/app/Services/LicenceGate.php +++ b/app/Services/LicenceGate.php @@ -46,14 +46,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 $organizationUuids */ diff --git a/routes/api.php b/routes/api.php index 9660471..5b65c48 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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; @@ -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 @@ -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'); diff --git a/tests/Feature/Catalog/CatalogExtensionsTest.php b/tests/Feature/Catalog/CatalogExtensionsTest.php index dc3fea5..c4caa3e 100644 --- a/tests/Feature/Catalog/CatalogExtensionsTest.php +++ b/tests/Feature/Catalog/CatalogExtensionsTest.php @@ -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' => 'http://localhost/v1/instances/me/releases/'.$latest->public_id.'/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']]; $this->getJson('/v1/catalog/extensions')->assertOk()->assertJsonPath('data.0.latest_release.dist', $dist); diff --git a/tests/Feature/Instance/InstanceReleaseDistTest.php b/tests/Feature/Instance/InstanceReleaseDistTest.php new file mode 100644 index 0000000..ab7eccf --- /dev/null +++ b/tests/Feature/Instance/InstanceReleaseDistTest.php @@ -0,0 +1,123 @@ + $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' => 'http://localhost/v1/instances/me/releases/'.$latest->public_id.'/dist', + '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(); +}); From 656c53d9e206add406555688c25376278479901a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 24 Oct 2026 17:32:39 +0800 Subject: [PATCH 2/2] Dist url tests: build the expected url with route(), as APP_URL differs in CI --- tests/Feature/Catalog/CatalogExtensionsTest.php | 2 +- tests/Feature/Instance/InstanceReleaseDistTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Feature/Catalog/CatalogExtensionsTest.php b/tests/Feature/Catalog/CatalogExtensionsTest.php index c4caa3e..8f29ccf 100644 --- a/tests/Feature/Catalog/CatalogExtensionsTest.php +++ b/tests/Feature/Catalog/CatalogExtensionsTest.php @@ -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 = ['url' => 'http://localhost/v1/instances/me/releases/'.$latest->public_id.'/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); diff --git a/tests/Feature/Instance/InstanceReleaseDistTest.php b/tests/Feature/Instance/InstanceReleaseDistTest.php index ab7eccf..52fbde1 100644 --- a/tests/Feature/Instance/InstanceReleaseDistTest.php +++ b/tests/Feature/Instance/InstanceReleaseDistTest.php @@ -47,7 +47,7 @@ function distLicence(Extension $extension, Instance $instance, array $state = [] expect($row['installed']['dist'])->toBeNull() ->and($row['latest']['version'])->toBe('1.1.0') ->and($row['latest']['dist'])->toBe([ - 'url' => 'http://localhost/v1/instances/me/releases/'.$latest->public_id.'/dist', + 'url' => route('v1.instances.me.releases.dist', ['release' => $latest->public_id]), 'size' => (int) $file->size, 'sha256' => $file->checksum, 'integrity' => $file->integrity,