Skip to content

omnibase/forge

Software forge for base-bundle: releases built from git tags, free and licensed downloads, a private Composer repository, client projects with a shared board, prepaid support hours and quotes - paid through base-bundle-market

Floor
Prepared structure · Omnibase
Version
main
Verified for real
Its README does not say
Licence
MIT
composer require omnibase/forge:dev-main

omnibase/forge is a software forge for an omnibase site, sold through omnibase/marketplace: what a studio sells besides its time. Its namespace is Base\Forge\.

What it adds

  • Software and releases. Each git tag of a repository becomes a release; git/git-bundle reads the tree in-process and the release is zipped from it.
  • Downloads. Free software for anyone; licensed software against a licence bought as a marketplace product (LicenseOffer). Files are served through signed, short-lived addresses and nginx's X-Accel-Redirect.
  • A private Composer repository. /composer/packages.json lists the free packages for anyone and the licensed ones for the holder of a valid licence, authenticated with HTTP Basic (the e-mail and the licence key).
  • A Unity registry. /upm speaks the npm registry protocol Unity's Package Manager reads from a scoped registry (The Unity registry).
  • The hub. A manifest, a catalogue with the paid packages' offers and the games, and a device flow that links a Unity editor to its user's licences (The hub).
  • Games and applications. Builds per platform attached to each release, a WebGL build played in the game's page (Games and applications, Licensing).
  • Client projects. A board the client and the studio both move cards on (The project board), the time logged, the repository's history, the pipelines (Pipelines).
  • Support hours. Prepaid packs (HourPack) credit a ledger, logged time debits it; the balance is the sum of the ledger's lines.
  • Quotes. Asked from the site, priced and sent from the back office, accepted and paid like any order, their hours credited (Quotes).

Pages

Propose a change docs/index.md

Installation

Requirements

  • PHP 8.2 or later, ext-zip;
  • doctrine/orm 2.15 or 3;
  • omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x, omnibase/admin, omnibase/marketplace;
  • git/git-bundle 1.x, with the php-git2 extension (ext-git2), to read the repositories (releases, the project's history);
  • omnisense/omnistate with omnistate/annuaire-entreprises: they turn on the marketplace's company register, which checks the SIRET numbers typed in quote requests;
  • Symfony 6, 7 or 8 (config, dependency-injection, form, http-kernel, security-bundle).

The bundle

composer require omnibase/forge:dev-main
// config/bundles.php
Omnistate\Bridge\Symfony\OmnistateBundle::class => ['all' => true],
Base\Forge\ForgeBundle::class => ['all' => true],

The routes

# config/routes.yaml
forge_controller:
    resource: "@ForgeBundle/src/Controller"
    type: attribute
    prefix: /

The configuration

Every key is optional (Configuration reference):

# config/packages/forge.yaml
forge:
    storage_dir: '%kernel.project_dir%/var/storage/forge'          # release archives, never under public/
    repositories_dir: '%kernel.project_dir%/var/storage/repositories'
    accel_prefix: '/_protected/forge/'   # nginx internal location; empty: PHP streams the file
    download_ttl: 600                    # seconds a signed link stays valid
    support_store: support               # marketplace store selling hour packs and quotes
    software_store: logiciels            # marketplace store selling licence offers
    hourly_rate: 6000                    # default rate of a quote line, in cents
    low_balance_minutes: 120             # below this, forge:hours:low-balance reminds the client
    contact_email: '%env(MAILER_CONTACT)%'   # where quote requests are mailed

To open each client their own repositories in git/git-bundle's viewer:

# config/packages/git.yaml
git:
    access_role: ROLE_USER
    repository_attribute: GIT_VIEW       # Base\Forge\Security\Voter\GitRepositoryVoter

Every project and piece of software with a repository shows up in the viewer through Base\Forge\Git\ForgeRepositoryProvider.

nginx

The archives are handed out through an internal location:

location /_protected/forge/ {
    internal;
    alias /srv/app/var/storage/forge/;
}

How a purchase is delivered

omnibase/marketplace's Checkout::confirm() dispatches OrderPaidEvent, and Base\Forge\EventSubscriber\OrderPaidSubscriber delivers the order:

Product Delivered
HourPack minutes times quantity credited to the buyer's ledger
LicenseOffer one License per unit (seats, duration and updates taken from the offer)
a quote's own HourPack its hours credited, the quote marked paid

Delivery is idempotent per order reference. Bank transfers (the manual gateway) stay pending until someone in the back office posts to forge_admin_order_paid, which confirms the order.

Licence keys (glk_...) are generated by their owner from /compte/licences and shown once; only their SHA-256 is stored.

The cron

Command Suggested
forge:release:sync hourly
forge:license:expire daily
forge:hours:low-balance weekly

The templates

They extend the site's layout1.html.twig, use forge-* classes for the site to style, and are overridden in templates/bundles/ForgeBundle/.

Propose a change docs/installation.md

Games and applications

A piece of software has a kind (Software::$kind, SoftwareKind): package (Composer, Unity), game or app. A game's or an application's releases carry builds, one per platform (Platform: webgl, windows, macos, linux, android), made elsewhere - Unity, a CI - and attached to the release:

bin/console forge:artifact:attach <slug> <version> <platform> <file> [--publish] [--changelog "..."]

or from the back office (Releases > Builds). The release is made when it does not exist yet: a game need not come from a repository's tags. A build replaces the platform's previous one; its sha256 is taken on the way in.

Platform File
webgl a zip of the build folder (index.html, Build/, TemplateData/), at its root or under one folder
windows, linux a zip of the build folder
macos a zip of the .app, or a .dmg
android an .apk or an .aab

Free and paid games

  • A free game is played in the browser (its WebGL build, in its page) and downloaded freely, its builds per platform.
  • A paid game is downloaded once bought (a licence offer, as any licensed software), its builds per platform through signed links. It is played in the browser only when the studio opens a playable preview of it (Software::$playablePreview, off by default; "Playable preview" in the back office) - then by anyone, as a demo.

The game's page

The software page (/telechargements/<slug>) of a game shows, above its versions:

  • Play, when its newest version has a WebGL build and the game plays in the browser (free, or a paid game with its playable preview open). Nothing of the game loads before Play is pressed: bundles/forge/js/play.js then frames /play/<slug>/<version>/ in the page (without JavaScript the link opens it on its own), with a full-screen button. A game that stores nothing before it is played leaves the site's cookie panel untouched; build it with Data Caching off (Player Settings > Publishing Settings) so that Unity writes nothing to the browser's IndexedDB either.
  • Its trailer (an uploaded video) and its screenshots (uploads, in their order), both set in the back office.
  • Under each version, its changelog and its builds: a signed, short-lived link each (/telechargements/<slug>/<version>?format=<platform>), its size and its sha256.

WebGL builds: compression

/play/<slug>/<version>/<path> (PlayController, WebglBuilds) unpacks the build once, beside its zip in the storage, and serves each file with what Unity's loader needs:

  • *.br with Content-Encoding: br, *.gz with Content-Encoding: gzip;
  • the type of what they hold: application/wasm for the .wasm (the browser then compiles it as it streams in), application/javascript for the .js, application/octet-stream for the .data.

So the browser decompresses the build, not Unity's JavaScript: build with Brotli (Unity's default; Brotli needs HTTPS, which the site is) and leave Decompression Fallback off - it would make the loader bigger and slower for nothing. The headers are set by PHP, so they do not depend on the web server's configuration. A build made with the fallback (.unityweb files) is served too, as it is.

