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'sX-Accel-Redirect. - A private Composer repository.
/composer/packages.jsonlists 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.
/upmspeaks 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.jsthen 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:
*.brwithContent-Encoding: br,*.gzwithContent-Encoding: gzip;- the type of what they hold:
application/wasmfor the.wasm(the browser then compiles it as it streams in),application/javascriptfor the.js,application/octet-streamfor 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
machinesPerSeatmachines (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: theIOPlatformUUID; 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:
- the signature verifies with the public key it ships with;
softwareis its own slug;machineequals the SHA-256 (hex) of its own machine identifier;- now is before
valid_until(UNIX time) - past it, activate again, online; - 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.
Signed download links
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:
- In the dashboard's template, replace the board's markup with the include above.
- Delete the application's own move action and its script;
Base\Forge\Service\Boardis what the action called by hand. - Rename what the stylesheet styled:
.kanbanis.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 optionalcatalog) or Git packages (type: git,url). A client adds them to a project'sPackages/manifest.jsonas 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(freeorlicensed), itsoffers(id, title, price in cents, currency, seats, months) andinstallable- true when the caller's licence key (Authorization: Bearer, as for the registry) covers it;games: every published game, withplay(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):
POST /upm/-/hub/device{"item": "com.glitchr.licensing"}answers adevice_code(kept by the editor), auser_code(ABCD-EFGH, shown to the user) andverification_uri_complete, which the editor opens in the browser.- 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". - Meanwhile the editor asks
POST /upm/-/hub/token{"device_code": ...}everyintervalseconds:authorization_pending,slow_downorexpired_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).
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"
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 |