Skip to content

refactor(truapi)!: use a ProductId newtype for product identifiers - #1448

Open
Mikelle wants to merge 11 commits into
mainfrom
refactor/product-id-runtime
Open

Mikelle wants to merge 11 commits into
mainfrom
refactor/product-id-runtime

Conversation

@Mikelle

@Mikelle Mikelle commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1385

Product ids are a ProductId newtype instead of &str/String. ProductId::parse is the only constructor: it trims, lowercases and requires a name dotNS can register or localhost[:port], so holding one means the id is valid and normalized. It encodes as a string and decodes by parsing. Wire messages, review structs and stored records keep strings and are parsed where they enter the runtime.

ProductId is used in the permission and manifest paths, key derivation, ProductAuthority and both host implementations, and the signing and pairing hosts' caches. The old string helpers are methods on it. Generated TypeScript, Swift and Kotlin bindings are unchanged.

Breaking changes

  • Rust: normalize_product_identifier, is_product_identifier, is_localhost_product_identifier, has_trusted_remote_permissions(&str) and truapi::manifest_cache_key are removed in favour of ProductId::parse and its methods. ProductContext.product_id, ProductStorageKey::new, ProductAuthority and the host_logic derivation functions use ProductId. The account gate methods are may_use_product_account, holds_cross_product_scope and holds_cross_product_scope_within, and return bool.
  • CLI: a product config's productName must be a full product id such as peopl.paseo. A bare label such as peopl stops startup before anything is stored.

Behaviour

  • A product id must follow the dotNS registrar's label rule (isSingleLabel: lowercase ASCII letters, digits and hyphens, no leading or trailing hyphen, at most 63 bytes), in any case. tést.dot, my_app.dot, fullwidth letters and invisible characters are refused, so no lookalike of a registered name passes.
  • The CLI lowercases productName, so Peopl.Paseo seeds the grants for peopl.paseo.
  • Permission administration treats a cross-product signature target that is not a product id as not approved, and refuses to approve it.
  • Native payment and top-up status notifications match followers by the normalized id. An invalid id is logged and dropped.

The signing host's SSO handlers and the worker ledger keep ids as received, like the wallets and host-papp. Since dotNS names are lowercase, that matches the normalized form for every real product.

Testing

make fmt-check, clippy (pinned nightly, -D warnings), the wasm32 check and cargo test for truapi, truapi-codegen and truapi-host-cli all pass, apart from three local truapi-host-cli script-runner failures unrelated to this change. Codegen output is byte-identical before and after.

Add `platform::ProductId`, built only by `ProductId::parse`, so holding one
means the id is normalized. It encodes as its string and decodes by parsing,
which lets `ProductContext` derive `Decode` and carry a `ProductId`. It
derives `Hash` and `Ord`, so the AutoSigning grant set, the native worker
registry and the CLI's Pocket cards are keyed by it.

The string helpers become methods: `normalize_product_identifier` is
`ProductId::parse`; `bare_product_label`, `same_product_on_one_network`,
`on_one_network`, `manifest_cache_key`, `has_trusted_remote_permissions`,
`normalizes_to_trusted_remote_permissions`, `is_localhost_product_identifier`
and `ProductContext::is_game_product` move onto `ProductId`.
`is_product_identifier` is removed.

The permission and manifest paths take `&ProductId`: `AuthorizedAccess`,
`scope_grant`, `grants_scope`, `require_context_access`, manifest resolution,
`account_access_authorization`, `cross_product_signature_key` and
`confirm_signature`. The account gate is `may_use_product_account`,
`holds_cross_product_scope` and `holds_cross_product_scope_within`, which take
the id a request was parsed into and answer `bool`, so each call parses it
once. `ProductStorageKey::new` takes a `ProductId`, removing the runtime's
`expect`.

Permission administration reports a cross-product signature approval for a
target that is not a product id as not determined, and refuses to set one.
The CLI parses a local config's `productName` while reading it, so a name
that is not a full product id stops startup before anything is stored.

Codegen recognises `ProductId` by its rustdoc item id in the `platform`
module and emits a string, so the generated clients and host codecs are
unchanged.

Closes #1385
…on and caches

The product-keyed derivations in `host_logic` take `&ProductId`: product
subtree and account keys, ring-VRF entropy, product entropy and aliases. The
reserved `uid.<suffix>` and `peopl.<suffix>` paths keep a private string
form, since the network suffix they are built from is not a validated type.

`ProductAuthority` and both of its implementations take `&ProductId` wherever
they took a product id string. So do the signing host's allowance cache and
AutoSigning grants, the pairing host's subtree, AutoSigning and allowance
caches and their storage helpers, the ring VRF registry and the native
payment and top-up status relay. Ids are parsed where they enter: requests
from a paired host over SSO and status notifications from a native host.
Wire messages, review structs and persisted records keep strings.

Typing the derivations fixes the places that used a raw id next to a
normalized one: the signing host derived AutoSigning ring-VRF entropy and the
statement-store and Bulletin allowance accounts from the id a paired host
sent, and the pairing host looked up the AutoSigning key for ring-VRF
registration under the raw caller id. A request from a paired host whose
calling product id is not a product id is now refused before anything is
derived or the user is asked.

The worker ledger keeps string ids: hosts match worker transitions against
their own spelling, so normalizing it needs the hosts to change first.
…ed host sends them