The frame is the stage's size (16:9, the page's width): build with a WebGL template whose canvas fills its window (width: 100%; height: 100%, matchWebGLToCanvasSize: true) - Unity's Default template draws a fixed 960x600 canvas, cropped in the frame on a phone. The sample game's Assets/WebGLTemplates/Forge/index.html is one: a canvas, a loading bar, nothing else.

A paid game without a playable preview is not played here (403; the staff may, to check a build), its files then cached by the browser alone (Cache-Control: private).

Checking a licence from the game

com.glitchr.licensing, a Unity package served by the forge's own registry (see upm.md), calls the licence API (see licensing.md) and checks the signed token offline.

Propose a change docs/games.md

Licensing a game or an application

How a licensed piece of software - a Unity game, a desktop application such as Switch for Coder - checks that it may run, machine by machine.

The model

  • A licence (License) covers one software for so long (expiresAt, null: perpetual) and its releases until a date (updatesUntil).
  • It has seats (seats), an e-mail each (LicenseSeat): the buyer's own first, the others given from their account or from the back office. Each seat has its own key (glk_…), made from the account page.
  • Each seat may activate the software on machinesPerSeat machines (2 by default, set on the offer). Its holder frees a machine from their account ("Mes licences"); the studio from the back office (Machines activées).

The API

JSON over HTTPS, no session: the seat's key is the credential.

POST /api/licenses/activate

At the first start, and then whenever the token is about to run out.

{
  "key": "glk_XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX",
  "software": "switch-for-coder",
  "machine": "<a stable identifier of this machine>",
  "name": "MacBook Air de Camille",
  "platform": "macos",
  "version": "1.4.0"
}
  • software: the software's slug on the site - a key opens only its own.
  • machine: stable across restarts and updates. Unity: SystemInfo.deviceUniqueIdentifier; macOS: the IOPlatformUUID; Windows: MachineGuid. The site stores only its SHA-256.
  • name, platform, version: shown to the holder, to recognise the machine.

