@projectpac/plugin-registry
v0.6.0
Published
Part of PAC: @projectpac/plugin-registry.
Readme
@projectpac/plugin-registry
What a node could install, and what each thing is for. It exists because a plugin's manifest deliberately does not say: the schema is strict and carries an id, a kind and its routes, and no description, no title, no readme. So a person deciding whether to run somebody's code has, from the node alone, a name and a category. This is where the sentence comes from.
It holds the names as much as the descriptions. An id is what a node calls a
plugin, what its folder under the data directory is named, and what a reader
matches an installed plugin against -- so a registry that lists the core plugins
is one in which those ids are taken. It is this table's primary key, so two
plugins claiming negotiation is not a thing that can happen quietly.
It serves flows and adapters from one list. Inside a node those are different kinds with different seams; from outside they are the same decision -- something you did not have, that you now want your node to run. There is one endpoint.
It is not the node directory beside it, and the two are worth telling apart:
| | node directory | plugin registry |
| ------------------ | ---------------------------------- | -------------------------- |
| holds | claims nodes make about themselves | one operator's list |
| who writes | anybody, signed by their node key | the operator, with a token |
| lifetime | presence expires | durable until removed |
| losing it costs | one republish tick | one registry:publish |
Nothing here is a package host. An entry carries a spec; fetching it is the node's own package source doing what it already does.
What an entry is
{
"id": "negotiation",
"package": "@projectpac/[email protected]",
"description": "The negotiation engine: the rounds and the settlement...",
"publisher": "core",
"tier": "extra",
"manifest": { "id": "negotiation", "kind": "flow-service", "serviceKey": "negotiation", "...": "..." }
}Three things about that shape are deliberate:
packageis what the node is handed, under the node's own name for it.POST /plugins {package}takes it verbatim. It names an exact version, because a floating spec resolves to whatever is newest, which becomes a different sdk generation the day one is published -- and a manifest from the wrong generation is refused by a node's strict schema with nothing useful to say about why. Where the bytes come from is not repeated beside it: the spec's own shape says, and the node decides which of its package sources serves it.kindandserviceKeyare not repeated outside the manifest. They are in it, it is served with every entry, and a field held twice is a field that disagrees with itself.idis the exception, because it is the key, and a check holds it to the manifest's.- A readme is not a field. It rides in on the same write and comes back on its own route, because the readmes outweigh the rest of the catalogue several times over and only the row somebody opened needs one.
The routes
| method | path | who | answers |
| ------ | --------------------- | --------- | --------------------------------------------------- |
| GET | /health | anybody | {ok, plugins: N} -- counts, not contents |
| GET | /plugins | anybody | {plugins: [...]} -- the whole list, no readmes |
| GET | /plugins/:id/readme | anybody | one readme, as text/plain |
| PUT | /plugins/:id | the token | register or replace one entry, with its readme |
| DELETE | /plugins/:id | the token | remove one |
Reading needs nobody's permission, because a node has no credential to offer
one. Writing needs PAC_REGISTRY_TOKEN and there is no other way in: no
signature, unlike the directory, because there is no id being claimed here and
so nothing for a key to prove. What a write asserts is "I am the person who runs
this registry", and a bearer token is exactly that claim.
A registry started without a token does not start, and says which variable it wanted. That is deliberately not "open on loopback, closed elsewhere": conditional auth is how a service ends up writable in production because somebody moved a bind address. Nor is it "read-only until one is set": a catalogue is registered rather than committed, so a registry nobody can write to is one that will never list anything.
Readmes are served as text, never html. Rendering one is the reader's business, and a registry that served markup would be a registry that could inject some.
Filling one
The catalogue is registered, not committed. A list baked into this package
would need a release to correct; a list checked in beside the code would be a
second copy of facts that already exist, in each package's own metadata where
sync-manifests.ts put them. So a script walks the checkouts and PUTs what it
finds, and the same script fills a registry on a laptop and the deployed one:
pnpm registry:list # what the walk finds, sending nothing
pnpm registry:publish --url https://registry.example.com --token "$TOKEN" --pruneNothing is imported. A manifest is read out of package.json, which is the
whole point of it being copied there: describing a plugin must not mean running
it. --prune also removes rows the walk did not produce, which is what makes a
deployed registry match the source rather than accumulate; without it they are
listed and left alone, because a walk that came up short for some other reason
must not take the catalogue with it.
The description is the one thing that cannot be generated. No manifest carries a
sentence, and every package.json description in this constellation is the
package's own name repeated -- so it comes from each README's opening paragraph,
which in this codebase is reliably the module's own top-of-file comment in
prose. Where that reads badly out of context, scripts/descriptions.json holds
a hand-written one keyed by npm name, and it wins. A package with neither stops
the publish rather than being listed undescribed.
Two packages are absent because they declare no manifest at all --
@projectpac/executor-cli, since the worker composes executors from its own
list, and @projectpac/artifacts, which is a contract two stores implement.
@projectpac/artifacts-memory does declare one and is excluded by name: it is
the in-memory double a suite uses instead of git, and its own README says it
must not be installable as though it were the store. It is not marked private,
so nothing else would keep it out.
Hosting one
One process, one sqlite file, and one token.
PAC_REGISTRY_TOKEN=dev-token npx @projectpac/plugin-registryIt listens on 127.0.0.1:7461 and writes plugin-registry.db beside you. The
token is the one variable it will not start without; three more move the rest:
| variable | default | what it is |
| -------------------- | -------------------- | ------------------------------------------------------- |
| PAC_REGISTRY_TOKEN | required | the token a write must carry |
| PAC_REGISTRY_HOST | 127.0.0.1 | the interface to bind |
| PAC_REGISTRY_PORT | 7461 | the port |
| PAC_REGISTRY_DB | plugin-registry.db | the sqlite file, resolved against the working directory |
Hosting one for real
The same recipe as the node directory beside it. Both carry better-sqlite3, so
the "check the binary actually built" step applies to both.
1. Node, outside anybody's home directory
sudo cp -a "$NVM_DIR/versions/node/$(node -v)" /opt/node
sudo ln -sf /opt/node/bin/node /usr/local/bin/node
sudo ln -sf /opt/node/bin/npm /usr/local/bin/npm2. Install it once
sudo /usr/local/bin/npm install --prefix /opt/pac-plugin-registry \
@projectpac/plugin-registry
ls /opt/pac-plugin-registry/node_modules/better-sqlite3/build/Release/better_sqlite3.nodeIf that binary is missing the service crashes on its first start:
sudo /usr/local/bin/npm rebuild --prefix /opt/pac-plugin-registry better-sqlite3.
3. A user, a directory, and a token
sudo useradd --system --no-create-home --shell /usr/sbin/nologin pac
sudo mkdir -p /var/lib/pac
sudo chown pac:pac /var/lib/pac
printf 'PAC_REGISTRY_TOKEN=%s\n' "$(openssl rand -hex 32)" \
| sudo tee /etc/pac-registry.env >/dev/null
sudo chmod 600 /etc/pac-registry.envThat is its own file rather than a line in the unit, because a unit is world-readable and a token is not.
4. The unit
/etc/systemd/system/pac-plugin-registry.service:
[Unit]
Description=PAC plugin registry
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pac
Group=pac
WorkingDirectory=/var/lib/pac
ExecStart=/usr/local/bin/node /opt/pac-plugin-registry/node_modules/@projectpac/plugin-registry/dist/main.js
Environment=PAC_REGISTRY_HOST=127.0.0.1
Environment=PAC_REGISTRY_PORT=7461
Environment=PAC_REGISTRY_DB=/var/lib/pac/plugin-registry.db
EnvironmentFile=/etc/pac-registry.env
Restart=always
RestartSec=2
# It needs one directory and one socket, and nothing else on the machine.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/pac
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=yes
LockPersonality=yes
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.targetMemoryDenyWriteExecute= is deliberately absent: v8 writes the code it
executes, so setting it stops the process from starting at all.
sudo systemctl daemon-reload
sudo systemctl enable --now pac-plugin-registry
curl -s localhost:7461/health{ "ok": true, "plugins": 0 }Empty, because nothing has been registered yet. From a checkout with the sibling repos beside it:
pnpm registry:publish --url https://registry.example.com --token "$TOKEN" --prune5. https, which is not optional
The unit binds loopback on purpose: the terminator in front holds the certificate, and 7461 never faces the internet.
Serve it over https even though the list is public, for the reason the directory gives about its own board -- every record read from one steers what a node does next. Here it is sharper than there: an entry carries the spec a node will hand to npm, so anyone able to rewrite this list in flight chooses what gets installed. And the token crosses the same wire. A client enforces it rather than advising it: the desktop app refuses a registry that is neither https nor on the machine asking.
registry.example.com {
reverse_proxy 127.0.0.1:7461
}Two things have to be true before Caddy can get a certificate: an A record for the name pointing at this box, and inbound 80 and 443 open. Leave 7461 closed.
What it costs to lose
One registry:publish. Everything in the database was walked out of the
checkouts and can be walked again; back it up if you like, but nothing depends
on it surviving.
What it does not do
It has no accounts and no submissions: there is one token, and whoever holds it
may write anything. publisher exists so a third-party entry could be told
apart from a core one, and every entry today says core because there is no way
for anybody else to add one. When submissions arrive they need a per-publisher
credential, a review step, and a rule about who may claim an id -- none of which
is built. Until then a plugin nobody has listed is installed the way it always
was, by naming its package.
