Metadata-Version: 2.4
Name: splent_feature_spl_registry
Version: 0.2.0
Requires-Python: >=3.13
Description-Content-Type: text/markdown
Requires-Dist: requests>=2.31

# splent_feature_spl_registry

The marketplace's registry of software product lines. It owns which SPLs exist,
who owns each one, the DOI pointer to the published UVL model, and the relay
that publishes a model to UVLHub.

Each line's name, description and `{mirror, doi, file}` pointer live in the
`spl` table, and `GET /api/v1/spls` serves them to the splent CLI and to the
job that builds the marketplace index.

## The model

```
developer --(splent CLI with a splent token)--> MARKETPLACE --(one server-side key)--> UVLHub
```

A developer authenticates to the marketplace with a token from
`splent_feature_apikeys` and sends a `.uvl` file. They never hold UVLHub
credentials, never name a UVLHub dataset and never learn UVLHub is involved.
The marketplace validates the model, relays it with its single key, and stores
the DOI pointer. The UVL content itself is never stored here.

Publishing a FEATURE is unchanged: `splent feature:release`, no account, no
token, the git tag is the publication. This feature is only about product
lines.

## Ordering

A release row is written **before** the irreversible call to UVLHub, then
updated with the outcome. This is the point of the design. If the publish call
times out and nothing was written down first, the marketplace cannot tell
whether a DOI was minted, and the honest answer ("I do not know") gets replaced
by the tempting one ("try again"), which mints a second permanent DOI for the
same model.

A release therefore settles into one of three states, and only one of them is
safe to retry:

| state       | meaning                                    | retry |
|-------------|--------------------------------------------|-------|
| `published` | UVLHub minted a DOI, recorded here         | not needed, republishing the same bytes is idempotent |
| `failed`    | UVLHub refused. Nothing was minted         | yes   |
| `unknown`   | No answer after the irreversible call      | **no**, an administrator settles it first |

`splent feature:spl_registry stuck` lists the undetermined ones and
`... settle <spl> <version> [--doi ...]` records what was found.

## Endpoints

| method | path                            | auth               |
|--------|---------------------------------|--------------------|
| GET    | `/api/v1/spls`                  | public             |
| GET    | `/api/v1/spls/<name>`           | public             |
| POST   | `/api/v1/spls/<name>/releases`  | Bearer, `write_spl` |
| GET    | `/marketplace/spls/<name>`      | public page        |

```bash
curl -X POST https://<marketplace>/api/v1/spls/my_line/releases \
     -H "Authorization: Bearer splent_..." \
     -F "file=@my_line.uvl"
```

## Configuration

Every variable has a working default; the feature runs with no `.env` entry.

| variable                             | default                  | what it does |
|--------------------------------------|--------------------------|--------------|
| `UVLHUB_API_KEY`                     | (none)                   | the single server-side key. Without it the write path answers 503 |
| `UVLHUB_URL`                         | `https://www.uvlhub.io`  | which instance to relay to |
| `SPL_REGISTRY_PUBLISHING_ENABLED`    | `true`                   | kill switch. `false` closes publishing and leaves every read path up |
| `SPL_REGISTRY_VERIFY_PUBLISHED`      | `true`                   | download the published file and compare it byte for byte |
| `SPL_REGISTRY_UVLHUB_TIMEOUT`        | `60`                     | seconds per UVLHub call |
| `SPL_REGISTRY_QUOTA_WINDOW_HOURS`    | `24`                     | the rolling window every quota is counted over |
| `SPL_REGISTRY_MAX_RELEASES_PER_USER` | `10`                     | per developer, per window |
| `SPL_REGISTRY_MAX_RELEASES_PER_SPL`  | `5`                      | per product line, per window |
| `SPL_REGISTRY_MAX_RELEASES_TOTAL`    | `100`                    | the ceiling that bounds the single key |
| `SPL_REGISTRY_MAX_SPLS_PER_USER`     | `5`                      | product lines one developer may own |

`UVLHUB_API_KEY` is deliberately never copied into `app.config`: that mapping
is dumped by `splent product:config`, by the settings screens and by Flask's
debug pages. It is read from the environment at call time in exactly one place,
`uvlhub.api_key()`, which is the only place in the whole workspace that reads
it server side.

Quotas are counted with SQL `COUNT`s, never in-process counters, because
several workers run in parallel and a per-process counter would multiply every
allowance by the number of them.

## 403 or 404

Every product line is in the public index unless an administrator withdraws it
with `visibility <spl> withdrawn`. That one flag decides what the publishing
endpoint says about a line that is not yours:

| the line is  | answer | why |
|--------------|--------|-----|
| listed       | `403`  | admitting the name is taken tells the caller nothing they cannot read in `GET /api/v1/spls` |
| withdrawn    | `404`  | the same answer, code and wording as a name nobody registered, so the write path cannot be walked to find what the read path hides |

Withdrawing hides a line. It deletes nothing: every release and every DOI stays
exactly where it is, and the owner can still publish to it.

## Authorship

A publication is credited to the publishing developer's profile, read server
side and never from a request field, because an author line a stranger can
choose is an impersonation vector on a permanent record.

The profile feature is **not** required. When the product installs it, the
credit is the `name` and `surname` on the developer's `UserProfile`, read
through `UserProfileService.get_by_user_id`. When it does not, or when the
developer never filled the form in, the credit is `SPLENT MARKETPLACE`. It is
never the email address, which would put a personal address on a permanent
public record.

A SPLENT profile holds a name and a surname and nothing else, so that is all
that is sent. There is no affiliation and no ORCID to send.

## Commands

```bash
splent db:seed
splent feature:spl_registry list
splent feature:spl_registry stuck [--hours 1]
splent feature:spl_registry settle <spl> <version> [--doi ...] [--note ...]
splent feature:spl_registry set-owner <spl> <email>
splent feature:spl_registry visibility <spl> public|withdrawn
```

`splent db:seed` runs `SplRegistrySeeder`, which **carries the founding product
lines as data**. They were published before the registry existed, and their
name, description, mirror, DOI and file name are written out in `seeders.py`.
The seeder is what rebuilds the registry on a fresh database, and it is
idempotent.

It records no content digest for the lines it brings in. The digest is the
idempotency key, and a release carrying one is the registry promising these
exact bytes are what that DOI serves. Nothing has compared them, and one of the
three does not even serve a file named after the line
(`sample_splent_spl` serves `sample_splent_app.uvl`), so the digest is left
empty.

Seeded lines arrive with **no owner**, which is what stops any authenticated
developer from publishing to them; `set-owner` hands one over.

## Requires

`auth` (the User an owner and a publisher point at) and `apikeys` (the
`write_spl` Bearer token the publishing endpoint is guarded by).

`profile` is **optional**. It improves the credit on a publication and the name
shown on the public page, and the feature works without it.

## Tests

```bash
splent feature:test splent_feature_spl_registry
```

Unit, integration and functional. The suite never reaches UVLHub: the conftest
replaces the four relay calls **and** the HTTP client underneath them, and it
does so through its own `MonkeyPatch` rather than the shared fixture. That last
detail is not decoration. While the shared fixture was used, a single
`monkeypatch.undo()` inside a test restored the real relay along with the real
`UVLHUB_API_KEY`, and a test run minted a permanent DOI on production Zenodo.
Publishing is irreversible, so the guard has to be one a test cannot lift.