Answers:

Status Body What to do
200 {"status": "active", "token": "…", "license": {…}, "machines_left": 1} Store the token; run.
401 {"error": "invalid_key"} Ask for the key again.
403 {"error": "wrong_software"} / {"error": "license_not_valid", "status": "revoked"} Stop; say why.
409 {"error": "no_machine_left", "machines": [{"name", "platform", "last_seen"}], "manage_url": "…"} Show the machines, open manage_url to free one.
422 the validation errors A bug in the request.
503 {"error": "not_configured"} The site has no signing key yet.

Activating an already active machine takes no new place: call it as often as needed.

POST /api/licenses/release

{"key": "…", "machine": "…"} - the machine frees its own place (an uninstall, a "deactivate" menu). {"status": "released"} or {"status": "not_activated"}.

GET /api/licenses/public-key

{"algorithm": "Ed25519", "public_key": "<base64>"} - ship it in the software rather than fetching it: a key fetched at run time could be swapped.

The token, checked offline

token = base64url(payload JSON) + "." + base64url(Ed25519 signature of the payload JSON)

The software runs when, all of them:

  1. the signature verifies with the public key it ships with;
  2. software is its own slug;
  3. machine equals the SHA-256 (hex) of its own machine identifier;
  4. now is before valid_until (UNIX time) - past it, activate again, online;
  5. for a version gate: its release date is before updates_until, when set.

valid_until is FORGE_LICENSE_GRACE_DAYS (30 by default) after the last activation, never beyond the licence's expiry: a machine offline for longer must come back online once; a revoked licence or a freed machine stops at the latest then.

