Decision 10 amended 2026-10-27 (STA-1355) by
0461
decision 3: inside the
@stackra-pro scope a per-name internal row covers @stackra-pro/ui, so
only Figentra’s own internal tokens read it and a customer grant, names: "*" included, never
covers it. The two scope rows and the rest of decision 10 stand.Accepted 2026-10-27 (STA-1333, batch stackra-181): the owner decided the registry in the
questionnaire of 2026-10-27 (Stackra finish plan, decision D5). Supersedes decisions 1 to 3 of
0177 and the registry decisions of its
amendment of 2026-10-21. Amends 0025 (decisions
1 and 2), 0077 (decision 2),
0178 (decisions 1 to 3,
for registry access), 0175 (decision 3),
0290 (decisions 16 and 17),
0316 (decisions 1 and 2, the channel only),
0168 (decision 6) and
0349
(decision 4, the channel only).
Context
0177 sent@stackra/* to npmjs and @stackra-pro/* to Keygen
Cloud. Neither is available or wanted now: the npmjs @stackra organisation was deleted and its name is locked until
about 2026-11-26, and the owner decided on 2026-10-27 to use no paid service and no Keygen. Stackra also ships more than
packages: skills, agents, MCP servers and plugins, which today have no versioned home. The analysis of 2026-10-20
(notes/pk2-registry-analysis.md) scored a Worker with R2 best on cost and licence fit, worst on the code we must own,
and left one question open since 0074: whether a registry can hold per-customer,
revocable, expiring tokens. The owner chose that option: our own npm-compatible registry on Cloudflare from day 1, one
store for every kind of item, and our own licence engine; the registry is on the 0.1.0 finish line.
Alternatives weighed: npmjs for free and a managed registry (Cloudsmith) for Pro, rejected as a paid service and two
stores; Verdaccio on a VM, rejected for its single writer and the ops burden; GitHub Packages, rejected because every
customer would need a GitHub account; waiting for the npmjs scope to unlock, rejected because the finish line does not
wait and our registry does not need npmjs.
Decision
Host and member
- Host:
npm.stackra.figentra.com. The name the docs already use (0284,workers/static/README.md) is kept, and 0290 decision 17 (delete the name) and 0175 decision 3 (point it at Keygen) are reversed. One host serves the npm protocol and the catalogue API; there is no second host for Pro, for licences or for the catalogue. The Worker takes the name as a Worker Custom Domain, asstatic.does (0284 decision 9). - Member:
workers/registry, package@figentra/worker-registry, Worker scriptstackra-registry, under every rule of 0284 decision 1 (tier:app, UNLICENSED, tests in__tests__/). Itswrangler.jsoncis hand-written under the carve-out of 0284 decision 8 until gen writes Workers config. It deploys only from CI (.github/workflows/deploy-registry.yml, GitHub environmentregistry), with the Cloudflare token read through Infisical OIDC as 0284 decision 7 says; nobody runswrangler deployfrom a workstation. The R2 bucket, the D1 database, the KV namespace and the custom domain are Pulumi resources in@stackra/infra-cloudflare.
Storage
-
R2 holds bytes, D1 holds truth, KV holds caches.
The licence-token check is cached in the Workers Cache API of each data centre for
registry.tokenCacheSeconds(default 60), not in KV, so the cache costs no KV writes and a revocation is effective everywhere within that time. Backups are D1 Time Travel; tarballs are immutable and rebuildable from the tagged release.
npm protocol surface
-
The registry speaks the subset of the npm registry protocol that npm, pnpm and Yarn need to install and publish
scoped packages, and nothing else.
Everything else is refused:
npm loginandadduser(PUT /-/user/*) answer 403 with a pointer to the licence docs, unpublish (DELETEof a package or version) answers 405, search and audit endpoints answer 404 (a consumer maps only the Stackra scopes to this host, so audits of third-party packages stay with npmjs). The registry never proxies an upstream: an unknown name is 404, never fetched from npmjs, so no one can inject a package by name. -
Caching headers. Open tarballs:
Cache-Control: public, max-age=31536000, immutable. Licensed tarballs and packuments:private, no-store. Open packuments:public, max-age=60.
Publishing
- Only the release workflows publish, through GitHub OIDC. The
publisherstable names, per scope, the trusted publisher:@stackraisfigentra/stackra,@stackra-proisfigentra/stackra-pro, each with workflow.github/workflows/release.yml, refrefs/heads/mainand environmentrelease, matched on the numericrepository_idandrepository_owner_idclaims as well as the names, so a renamed or recreated repository does not inherit the right. The job requests a GitHub ID token with audiencenpm:npm.stackra.figentra.comand exchanges it at the endpoint of decision 4 for a publish token bound to that one package name, validregistry.publishTokenSeconds(default 900), stored hashed. This is the exchange shape npm’s trusted publishing uses; where the client does not run it against a host other than npmjs, the release workflow performs the exchange in one step and passes the token asNODE_AUTH_TOKENfor that job. There is no long-lived publish token, no human publish and no break-glass account; a fix is a new run of the workflow. 0289 decision 11 (a 7-day npm token for the first publish) does not apply here, because a trusted publisher is a scope row, not a per-package npmjs setting. - What a publish must pass. The Worker refuses a version that already exists (versions are immutable, also after a
deprecation, 0013); a tarball over
registry.maxTarballBytes(default 50 MiB); a tarball whosepackage.jsonname or version differs from the request; astackra.kindoutside decision 11; a scope whose licence rule fails (@stackra-pro/*must name the commercial licence and carry itsLICENSE, and must contain nosrcand no source maps, 0316 decision 2;@stackra/*must not name the commercial licence, and is MIT or, for a vendored third-party item, its upstream open-source licence with aNOTICE, 0024 decision 5); a missing provenance bundle; and a client integrity or shasum that differs from the one the Worker computes. Dist-tag changes and deprecations need the same publish token. Every publish, tag change and deprecation is anaudit_eventsrow naming the workflow run.
Integrity and signing
- Integrity. The Worker computes the SHA-512
dist.integrity(SRI form), the SHA-1dist.shasumand the SHA-256 digest of each tarball at publish and stores them; installs verify integrity as npm clients always do. - Registry signatures and provenance. Each version carries
dist.signatures: [{ keyid, sig }], an ECDSA P-256 signature over<name>@<version>:<integrity>, the format npm’s registry signatures use, sonpm audit signaturescan verify our packages against the keys at/-/npm/v1/keys(keytype: ecdsa-sha2-nistp256,keyid=SHA256:<base64 of the SHA-256 of the public key's SPKI DER>,expires). The private key is the Worker secretREGISTRY_SIGNING_KEY, sourced from Infisicalprod/registry(0173); it rotates yearly, and a rotated key stays listed with itsexpiresso old signatures still verify. Provenance: the release workflow attests each packed tarball with GitHub artifact attestations (Sigstore,actions/attest-build-provenance) and sends the bundle with the publish. The Worker checks that the bundle’s subject digest equals the tarball’s SHA-256 and that its certificate names the trusted publisher’s repository and workflow, stores it in R2 and serves it (decision 4). Full Sigstore chain verification is done by consumers (gh attestation verify) and by the release smoke job, not by the Worker.
Scopes and kinds
-
Read rules are scope rows, not code. The
scopestable ships with two rows:A third rule,internal, exists for the restricted@figentra/*channel of 0349 decision 4: its row is added by the first OS batch that publishes, with the OS release workflow as its trusted publisher; it is not part of 0.1.0. No other scope is added without an ADR. A consumer’s.npmrcmaps the scopes to the host and sends the licence token for it: -
stackra.kindand the catalogue. Every item is an npm tarball whosepackage.jsonmay carry"stackra": { "kind": <kind>, ... }. The kinds are exactlypackage,skill,agent,mcpandplugin; a missingstackra.kindmeanspackage. A non-package item is named@<scope>/<kind>-<name>(for example@stackra/skill-archify), so items and packages never collide.stackra add <kind> <name>(stackra-184) resolves@stackra/<kind>-<name>, then@stackra-pro/<kind>-<name>, and installs:Non-package installs are pinned by name, version and integrity instackra-items.lock.yamlat the workspace root (its schema is stackra-184’s). Thestackrafield’s schema per kind is a contract of@stackra/skills(./contracts:StackraKind,StackraManifest,CatalogueItem,CataloguePage), which the Worker and the CLI both import. Thepackagekind’stier,category,runtimesandsummarywere added by the amendment of 2026-10-27 at the end of this page. -
The catalogue API is read-only and lists every item of the
openandlicensedscopes to anyone, metadata only (no tarball, no manifest file contents beyondstackra):GET /-/stackra/v1/catalogue?kind=&scope=&q=&cursor=&limit=returnsCataloguePage:{ items: CatalogueItem[], next: string | null }, newest publish first,limitat most 100.GET /-/stackra/v1/catalogue/@scope/namereturns oneCatalogueItemwithversions.CatalogueItemis an object ofname,scope,kind,description,latest,distTags,access("open"or"licensed"),licence,keywords,repository,homepage,publishedAt,deprecatedandstackra.GET /-/stackra/v1/entitlementswith a licence token returns what it may read:{ holder, grants, expiresAt, status }; the CLI shows it asstackra whoami.
Licences
- Our own licence engine, in D1. A licence is
{ id, holderRef, grants, notBefore, expiresAt, status }, whereholderRefis the opaque reference of the customer account in Figentra OS,statusisactive,suspendedorrevoked, and each grant is{ scope: "@stackra-pro", names: "*" | string[] }(an item of any kind is a name). A licence has one or more tokens (one per CI system or machine, each with a label), up toregistry.licence.maxTokens(default 20). A token isstkl_followed by 43 base64url characters (32 random bytes), shown once at issue and stored only as its SHA-256 hash; a fast hash is enough because the token is random, and the prefix lets gitleaks find a leaked one. Each token and each licence can be revoked on its own. Reads of alicensedscope are refused once the licence issuspendedorrevoked, andregistry.licence.graceDays(default 14) afterexpiresAt; a suspension or an expiry never touches anything a customer already installed (0178 decision 4). Downloads count per licence and day indownloads_daily; no IP address is stored. - The licence API and the OS billing hook. The licence API is
/-/stackra/v1/licences:POSTissues a licence (with its first token),PATCH /:idrenews (expiresAt) or sets the grants,POST /:id/suspend,POST /:id/resume,POST /:id/revoke,POST /:id/tokensandDELETE /:id/tokens/:tokenId. Its caller is the Figentra OS billing and licensing service, authenticated by an issuer token (roleissuer, stored hashed inissuer_tokens, its value in Infisicalprod/registry, rotated by the owner). Every call carriesIdempotency-Key, the OS event id, so a retried billing webhook is applied once. The contract is the command and result types in@stackra/licensing./contracts(RegistryLicenceCommand,RegistryLicence,RegistryLicenceGrant): OS maps a subscription start to issue, a renewal to renew, a failed payment to suspend, a cancellation or refund to revoke, and a plan change (plans from@stackra/entitlements) to set the grants. The registry holds no prices and no plans. - The
LicenceIssuerport gets an own-issuer driver, the default.@stackra/licensingadds theregistrydriver ofLicenceIssuerover the licence API (stackra-183) and makes it the default; thekeygendriver stays, optional, for an on-prem customer who runs Keygen. A signed offline licence file for on-prem (checkOut) is signed with its own Ed25519 key,LICENCE_SIGNING_KEY, never with the registry signing key.
npmjs and npm.figentra.com
- The npmjs mirror is optional and off. When the
@stackraname unlocks on npmjs (about 2026-11-26), the owner re-registers the organisation anyway, so no one else can claim it and confuse a consumer whose.npmrclacks the scope mapping. If the owner switches the mirror on (repository variableSTACKRA_NPM_MIRROR=on), the release workflow publishes the same@stackra/*tarballs, byte for byte, to npmjs with npm trusted publishing and provenance, after they are on our registry. Our registry stays the source of truth: same versions, no npmjs-only version, never@stackra-pro/*, never a non-package kind unless the owner says so. A failed mirror publish is retried with the same tarball, never rebuilt. npm.figentra.comis retired. The npflared Workernpm-registrygives up thenpm.stackra.figentra.comname beforestackra-registrytakes it, and keeps serving onlynpm.figentra.com. Its remaining consumers (@figentra/cliand Academorix) move to this registry: a package that still has a consumer is republished from its source through a release workflow (its tarball is not copied, because only CI publishes), under theinternalrow of decision 10. When the last consumer has moved,npm.figentra.comanswers 410 Gone for 30 days; then the owner deletes thenpm-registryWorker, its R2 bucket and its DNS record (through Pulumi once the zone is imported, 0290 decision 15). TheNPM_FIGENTRA_TOKENsecret is deleted from Infisical the same day.
Versions and settings
- One shared version.
@stackra/*and@stackra-pro/*release at one exact semver, first0.1.0(amending 0177 decision 5, which let Pro follow within a week). Decisions 4 and 6 to 8 of 0177 stand: release checks, deprecation, canaries (dist-tagcanary) and the@stackra/sourcecondition. - Every limit is a setting.
registry.maxTarballBytes,registry.publishTokenSeconds,registry.tokenCacheSeconds,registry.licence.graceDaysandregistry.licence.maxTokensare Worker vars with the defaults above; scopes and publishers are D1 rows seeded by a migration. No limit or scope is a constant in code.
Consequences
- Free installs need no account and no token; Pro installs need one licence token in
.npmrc; publishing needs no secret at all. - We own an npm registry. Its protocol subset, its tests and its incidents are ours (stackra-182), and a client change in npm or pnpm can break it; the release smoke test installs from it with npm, pnpm and Yarn.
- Supply chain: no upstream proxy, immutable versions, registry signatures that
npm audit signatureschecks, and a provenance bundle per version. npm does not show our provenance on a package page; that needs the mirror. - Cost: the Workers Free plan covers launch (100,000 requests a day, D1 5 million reads a day, 10 GB of R2); the owner moves to Workers Paid (5 USD a month) when daily requests pass about 80,000.
- 0178 no longer needs Keygen for registry access; Keygen is an optional on-prem driver.
- The open question of 0074 and 0077 (per-customer, revocable tokens) is closed by decision 13.
- Chores: the owner creates the
registryGitHub environment, the Infisical/registrypath withREGISTRY_SIGNING_KEYand the issuer token, re-registers the npmjs organisation when it unlocks, and switches offnpm.figentra.com(decision 17).
Enforcement
planned — every rule of decisions 4 to 14 is a check inworkers/registry with its tests in __tests__/ beside the
code (stackra-182, stackra-183): the refused routes, OIDC claim matching, the publish checks of decision 7, signatures
against /-/npm/v1/keys, scope read rules and licence status. The release workflows (stackra-185) publish only
through the exchange of decision 6; zizmor checks their id-token: write scope. tools/scripts/validate-repository.mjs
keeps refusing a member here that depends on @stackra-pro/*. A gitleaks rule for the stkl_ prefix is planned.
Nothing checks the deletions of decision 17; they are owner steps logged in Linear.
Decided, with triggers
- Third-party publishers. Only Figentra’s release workflows publish. Opening the catalogue to outside authors needs its own ADR, when the first outside item is requested.
- Perpetual fallback. An expired licence does not keep access to versions released before its expiry. Revisit at the first customer contract that asks for it.
- Mirror. Decision 16 stays off until the owner switches it on.
Amendment 2026-10-27 (owner decision): the package fields of stackra and the member metadata
Context. Every member’s package.json carried a description, licence and exports, but no keywords, homepage, bugs,
author or engines, and nothing an agent could read to place a member without opening its code: its tier, its
category, where it runs and when to reach for it. The owner decided on 2026-10-27 that every member of this repository
carries both, in one checked shape, and that decision 11’s stackra field is where the agent-readable part lives.
Decision. This amends decision 11; the kinds and the other kinds’ fields are unchanged.
- The
packagekind carries four more keys."stackra": { "kind": "package", "tier", "category", "runtimes", "summary" }.tierislib,app,serviceorconfig(the turbo tier tag withouttier:);categoryis thepackages/<category>folder (toolingincluded) and is absent for apps, services and Workers;runtimesis a non-empty set from the file contract’sRuntime:vocabulary (isomorphic,node,browser,worker,react-native), read from the headers of the files its export entries name;summaryis one sentence for agents on when to reach for the member, never a copy ofdescription.STACKRA_MANIFEST_SCHEMA(@stackra/skills/contracts, with the vocabulariesStackraTier,StackraCategoryandStackraRuntime) checks them and keeps them optional, so apackageitem published from elsewhere still parses;exportsanddescriptionare never repeated there. - Every member of this repository writes
"kind": "package"and all four keys, and the npm fieldskeywords("stackra", the category, then 3 to 8 domain terms; no tier or runtime words),repositorywith itsdirectory,homepage(https://github.com/figentra/stackra/tree/main/<dir>#readme),bugs(https://github.com/figentra/stackra/issues),author(Figentra (https://figentra.com)) andengines({ "node": ">=24.0.0" }). Versions,privateandpublishConfigare not part of this amendment. - Turbo tags follow the field. A member’s
turbo.jsonctagsare exactlytier:<tier>, thencategory:<category>(packages only), thenruntime:<r>for each runtime inruntimesorder. Exactly one tier tag stays the rule, and the boundary rules of the rootturbo.jsoncstill name tier tags only. pnpm validateenforces it (checkMemberMetadataof@stackra/repo-checks, run in the Stackra repository): it refuses a missing field, astackrafield the builtSTACKRA_MANIFEST_SCHEMArefuses (CI builds before it validates), a tier or category that disagrees with the folder, and turbo tags that differ from the field.pnpm newandstackra newwrite the fields, so a new member passes.
package.json
alone, and turbo run --filter can select by category or runtime tag. Each new member needs a --summary sentence.
Amendment 2026-10-31 (owner decision): the @figentra-oss scope
Figentra Flow is MIT, so it cannot publish to @figentra, which is internal with a commercial licence rule. The owner
added an open scope for Figentra’s open products (migration 0005_figentra_oss.sql); this amendment is the ADR decision
10 asks for before another scope is added. The scope rows are now:
Every publisher uses
.github/workflows/release.yml on refs/heads/main and is matched on its numeric repository and
owner ids (decision 6).
Amendment 2026-11-01: contracts, the contract kind and branch prerelease
Written with 0511 (adr/0511-cross-repo-service-contracts-and-cross-app-events), which amends decisions 6, 10 and 11 as follows.
- Decision 6 (publishers). A publisher row names one producer repository, its workflow
.github/workflows/release.ymland environmentrelease, so a scope may hold one row per repository, as the@figentrarows of the 2026-10-31 table do. A contract name’s row admits a prerelease version from any ref of that workflow, and a stable version only fromrefs/heads/main. - Decision 10 (read rules). The
@figentrarow of the 2026-10-31 table is theinternalread rule that contracts are read through. Its publishers are the producer repositories of0511decision 5. - Decision 11 (kinds). The kinds are exactly
package,skill,agent,mcp,pluginandcontract. A contract is named@figentra/<service>-contract(0511decision 1), not@<scope>/contract-<name>, andstackra adddoes not install it. Its fields in thestackraobject are set by the schema change that adds the kind.
Amendment 2026-11-01 (owner decision): sources in an internal scope
Decision 7’s ban onsrc and source maps comes from 0316 decision 2, which keeps
sources out of what customers install. It now applies only to a commercial scope whose read rule is licensed
(@stackra-pro). An internal scope (@figentra) may publish src/ and .map files, because only a Figentra
internal licence reads it. The template lines of 0481
are their sources: @figentra/os-mobile ships the Expo project EAS builds from. Every other commercial check still
applies there: the scope’s licence string and a LICENSE file. The read rule is the scope row’s own, so a per-name
internal rule inside @stackra-pro (@stackra-pro/ui) keeps the ban. The OS release workflow publishes those lines
as a trusted publisher of @figentra (migration 0006_figentra_os.sql, decision 10). Linear STA-1828.
Supersedes and sources
Supersedes: 0177 decisions 1 to 3 and decisions 1 to 3 of its amendment of 2026-10-21 (trusted publishing to npmjs); its other decisions and amendments stand, decision 5 as amended by decision 18. Amends: 0025 decisions 1 and 2, 0077 decision 2, 0178 decisions 1 to 3, 0175 decision 3, 0290 decisions 16 and 17, 0316 decisions 1 and 2 (the channel), 0168 decision 6 and 0349 decision 4 (the channel). Related: 0024, 0074, 0113, 0173, 0284, 0289. Sources: owner decisions of 2026-10-27 (questionnaire; Stackra finish plan decision D5); Linear STA-1333;notes/pk2-registry-analysis.md
(2026-10-20); npm registry documentation for packuments, dist-tags, registry signatures and trusted publishing.