Persist module settings safely
Implement personal, shared and browser-local JSON with revisions and cleanup.

The project you will build
Create a settings lab. Its actions show the complete lifecycle of one key in each storage scope, including reads, writes, listing and removal. Use these primitives inside preferences or customize to build your actual settings form.
Target host: Gravewright 0.3.0. Actions run only when you click Run action, using the signed-in account. The demonstration panel is English. Each recipe specifies inputs and an observable result.
1. Create the files
Create a folder named module-settings. Save the complete files below as UTF-8, or download the project. manifest.json belongs at the root. No npm dependencies or JavaScript build is required.
module-settings/
manifest.json
main.mjs
recipes.mjs
styles.css
LICENSE.txt2. Package, install and activate
From the project folder, run in PowerShell:
python -m json.tool manifest.json
python -m zipfile -c ../module-settings-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../module-settings-1.0.0.zipOn Linux/macOS, replace python with python3 in the same commands. The archive listing must show manifest.json at its root. As installation owner, open Inside → Modules → Install module → Install from ZIP, select the ZIP and click Install. As GM, open the campaign, select its Gravewright button → Extensions and Activate version 1.0.0 of Persist module settings safely. Close that menu and click Persist module settings safely. See your first module for screen-by-screen instructions.
The package stays inactive until selected for the table. Test one demonstration extension at a time because their launchers share a position. For changed bytes, increment version to 1.0.1, build another ZIP, install and activate that version; do not edit installed files.
3. Run each recipe
Read personal settings and list their keys
Run before saving. A missing key returns null; this is different from an unavailable storage service.
async ({storage}) => {
return {current:await storage.user.get('workshop.preferences'),keys:await storage.user.list('workshop.')};
}Expected result: null on first use and a key/revision list.
How to verify: Repeat as another account or in another campaign; server-backed user storage is separated.
Save a versioned personal setting with CAS
Choose a label. Read supplies the expected revision; a concurrent edit causes conflict. Do not replace that revision and retry automatically.
async ({storage},v) => {
const current=await storage.user.get('workshop.preferences');
if(current && current.value?.schema!==1) throw new Error('Unsupported settings schema; migrate explicitly');
return storage.user.set('workshop.preferences',{schema:1,label:v.label},{expectedRevision:current?.revision??null});
}Expected result: Stored value and new opaque revision, retained across deactivation.
How to verify: Read again, deactivate/reactivate and compare. Simulate two reads before two writes; the second stale write must fail.
Delete only this tutorial preference
Delete uses the latest revision of one exact key. No other module or campaign preference is removed.
async ({storage}) => {
const current=await storage.user.get('workshop.preferences');
if(current) await storage.user.delete('workshop.preferences',{expectedRevision:current.revision});
return storage.user.get('workshop.preferences');
}Expected result: null.
How to verify: Read and list again; the tutorial key is absent.
Share one GM-authored setting with the table
GM writes; players take the read-only branch. This is shared module JSON, not permission to change unrelated documents.
async ({api,storage,signal},v) => {
if(!(await api.actors.state({}, {signal})).is_gm) return {readOnly:true,current:await storage.table.get('workshop.shared')};
const current=await storage.table.get('workshop.shared');
const saved=await storage.table.set('workshop.shared',{schema:1,label:v.label},{expectedRevision:current?.revision??null});
return {saved,keys:await storage.table.list('workshop.')};
}Expected result: GM receives the saved revision; player receives that same shared value.
How to verify: Use two accounts in the same campaign. Direct player storage.table.set must be rejected server-side.
Remove the shared tutorial key
Run as GM after testing the shared value.
async ({storage}) => {
const current=await storage.table.get('workshop.shared');
if(current)await storage.table.delete('workshop.shared',{expectedRevision:current.revision});
return storage.table.get('workshop.shared');
}Expected result: null; a player cannot delete this key.
How to verify: Read as a player after GM deletion.
Use browser-local settings and clear them
Only UI choices belong here. This namespace is module+browser origin, shared across accounts and tables in that browser.
async ({storage},v) => {
await storage.local.set('workshop.local',{schema:1,density:v.density});
const value=await storage.local.get('workshop.local');
await storage.local.delete('workshop.local');
return {observed:value,afterDelete:await storage.local.get('workshop.local')};
}Expected result: observed contains the choice and afterDelete is null. Local storage has no CAS revision.
How to verify: Do not store private notes, permissions or account secrets in this namespace.
4. Understand and adapt the complete files
main.mjs renders the launcher and form. recipes.mjs owns behavior: change the chosen recipe’s run function while keeping its signature. The form passes real number, boolean and File values, blocks overlapping requests and renders using textContent. It does not evaluate user-provided code. api, host, assets and storage belong to the mount; signal cancels work on deactivation. The modal stays open during pending work and is released when the mount aborts. Each business function remains responsible for revisions, permissions and transaction sequencing.
manifest.json
{
"id": "tutorial.module-settings",
"name": "Persist module settings safely",
"version": "1.0.0",
"sdk": {
"requires": ">=1.0.0 <2.0.0",
"tested": "1.0.0"
},
"entry": "main.mjs",
"description": "Implement personal, shared and browser-local JSON with revisions and cleanup.",
"author": "Gravewright documentation",
"license": "MIT"
}main.mjs
import {recipes} from './recipes.mjs';
export default {
start({styles}) { styles.use('styles.css'); },
register({register}) {
register("table.interface", ({root,api,host,assets,storage,context,events,viewport,signal,onDispose}) => {
const doc=root.ownerDocument;
const button=doc.createElement('button');
button.type='button';button.className='tutorial-launcher';
button.textContent="Persist module settings safely";
root.append(button);
for(const type of ['pointerdown','mousedown'])button.addEventListener(type,event=>event.stopPropagation(),{signal});
let modal,pending=false;
button.addEventListener('click',event=>{
event.stopPropagation();
if(modal?.isConnected) { modal.focus();return; }
const panel=doc.createElement('section');
panel.className="tutorial-workbench-module-settings";
panel.setAttribute('role','dialog');panel.setAttribute('aria-modal','true');
panel.setAttribute('aria-label',button.textContent);panel.tabIndex=-1;
const title=doc.createElement('h2');title.textContent=button.textContent;panel.append(title);
const choose=doc.createElement('select');choose.setAttribute('aria-label','Action');
for(const recipe of recipes) {
const option=doc.createElement('option');option.value=recipe.id;option.textContent=recipe.label;choose.append(option);
}
const form=doc.createElement('form'),fields=doc.createElement('div');
const description=doc.createElement('p');
const run=doc.createElement('button');run.type='submit';run.textContent='Run action';
const close=doc.createElement('button');close.type='button';close.textContent='Close';
const output=doc.createElement('pre');output.setAttribute('role','status');output.setAttribute('aria-live','polite');
form.append(description,fields,run,close,output);panel.append(choose,form);
function renderFields(){
const recipe=recipes.find(item=>item.id===choose.value);fields.replaceChildren();
description.textContent=recipe.description;
for(const field of recipe.fields||[]) {
const label=doc.createElement('label');label.textContent=field.label;
const input=doc.createElement('input');input.name=field.name;input.type=field.type||'text';
if(field.type==='number')input.step=String(field.step??'any');
if(field.type==='checkbox')input.checked=!!field.value;
else if(field.type!=='file')input.value=String(field.value??'');
input.required=!!field.required;
if(field.accept)input.accept=field.accept;
label.append(input);fields.append(label);
}
output.textContent='';
}
choose.addEventListener('change',renderFields,{signal});renderFields();
form.addEventListener('submit',async event=>{
event.preventDefault();if(pending)return;
const recipe=recipes.find(item=>item.id===choose.value),values={};
for(const input of fields.querySelectorAll('input')) {
values[input.name]=input.type==='file'?input.files[0]:input.type==='checkbox'?input.checked:input.type==='number'?Number(input.value):input.value;
}
pending=true;choose.disabled=true;run.disabled=true;close.disabled=true;
for(const input of fields.querySelectorAll('input'))input.disabled=true;
output.textContent='Running…';
try {
const value=await recipe.run({api,host,assets,storage,context,events,viewport,signal,onDispose},values);
if(!signal.aborted)output.textContent=typeof value==='string'?value:JSON.stringify(value??null,null,2);
} catch(error) {
if(!signal.aborted)output.textContent=String(error.code||error.message||'unavailable');
} finally {
pending=false;
if(!signal.aborted){choose.disabled=false;run.disabled=false;close.disabled=false;for(const input of fields.querySelectorAll('input'))input.disabled=false;}
}
},{signal});
close.addEventListener('click',()=>void modal.close(),{signal});
modal=api.ui.openModal(panel,{canClose:()=>!pending||signal.aborted});
},{signal});
onDispose(()=>{button.remove();if(modal)void modal.close();});
});
},
stop() {}
};recipes.mjs
export const recipes=[
{
id:"user-read",
label:"Read personal settings and list their keys",
description:"Run before saving. A missing key returns null; this is different from an unavailable storage service.",
fields:[],
run:async ({storage}) => {
return {current:await storage.user.get('workshop.preferences'),keys:await storage.user.list('workshop.')};
}
},
{
id:"user-set",
label:"Save a versioned personal setting with CAS",
description:"Choose a label. Read supplies the expected revision; a concurrent edit causes conflict. Do not replace that revision and retry automatically.",
fields:[
{
"name": "label",
"label": "Panel label",
"value": "My campaign notes",
"type": "text",
"required": true
}
],
run:async ({storage},v) => {
const current=await storage.user.get('workshop.preferences');
if(current && current.value?.schema!==1) throw new Error('Unsupported settings schema; migrate explicitly');
return storage.user.set('workshop.preferences',{schema:1,label:v.label},{expectedRevision:current?.revision??null});
}
},
{
id:"user-delete",
label:"Delete only this tutorial preference",
description:"Delete uses the latest revision of one exact key. No other module or campaign preference is removed.",
fields:[],
run:async ({storage}) => {
const current=await storage.user.get('workshop.preferences');
if(current) await storage.user.delete('workshop.preferences',{expectedRevision:current.revision});
return storage.user.get('workshop.preferences');
}
},
{
id:"table-store",
label:"Share one GM-authored setting with the table",
description:"GM writes; players take the read-only branch. This is shared module JSON, not permission to change unrelated documents.",
fields:[
{
"name": "label",
"label": "Shared label",
"value": "Workshop rules of courtesy",
"type": "text",
"required": true
}
],
run:async ({api,storage,signal},v) => {
if(!(await api.actors.state({}, {signal})).is_gm) return {readOnly:true,current:await storage.table.get('workshop.shared')};
const current=await storage.table.get('workshop.shared');
const saved=await storage.table.set('workshop.shared',{schema:1,label:v.label},{expectedRevision:current?.revision??null});
return {saved,keys:await storage.table.list('workshop.')};
}
},
{
id:"table-delete",
label:"Remove the shared tutorial key",
description:"Run as GM after testing the shared value.",
fields:[],
run:async ({storage}) => {
const current=await storage.table.get('workshop.shared');
if(current)await storage.table.delete('workshop.shared',{expectedRevision:current.revision});
return storage.table.get('workshop.shared');
}
},
{
id:"local",
label:"Use browser-local settings and clear them",
description:"Only UI choices belong here. This namespace is module+browser origin, shared across accounts and tables in that browser.",
fields:[
{
"name": "density",
"label": "Local density",
"value": "compact",
"type": "text",
"required": true
}
],
run:async ({storage},v) => {
await storage.local.set('workshop.local',{schema:1,density:v.density});
const value=await storage.local.get('workshop.local');
await storage.local.delete('workshop.local');
return {observed:value,afterDelete:await storage.local.get('workshop.local')};
}
}
];styles.css
.gw-module-root[data-module-id="tutorial.module-settings"] .tutorial-launcher { position: fixed; left: 16px; bottom: 112px; z-index: 10; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); color: var(--text); font-size: 14px; pointer-events: auto; max-width: min(440px, calc(100vw - 32px)); white-space: normal; }
.tutorial-workbench-module-settings { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-module-settings label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-module-settings input, .tutorial-workbench-module-settings select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-module-settings pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-module-settings button { margin-right: var(--space-3); }LICENSE.txt
MIT License
Copyright (c) 2026 Gravewright contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.5. Permissions, failures and final test
Perform writes only in a new development campaign with original resources. The server remains authoritative when controls are hidden. Test GM, read-only player, a missing UUID, another campaign’s ID, network failure and deactivation during a pending request. A conflict requires a fresh read and a user decision; do not silently force retries. Every new logical write needs a new requestId. Only reuse one to retry that exact operation when the service supports receipts. Read and write steps are separate transactions, so inspect persisted state after a partial failure.
How to use AI to extend this project
Adapt this complete module-settings module for [specific goal] on Gravewright 0.3.0. Read the corresponding public service and recipe. Preserve authorized projections, opaque revisions, request UUIDs, cancellation and cleanup. Provide complete files and a new immutable version, plus explicit GM/player and failure tests.