Unity (C#)

The com.glitchr.licensing package (served by the forge's Unity registry, see upm.md; source: github.com/glitchr-studio/unity-licensing) does all of it - LicenseClient.ActivateAsync(), LicenseClient.Check() -, with Ed25519 in managed code. By hand, with an Ed25519 library:

string[] parts = token.Split('.');
byte[] json = Base64Url.Decode(parts[0]);
byte[] signature = Base64Url.Decode(parts[1]);
bool genuine = Ed25519.Verify(signature, json, PublicKey);
var payload = JsonUtility.FromJson<LicensePayload>(Encoding.UTF8.GetString(json));
string machine = Sha256Hex(SystemInfo.deviceUniqueIdentifier);
bool runs = genuine && payload.software == "my-game" && payload.machine == machine
    && DateTimeOffset.UtcNow.ToUnixTimeSeconds() < payload.valid_until;

Swift (macOS)

CryptoKit verifies Ed25519 natively:

let parts = token.split(separator: ".").map(String.init)
let json = Data(base64URLEncoded: parts[0])!, signature = Data(base64URLEncoded: parts[1])!
let key = try Curve25519.Signing.PublicKey(rawRepresentation: Data(base64Encoded: publicKey)!)
let genuine = key.isValidSignature(signature, for: json)
let payload = try JSONDecoder().decode(LicensePayload.self, from: json)
let machine = SHA256.hash(data: Data(platformUUID.utf8)).map { String(format: "%02x", $0) }.joined()
let runs = genuine && payload.software == "switch-for-coder" && payload.machine == machine
    && Date().timeIntervalSince1970 < payload.valid_until

The signing key

bin/console forge:license:keypair makes a pair. The secret goes in the vault (bin/console secrets:set FORGE_LICENSE_SIGNING_KEY), the public key in the software. A new pair makes every token issued so far unverifiable: ship the new public key first.

An artifact is downloaded through a link signed by omnisense/omnibase's Base\Service\DownloadLinks (route forge_download_file, valid forge.download_ttl seconds; the Composer index signs its links for thirty days). Who may download was checked when the link was handed out (the licence); the download action only checks the signature. The forge no longer has a DownloadLinks of its own.

Propose a change docs/licensing.md

The project board

A project (Base\Forge\Entity\Project) has a board: four columns (Base\Forge\Enum\CardColumn: to do, in progress, in review, delivered) and its cards (Base\Forge\Entity\Card), which the client and the studio both move. The bundle ships the whole of it: the page, the move, the script and the stylesheet.

What it gives

GET /projets/{id}/tableau (forge_project_board) The board on a page of its own, in the host's layout
PATCH /projets/cartes/{id} (forge_board_card) A card moved: {"column": "review", "position": 0}, the board's token in X-CSRF-Token
@Forge/client/project/_board.html.twig The board alone, to include in a page of one's own
Base\Forge\Service\Board move($card, $column, $position) renumbers the columns it touches; add($project, $card) puts a card at the end of its column
bundles/forge/js/board.js, bundles/forge/css/board.css Drag and drop and the arrow keys, each move saved at once; no dependency

Who sees a project and who moves its cards is ProjectVoter's: its client and the staff. A delivered project (ProjectStatus::DONE) is read-only for its client: the page carries no token and its script leaves it alone.

In a page of one's own

{% include '@Forge/client/project/_board.html.twig' with {project: project} %}
Variable
project The project
editable Whether the cards move. Default: what ProjectVoter::EDIT_BOARD says of the visitor
assets false when the page already loads the stylesheet and the script. Default: the include prints both

Run bin/console assets:install once, as for any bundle with public files.

The script needs nothing called: every [data-forge-board] carrying a data-token is taken in charge, the ones a page brings in later too (a page swapped in without a reload). It announces what happens on the board's element:

Event detail
forge-board:moved {card, column, position}, once the server has saved the move
forge-board:error {message}, when it refused it: the card is back where it was

A refused move is said with SweetAlert when the page has it (window.Swal), in a role="alert" paragraph of the board otherwise.

Its look

The stylesheet sets no colour of its own beyond defaults; a site sets these on .forge-board or above:

.forge-board {
    --forge-board-column: var(--my-surface-soft);
    --forge-board-card: var(--my-surface);
    --forge-board-soft: var(--my-ink-soft);
    --forge-board-radius: .75rem;
    --forge-board-backlog: #8a8f98;
    --forge-board-progress: #1c7ed6;
    --forge-board-review: #f08c00;
    --forge-board-done: #2f9e44;
}

A card carries is-backlog, is-progress, is-review or is-done; a column being dragged over, is-over; the card in hand, is-dragging.

An application that had its own board

An application that wrote its own board before the bundle had one (a controller renumbering the cards, a Stimulus controller, the markup in its dashboard) keeps working as it is: the bundle adds routes, a template and a service, and changes nothing that was there. To move over:

  1. In the dashboard's template, replace the board's markup with the include above.
  2. Delete the application's own move action and its script; Base\Forge\Service\Board is what the action called by hand.
  3. Rename what the stylesheet styled: .kanban is .forge-board, .kanban-column .forge-board-column, .kanban-card.kanban-review .forge-board-card.is-review - or drop those rules and set the custom properties.

Propose a change docs/board.md

Pipelines

A pipeline (Base\Forge\Entity\Pipeline) is one run of a project's checks on a commit - its tests, its build, its deployment -, wherever it ran: a forge's CI, a script. It has stages in order (Stage), each with its jobs (Job). The bundle records what happened and shows it; it runs nothing itself.

Recording a run

use Base\Forge\Entity\Pipeline;
use Base\Forge\Enum\PipelineStatus;

$pipeline = $pipelines->findOneBySource('gitlab', '4821')            // the same run, brought up to date
    ?? (new Pipeline($project, 'main', $sha))->setSource('gitlab', '4821');
$pipeline->setUrl('https://gitlab.example.org/site/-/pipelines/4821');

$pipeline->stage('install')->job('composer', PipelineStatus::SUCCESS);
$pipeline->stage('test')->job('phpunit', PipelineStatus::RUNNING)->setUrl('https://gitlab.example.org/site/-/jobs/9917');
$pipeline->stage('test')->job('lint', PipelineStatus::FAILED)->setAllowFailure(true);
$pipeline->stage('deploy')->job('production', PipelineStatus::MANUAL)->setNeeds(['phpunit']);

$entityManager->persist($pipeline);
$entityManager->flush();

stage() and job() find the one of that name or make it, so the same lines bring a run up to date as its jobs move on. source and its id name a run once (a unique pair).

A status (Base\Forge\Enum\PipelineStatus: pending, running, success, warning, failed, canceled, skipped, manual) is set on jobs only. A stage is what its jobs say and a pipeline what its stages say - failed as soon as one failed, running while one runs, waiting on a manual step - unless the pipeline's own status was set by hand (setStatus()). A job allowed to fail that failed counts as a warning.

The bundle has no listener for a CI's webhooks yet: what records the runs (a webhook's controller, a command the CI calls) is the application's for now.

Seeing it

/admin/forge/pipelines (forge_admin_pipelines) The latest runs of every project, each as its graph
/admin/forge/pipelines/{id} (forge_admin_pipeline) One run: its graph, its jobs in a table, the project's earlier runs
PipelineCrudController The runs in the back office's lists, with a "Graphe" action
@Forge/client/project/_pipelines.html.twig A project's latest runs, to include in a page of one's own; the board's page (forge_project_board) does
@Forge/_pipeline.html.twig One run as its graph
{% include '@Forge/client/project/_pipelines.html.twig' with {project: project, limit: 3} %}

The graph

A run is drawn by @glitchr/graphjs, which omnibase/admin vendors and its layout loads on every page of the back office: a node per job - its stage under its name, in the colour of its status -, ranked by stage, an edge from each job it waits for (the ones needs names, or every job of the stage before). The template writes it in the page (data-graph, the pipeline preset), so the jobs still read, in order, without the script. Outside the back office the include loads the library itself, once per page (assets: false when the page already does).

Base\Forge\Service\PipelineGraph::data($pipeline) returns the same graph as data - {nodes: [{id, label, sub, rank, state, href, title}], edges: [{from, to}]} - for Graph.setData(), when a page follows a run that is still moving:

const graph = Graph.get(document.querySelector('#forge-pipeline-12'));
graph.setState('job-31', 'success');

Tables

forge_pipeline, forge_pipeline_stage, forge_pipeline_job: an application that updates the bundle writes their migration (doctrine:migrations:diff).

Propose a change docs/pipelines.md

The hub

The forge serves, beside its Unity registry (upm.md), what the hub clients read: the Unity window com.glitchr.hub (Window > Glitch Art Hub, in the editor) and the desktop hub. A client is pointed at one URL, the hub manifest, and finds there the brand, the sources and whether something can be bought.

The manifest: GET /upm/-/hub.json

{
  "version": 1,
  "name": "Glitch Art",
  "logo": "https://glitchr.io/assets/images/brand/monkey.png",
  "color": "#fc6875",
  "sources": [
    {
      "name": "Glitch Art",
      "type": "upm",
      "url": "https://glitchr.io/upm",
      "scopes": ["com.glitchr"],
      "catalog": "https://glitchr.io/upm/-/hub/catalog.json"
    }
  ],
  "games": "https://glitchr.io/upm/-/hub/catalog.json",
  "checkout": {
    "device": "https://glitchr.io/upm/-/hub/device",
    "token": "https://glitchr.io/upm/-/hub/token"
  },
  "licenses": {
    "list": "https://glitchr.io/upm/-/hub/licenses",
    "activate": "https://glitchr.io/api/licenses/activate",
    "release": "https://glitchr.io/api/licenses/release"
  }
}
  • sources: UPM registries (type: upm, url, scopes, an optional catalog) or Git packages (type: git, url). A client adds them to a project's Packages/manifest.json as scoped registries or Git dependencies.
  • checkout: present when the hub sells. A client shows "Buy" only for a source whose manifest has it - a static hub (a copied template on GitHub Pages) never does.

It is configured in config/packages/forge.yaml:

forge:
    hub:
        name: 'Glitch Art'
        logo: '/assets/images/brand/monkey.png'   # or an absolute URL
        color: '#fc6875'
        scopes: ['com.glitchr']                    # empty: those of the Unity packages
        checkout: true

The catalogue: GET /upm/-/hub/catalog.json

What a shop window shows and the Package Manager's search does not:

  • packages: every published Unity package, the paid ones too, each with its versions and their changelog, its picture and screenshots, its page, pricing (free or licensed), its offers (id, title, price in cents, currency, seats, months) and installable - true when the caller's licence key (Authorization: Bearer, as for the registry) covers it;
  • games: every published game, with play (the address of its WebGL build) only when it is played in the browser - a free game, or a paid one whose playable preview is open (games.md) -, its platforms and its offers.

Buying: the device flow

The editor has no session on the site. It is linked to its user's licences the way a television is linked to an account (OAuth's device flow, RFC 8628):

  1. POST /upm/-/hub/device {"item": "com.glitchr.licensing"} answers a device_code (kept by the editor), a user_code (ABCD-EFGH, shown to the user) and verification_uri_complete, which the editor opens in the browser.
  2. On that page (/hub/link), signed in, the user sees the code and the item's offers; they buy one through the shop's cart and checkout, in a tab of their own, then come back and press "Link this editor".
  3. Meanwhile the editor asks POST /upm/-/hub/token {"device_code": ...} every interval seconds: authorization_pending, slow_down or expired_token (400) until the user links it; then, once, {"key", "email", "registry"}.

