Grant page
sa-forge-signer-grant is a web page, served on 127.0.0.1, for the human who holds the profile wallet (Path B). It does the grant and revoke steps of the key lifecycle: it builds a Player Profile AddKeys or RemoveKeys from what you enter, simulates it, and has your browser wallet sign it. It holds no keys and never sees one. It is an early spike in apps/grant/: a Leptos app compiled to WebAssembly, with one small JavaScript file for the wallet and RPC calls and no npm dependencies.
Open it in the browser where your wallet extension already runs. Wallet extensions do not run inside desktop app webviews, so this is a page, not a desktop app.
rustup target add wasm32-unknown-unknown
cargo install --locked trunk
just apps grant # http://127.0.0.1:8791
Trunk downloads the wasm-bindgen CLI matching apps/grant/Cargo.lock the first time it builds, unless that version is already installed.
Granting a session key
- Network: the RPC defaults to
https://rpc1.z.ink. Every step that uses it (connecting, reading the profile, reviewing, signing) first checks its genesis hash and refuses any network other than Z.ink testnet. Editing the RPC field clears the check and any transaction under review, and a transaction reviewed against one URL is not sent through another. - Wallet: wallets that support Wallet Standard and
solana:signTransactionappear as buttons. Connecting looks for profiles that hold the wallet among their first four keys, and loads the profile if there is exactly one. Otherwise paste its address. - Profile: the table shows every key: index, pubkey, scope, permission names and expiry.
- Grant: paste the session key’s pubkey (
sa-forge-signer key show <name>), choose permissions (presets or individual SAGE player actions), and an expiration: 7, 30, 60 or 90 days, or No expiration (expire_time-1, the key works until it is revoked). It defaults to 7 days, and the page shows the date it ends. Review re-reads the profile, builds the instruction and simulates it. - Sign: check the review, then Sign with wallet and send. The log links the transaction on the explorer, and once it confirms shows the new key’s index. Give that index to the agent, which passes it as forge-mcp’s
keyIndex; the signer config has no index field, and itsprofilesetting is informational.
Caution. The permission bit numbers and names the page offers are not from a primary source. They are transcribed from SAGE forge’s permission tables, which are not public, because the programs’ IDLs do not define them. Only
ADD_KEYS,AUTHand SAGE’sDOCK(bit 12) have been confirmed on-chain so far. A wrong bit grants a different action than its name says, so check a new grant against what the agent can and cannot do before relying on it.
The bits, as apps/grant/src/perms.rs defines them (b(bit, name, player), where player marks a bit the page offers to a session key):
#![allow(unused)]
fn main() {
pub const DRAIN_SOL_VAULT: u32 = 63;
pub const SAGE: &[Bit] = &[
b(0, "MANAGE_GAME", false),
b(1, "MANAGE_SYSTEM", false),
b(2, "MANAGE_STAR", false),
b(3, "MANAGE_PLANET", false),
b(4, "MANAGE_SHIP", false),
b(5, "MANAGE_CHARACTER", false),
b(6, "MANAGE_STAR_BASE", false),
b(7, "MANAGE_MINE_ITEM", false),
b(8, "MANAGE_RESOURCE", false),
b(9, "REMOVE_SHIP_ESCROW", true),
b(10, "MOVE_FLEET", true),
b(11, "UNDOCK", true),
b(12, "DOCK", true),
b(13, "RENT_FLEET", true),
b(14, "DO_CRAFTING", true),
b(15, "MANAGE_CARGO_POD", true),
b(16, "ADD_REMOVE_CARGO", true),
b(17, "DO_STAR_BASE_UPGRADES", true),
b(18, "MANAGE_FLEET", true),
b(19, "MANAGE_FLEET_CARGO", true),
b(20, "DO_MINING", true),
b(21, "RESPAWN", true),
b(22, "MANAGE_SURVEY_DATA_UNIT", true),
b(23, "SCAN_SURVEY_DATA_UNIT", true),
b(24, "DO_STAR_BASE_UPKEEP", true),
b(25, "MANAGE_PROGRESSION", false),
b(26, "MANAGE_CREW_CONFIG", false),
b(27, "MANAGE_CREW", true),
b(28, "WITHDRAW_CREW", true),
b(29, "MANAGE_REGION", false),
b(30, "MANAGE_CARGO", false),
b(31, "MANAGE_CLAIM_STAKE_CONFIG", false),
b(32, "MANAGE_COMBAT", false),
b(33, "MANAGE_CLAIM_STAKE", true),
b(34, "MANAGE_CRAFTING", false),
b(35, "ENGAGE_IN_COMBAT", true),
b(36, "REPAIR", true),
b(37, "MANAGE_SCANNING", false),
b(38, "SCAN", true),
b(39, "MANAGE_LOCAL_MARKET_ORDERS", true),
b(40, "DO_RESEARCH", true),
b(41, "DAILY_CHECK_IN", true),
b(42, "MANAGE_CURRENCY", true),
];
pub const PROFILE: &[Bit] = &[
b(0, "AUTH", false),
b(1, "ADD_KEYS", false),
b(2, "REMOVE_KEYS", false),
b(3, "CHANGE_NAME", false),
b(4, "CREATE_ROLE", false),
b(5, "REMOVE_ROLE", false),
b(6, "SET_AUTHORIZER", false),
b(7, "JOIN_ROLE", false),
b(8, "LEAVE_ROLE", false),
b(9, "TOGGLE_ACCEPTING_NEW_MEMBERS", false),
b(10, "ADD_MEMBER", false),
b(11, "REMOVE_MEMBER", false),
];
pub const FACTION: &[Bit] = &[b(0, "SET_FACTION", false)];
}
The presets, by SAGE bit:
#![allow(unused)]
fn main() {
/// SAGE presets for a session key. None includes `DRAIN_SOL_VAULT`; it is a separate choice.
pub const PRESETS: &[Preset] = &[
Preset {
name: "Fleet and movement",
bits: &[10, 11, 12, 18, 19],
},
Preset {
name: "Mining and cargo",
bits: &[16, 19, 20, 38],
},
Preset {
name: "Market trading",
bits: &[16, 19, 39],
},
Preset {
name: "All player actions",
bits: &[
9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 27, 28, 33, 35, 36, 38,
39, 40, 41, 42,
],
},
];
}
Revoking
Revoke on a key’s row builds RemoveKeys for that one index, simulates it, and shows it for review in the same way. RemoveKeys names only an index, so the page also keeps the pubkey of the row you clicked: it refuses to review or sign if the profile, re-read at each step, now holds another key at that index, and after the transaction confirms it says whether that key is gone. Auth keys cannot be revoked here. Removing a key may move every key after it down one index: the table is re-read after it confirms, so re-check the indexes the agent passes to forge-mcp. Then sa-forge-signer key destroy the key file (Key lifecycle).
What it checks
- Grants are SAGE-scoped only, from the player-action bits; admin bits are not offered.
DRAIN_SOL_VAULT(bit 63) is a separate checkbox, off by default: account-creating actions such as creating fleets, crafting and claim stakes need it, and it also lets the key spend the profile’s SOL vault. - The connected wallet must be an unexpired Player Profile key on the profile with
ADD_KEYS(to grant) orREMOVE_KEYS(to revoke), orAUTH. A profile whose key threshold is above 1 is refused, since the page signs with one wallet. - A key already on the profile and unexpired is refused; revoke it first to change its permissions or expiry. The session key may not be the wallet itself.
- Every transaction is built from the form and a fresh read of the profile, never from pasted input. It is simulated before review and rebuilt with a fresh blockhash at signing.
- The signed transaction must carry exactly the message the page built. If the wallet changed it (some wallets add their own instructions), nothing is sent.
Limits
- Wallets on Z.ink: Phantom has no custom networks, so its preview and warnings for a Z.ink transaction may be wrong or missing. The page sends through its own RPC, not the wallet’s. Backpack and Solflare accept a custom RPC. Not yet tested with each wallet on Z.ink.
- Ledger: a Ledger signs through the wallet it is connected to. Its Solana app does not decode Player Profile instructions, so it needs blind signing, and the device shows only a hash: check the review on the page before you approve.
- Trust the page you run: whoever can change the files the page is built from can change what you sign, and with blind signing you would not see it. Build and serve it from a checkout the agent’s OS user cannot write, as with the signer’s own isolation.
- The Player Profile layouts are written by hand from the program’s IDL; tests check them against output from its SDK (
apps/grant/tests/fixtures/). Profile discovery searches only the first four key positions.