The previous commit parsed the calling product id at the signing host's SSO
handlers, so a resource allocation, VRF signature or subtree request derived
everything from the normalized id and refused an id that is not a product id.
That is a behaviour change, and not one the rest of the system agrees with:
host-spec C.7 calls product ids case-sensitive, the iOS and Android wallets
answer the same requests using the id as sent, and host-papp and
product-sdk's terminal send it verbatim, defaulting to the terminal's
`appId`.

The handlers go back to what main does. Statement-store, Bulletin and
smart-contract allocations derive from the id as sent, so `allocate_*` take
the requester's string again. An AutoSigning allocation parses the id for
the product key and derives its ring-VRF domain entropy from the id as sent,
through `derive_ring_vrf_domain_entropy_from_junction`. `sign_vrf_request`
takes the raw caller id, and one that does not parse gets no grant and
reaches the prompt. The two tests that pinned the normalized behaviour are
removed, and the changeset no longer describes a behaviour change.

Whether product ids are case-sensitive or normalized is left to a separate
decision across host-spec, the wallets, host-papp and the core.
…untime

`host_logic/dev_server.rs`, added on main by #1361, parses its product id with `ProductId::parse`, since this branch removes `normalize_product_identifier` and `is_localhost_product_identifier`.
@github-actions

Copy link
Copy Markdown
Contributor

This pull request touches an app, which is not built by default. Add a label for each build you want:

  • iOS simulator build, ios-simulator-build: builds the iOS app, runs its tests and attaches a build for the iOS Simulator on a Mac.
  • iOS device build, ios-device-build: attaches a signed build that installs on a registered iPhone or iPad.
  • Android build, android-device-build: attaches an APK that installs on an Android phone or an emulator.

Each starts as soon as it is added and follows the branch from then on.

@github-actions github-actions Bot added rust Pull requests that update rust code host-ios Touches the iOS host tree host-android Touches the Android host tree labels Oct 15, 2026
@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Bundle size report

Compared with main at 81008a4.

Raw Gzip Brotli
truapi-host 7.88 MiB (-5.7 KiB, -0.1%) 5.66 MiB (-3.4 KiB, -0.1%) 5.38 MiB (-2.0 KiB)
truapi-provider 4.03 MiB 1.27 MiB 960.9 KiB
truapi 6.30 MiB 891.5 KiB 730.0 KiB
Total 18.22 MiB (-5.7 KiB) 7.80 MiB (-3.4 KiB) 7.04 MiB (-2.0 KiB)

WebAssembly modules

Raw Gzip Brotli
truapi-host/wasm/web/truapi_server_bg.wasm 2.77 MiB (-5.7 KiB, -0.2%) 987.3 KiB (-3.4 KiB, -0.3%) 744.3 KiB (-2.0 KiB, -0.3%)
truapi-host/wasm/web/truapi_verifiable_bg.wasm 4.89 MiB 4.64 MiB 4.61 MiB
truapi-provider/truapi_provider_bg.wasm 3.99 MiB 1.26 MiB 953.1 KiB
Changed files (1)
Raw Gzip Brotli
truapi-host/wasm/web/truapi_server_bg.wasm 2.77 MiB (-5.7 KiB, -0.2%) 987.3 KiB (-3.4 KiB, -0.3%) 744.3 KiB (-2.0 KiB, -0.3%)

Commit: 9575799

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

CI Status: 24 required jobs green, 21 passed and 3 skipped by path filter.

All job results
job result
android-bindings success
bundle-size success
changes success
changeset-guard success
cli-package success
codegen success
e2e skipped
explorer success
headless-install success
host-android-bindings success
host-android-detekt success
host-wasm success
ios-bindings success
ios-swift success
licenses success
playground success
provider-android-bindings skipped
release-guard success
rust success
ts-client success
ts-debugger success
ts-host success
wasm-provider success
workflow-lint skipped

Signing credentials: failure as of 2026-10-15, a release may fail

Commit 95757990 · run log

Shorter, plainer wording for the doc and test comments this branch wrote, and commas in place of the em dashes in the local_product_signing_key paragraph it touched.
ProductId::parse checked labels against a hand-written list of invisible characters, which let lookalike ids through: soft hyphens, fullwidth letters, Arabic letter marks and other Unicode the list did not name. dotNS itself registers only canonical lowercase ASCII DNS labels (isSingleLabel in the dotNS contracts): a-z, 0-9 and hyphens, no leading or trailing hyphen, 1 to 63 bytes, rejected rather than folded. So every label now has to follow that rule, after trimming and lowercasing. Unicode ids such as tést.dot could never resolve on dotNS, so the NFC step and the test claiming dotNS names are internationalized are gone.
…egister

platform_bounds used `Tést.DOT`, which the dotNS label rule now refuses. A mixed-case ASCII id still checks that the key round-trips and the id is lowercased.
The product selection, the switch command, pairing and script runs and
the per-product storage map hold a ProductId, so an id is parsed once
where it enters the CLI. ProductContext::from_parts builds a context
from an id that is already parsed.
@Mikelle
Mikelle marked this pull request as ready for review October 15, 2026 19:50
@Mikelle
Mikelle requested a review from a team October 15, 2026 19:50

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

host-android Touches the Android host tree host-ios Touches the iOS host tree rust Pull requests that update rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(truapi): use a ProductId newtype instead of &str/String for product identifiers

1 participant