The key is a new key of one of the user's seats (the seat's previous key stops working); it opens every licence of that e-mail, as for Composer. The editor stores it as the registry's token in the user's ~/.upmconfig.toml, never in the project. The codes live ten minutes, in the cache only.

Licences: GET /upm/-/hub/licenses

With the key as a Bearer: the holder's e-mail and each valid licence - its software (slug, which the licence API asks for, its name, its UPM name, its page), status, expiry, updates, seats, machines per seat, and the machines of that holder's seat, each with the SHA-256 of its identifier (machine), which a client compares with its own to say "this machine". A client activates and releases itself through the addresses the manifest lists under licenses (/api/licenses/activate, /api/licenses/release, see licensing.md).

Propose a change docs/hub.md

The Unity registry

The forge serves its Unity packages to Unity's Package Manager as a scoped registry: the npm registry protocol, under /upm, beside the Composer repository (/composer/packages.json) it mirrors. A piece of software is a Unity package when it has a UPM name (Software::$upmName, com.glitchr.<name>) and a git repository whose tree at each tag is the package: package.json at its root, every asset with its .meta.

What it serves

Request Answer
GET /upm/-/v1/search?text=<scope>&from=<n>&size=<n> The packages whose name, display name, description or keywords hold every word of text, one page.
GET /upm/-/all Every package, the older listing.
GET /upm/<name> The package document: each version the caller may install, its package.json at that tag, dist.tarball, dist.shasum (sha1), dist.integrity (sha512).
GET /upm/<name>/-/<name>-<version>.tgz The tarball.

This is what Unity 6 (6000.0) was seen asking, on 2026-10-26, from a probe registry: the search, page after page until one comes back empty (/-/all when the search is a 404), then the package document, then HEAD and GET of the tarball - every request with the registry's token from .upmconfig.toml as Authorization: Bearer <token>.

The tarball

ArtifactBuilder::buildUpm() writes it from the tree at the tag, as npm does: a gzip'd ustar archive, every file under package/, its package.json carrying the package's UPM name and the release's version (whatever the tag's file said). Its sha256, sha1 and sha512 are stored on the artifact (format upm). forge:release:sync builds it with the zip for each new tag; "Build" in the back office rebuilds both.

Free and licensed packages

A free package is listed and installed by anyone. A licensed one only by the holder of a valid licence covering that version (License::covers()), as for Composer: the licence key goes in the user's ~/.upmconfig.toml (%USERPROFILE%\.upmconfig.toml on Windows):

[npmAuth."https://glitchr.io/upm"]
token = "glk_XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
alwaysAuth = true

The key alone is enough: it names its seat, whose e-mail names every licence it holds. _auth = "<base64 of e-mail:key>" (Basic) works too, the e-mail then checked against the seat's.

Caller Search A licensed package's document and tarball
No key free packages only 401, WWW-Authenticate: Bearer
A wrong key 401 401
A key whose licences do not cover it its covered packages 403
A key that covers it listed 200

Each tarball handed out is one Download row, channel upm, counted against the licence that covered it (a HEAD is not counted).

The host's firewall

The registry reads the Authorization header itself. A firewall of the host that authenticates Bearer tokens on every path (Symfony's access_token, as on the main firewall of omnibase's sites) would take the licence key for one of its own tokens and refuse it before the registry sees it (401 invalid_token). Keep /upm out of it, before that firewall:

# config/packages/security.yaml
security:
    firewalls:
        upm:
            pattern: ^/upm/
            stateless: true
            security: false

In the project

"scopedRegistries": [
  {"name": "Glitch Art", "url": "https://glitchr.io/upm", "scopes": ["com.glitchr"]}
],
"dependencies": {
  "com.glitchr.licensing": "1.0.0"
}

The software page shows these lines for each Unity package.

A development site

A forge run locally behind a self-signed certificate (https://localhost:8453) is refused by the Package Manager until it trusts that certificate: give it the certificate as an extra certificate authority, caFile in the Package Manager's configuration file (Unity manual, "Configure your network for Package Manager"):

caFile = "/path/to/localhost.crt"

Propose a change docs/upm.md

Quotes

A client asks for a quote from the site (/support/devis), the studio prices it in the back office - lines of hours at a rate, a discount, a date - and sends it (/admin/forge/quote/{id}/send); the client reads it at /devis/{token}, accepts it, and pays a one-off HourPack holding its hours. Paid, the hours are credited and the quote marked paid (EventSubscriber\OrderPaidSubscriber).

What is omnibase/marketplace's

Since the marketplace's business quotes, what every quote is lives there, and the forge keeps only its hours:

Here From omnibase/marketplace
Entity\Quote (table forge_quote, unchanged) extends Base\Marketplace\Entity\Quote\AbstractQuote: client, company check, status, discount, validity, token, isAcceptable(), accept(), markPaid()
Entity\QuoteLine (minutes at an hourly rate) -
Enum\QuoteStatus an alias of Base\Marketplace\Enum\QuoteStatus (requested, draft, sent, accepted, paid, declined)
Repository\QuoteRepository Base\Marketplace\Quote\QuoteRepositoryTrait: saveNumbered() (Q-2026-0001), findForClient(), pipeline()
Service\QuoteToOrder extends Base\Marketplace\Service\QuoteToOrder: a forge quote becomes one HourPack of the support store in the client's cart
Service\QuoteStatusGuard delegates to Base\Marketplace\Service\QuoteStatusGuard
the request form Base\Marketplace\Form\QuoteRequestType with ['trade' => false] and Base\Marketplace\Model\QuoteRequest (the forge's own copies are gone)

hasSomethingToSell() is the hours: a quote without any cannot be accepted.

Upgrading

Nothing in the database: forge_quote and forge_quote_line keep their columns (the schema generated before and after is the same). An application using Base\Forge\Form\QuoteRequestType or Base\Forge\Model\QuoteRequest directly uses the marketplace's instead; Base\Forge\Enum\QuoteStatus keeps working (an alias).

Propose a change docs/quotes.md

Configuration reference

The forge tree

Dumped with Symfony's YamlReferenceDumper from Base\Forge\DependencyInjection\ForgeConfiguration:

forge:

    # Where release archives are written. Never under public/: they are handed out by the download controller.
    storage_dir:          '%kernel.project_dir%/var/storage/forge'

    # Where the repositories of projects and software that are not in git.repositories are cloned (<name>.git).
    repositories_dir:     '%kernel.project_dir%/var/storage/repositories'

    # The nginx `internal` location aliasing storage_dir (X-Accel-Redirect). Empty: PHP streams the file itself.
    accel_prefix:         /_protected/forge/

    # How long a signed download link stays valid, in seconds.
    download_ttl:         600

    # The market store (slug) hour packs and accepted quotes are sold from.
    support_store:        support

    # The market store (slug) licence offers are sold from.
    software_store:       logiciels

    # Default hourly rate of a quote line, in cents.
    hourly_rate:          6000

    # Below this balance a client is reminded to top up (forge:hours:low-balance).
    low_balance_minutes:  120

    # Where quote requests are mailed. Null: they are only listed in the back office.
    contact_email:        null

    # The hub manifest (/upm/-/hub.json) the Unity window (com.glitchr.hub) and the desktop hub read: the brand and the sources. See docs/hub.md.
    hub:

        # The hub's name, shown by the clients.
        name:                 Forge

        # Its logo: an absolute URL, or a path on this site.
        logo:                 null

        # Its accent colour, #rrggbb.
        color:                null

        # The scopes of the Unity registry (com.glitchr): those of the Unity packages when empty.
        scopes:               []

        # Whether the clients offer to buy: the licensed packages' offers, bought on this site and linked to the editor by a device code.
        checkout:             true
Key Default Read by
storage_dir var/storage/forge the release archives and builds; never under public/
repositories_dir var/storage/repositories the clones of the repositories not in git.repositories (<name>.git)
accel_prefix /_protected/forge/ the download controller's X-Accel-Redirect; empty, PHP streams the file
download_ttl 600 how long a signed download link stays valid, in seconds
support_store support the marketplace store hour packs and accepted quotes are sold from
software_store logiciels the store licence offers are sold from
hourly_rate 6000 a quote line's default rate, in cents
low_balance_minutes 120 forge:hours:low-balance: the balance under which a client is reminded
contact_email null where quote requests are mailed; null, the back office only
hub.name, logo, color, scopes, checkout Forge, none, none, the Unity packages', true the hub manifest (The hub)

Each key is a container parameter (%forge.storage_dir%, %forge.hub.name%...).

Propose a change docs/configuration.md

Reference

Routes

Name Path Methods Access
forge_downloads /telechargements any everyone
forge_software /telechargements/{slug} any everyone
forge_download /telechargements/{slug}/{version} any checks the licence, redirects to a signed address
forge_download_file /telechargements/fichier/{id}/{filename} any the signed address
forge_play /play/{slug}/{version}/{path} GET, HEAD a WebGL build's files, with the headers Unity's compressed builds need (Games)
forge_composer_packages /composer/packages.json GET everyone; the licensed packages for HTTP Basic (e-mail, licence key)
forge_upm_search, forge_upm_all /upm/-/v1/search, /upm/-/all GET the Unity registry (The Unity registry)
forge_upm_package /upm/{name} GET, HEAD the same
forge_upm_tarball /upm/{name}/-/{filename} GET, HEAD the same; licensed packages with the licence key as token
forge_hub_manifest /upm/-/hub.json GET everyone (The hub)
forge_hub_catalog /upm/-/hub/catalog.json GET everyone
forge_hub_licenses /upm/-/hub/licenses GET a licence key: its licences and its seat's machines (a hub client's Licences page)
forge_hub_device, forge_hub_token /upm/-/hub/device, /upm/-/hub/token POST the device flow
forge_hub_link /hub/link GET, POST ROLE_USER: the code a device shows
forge_api_license_activate, forge_api_license_release /api/licenses/activate, /api/licenses/release POST a licence key (Licensing)
forge_api_license_public_key /api/licenses/public-key GET everyone: the key that checks activation tokens
forge_account_licenses /compte/licences any ROLE_USER
forge_license_rekey /compte/licences/{id}/cle POST ROLE_USER: a new key, shown once
forge_license_seat_give /compte/licences/{id}/postes POST ROLE_USER
forge_license_seat_take_back /compte/licences/{id}/postes/{seat}/retirer POST ROLE_USER
forge_license_machine_release /compte/licences/{id}/machines/{activation}/liberer POST ROLE_USER
forge_project_board /projets/{id}/tableau GET ROLE_USER (The project board)
forge_board_card /projets/cartes/{id} PATCH ROLE_USER: a card moved
forge_review /avis/{token} GET, POST the review's token: a client reviews a project
forge_shop /support any everyone
forge_quote_request /support/devis GET, POST everyone (Quotes)
forge_quote /devis/{token} any the quote's token
forge_quote_accept /devis/{token}/accepter POST ROLE_USER
forge_admin_release_build /admin/forge/release/{id}/build any ROLE_ADMIN: build a release's archive
forge_admin_release_builds /admin/forge/release/{id}/builds GET, POST ROLE_ADMIN: a release's platform builds
forge_admin_quote_send /admin/forge/quote/{id}/send any ROLE_ADMIN
forge_admin_project_review /admin/forge/project/{id}/review any ROLE_ADMIN: ask the client for a review
forge_admin_order_paid /admin/forge/order/{reference}/paid POST ROLE_ADMIN: a bank transfer received
forge_admin_order_refund /admin/forge/order/{reference}/refund GET, POST ROLE_ADMIN: hours refunded
forge_admin_pipelines, forge_admin_pipeline /admin/forge/pipelines, /admin/forge/pipelines/{id} GET ROLE_ADMIN (Pipelines)

Commands

Command Arguments and options Does
forge:release:sync [slugs...], --no-fetch, --no-build fetches the repositories, turns new version tags into releases and builds their zip
forge:artifact:attach slug version platform file, --publish, --changelog attaches a platform build (webgl, windows, macos, linux, android) to a release, made if missing
forge:license:expire marks the licences past their expiry as expired
forge:license:keypair makes the key pair that signs the licence activation tokens
forge:hours:low-balance --dry-run e-mails the clients whose support hours run low

Twig

Name Kind Returns
forge_pipelines(project, limit = 5) function a project's latest pipelines
forge_pipeline_graph(pipeline) function a run's nodes and edges, for Graph.setData()
forge_pipeline_sources(pipeline) function each node and the nodes it comes from
forge_hours filter minutes as hours and minutes
forge_money(currency = 'EUR', locale = null) filter cents as an amount
forge_price(currency = 'EUR', locale = null) filter cents as a price, marked safe

Listeners

Listener On
OrderPaidSubscriber omnibase/marketplace's OrderPaidEvent: delivers hour packs, licences, quotes (Installation)
TimeEntryLedgerSubscriber Doctrine's onFlush: logged time debits the hours ledger
LicenseSeatListener a seat saved from the back office: its holder is told once, as from the owner's account
LicenseApiErrorSubscriber the licence API's errors answered in JSON with their own status (422 for a request it cannot read), never the site's HTML error page

Enums

Enum Cases
SoftwareKind package, game, app
SoftwareStatus live, dev, draft
Pricing free, licensed
Platform webgl, windows, macos, linux, android
LicenseStatus active, expired, revoked
ProjectStatus open, paused, done
CardColumn backlog, progress, review, done
PipelineStatus pending, running, success, warning, failed, canceled, skipped, manual
QuoteStatus requested, draft, sent, accepted, paid, declined
CreditReason purchase, time, adjustment, refund

Back office screens

Projects, cards, time entries, pipelines, project reviews, software, releases, downloads, licences, licence offers, seats, activations, hour packs, hour credits, hour refunds and quotes, each a CRUD of omnibase/admin.

Propose a change docs/reference.md

Testing

Running the suite

vendor/bin/phpunit                                   # standalone
vendor/bin/phpunit -c vendor/omnibase/forge          # from a host

In omnisense/omnibase-harness, mounting the checkout in place of the installed package:

docker run --rm --network none \
    -v "$PWD":/srv/demo/vendor/omnibase/forge:ro \
    omnisense/omnibase-harness:latest test forge

On this checkout, in the harness:

OK (48 tests, 460 assertions)

What the tests cover

Folder Tests
Controller the board's controller, the CRUDs' required fields, a game's pages, the hub, the pipelines' screens, the Unity registry
Entity licences, projects, quotes
Enum PipelineStatusTest
Model CardMoveTest: a card's move as the board sends it
Service the board, the hours ledger, licence keys, the pipeline graph, the Unity tarballs

Propose a change docs/testing.md