Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/pages/plain/publish.astro
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import PlainLayout from '../../layouts/PlainLayout.astro';
<li>Email: where we send your submission confirmation and the review decision.</li>
<li>Identity: app id (io.pilot.&lt;name&gt;), version (semver), and a one-line description.</li>
<li>Backend: the app type (HTTP API; CLI/binary is coming soon), the backend base URL, and any auth headers. Header values may use $&#123;TOKEN&#125; placeholders that the operator supplies at install — never stored in the app.</li>
<li>Methods: each method an agent can call — name (&lt;ns&gt;.&lt;verb&gt;), HTTP route, latency class (fast under 5s, medium up to 15s, slow up to 1 minute), description, and parameters.</li>
<li>Methods: each method an agent can call — name (&lt;ns&gt;.&lt;verb&gt;), HTTP route (verb GET/POST/PUT/PATCH/DELETE and a path template that may contain &#123;name&#125; placeholders), latency class (fast under 5s, medium up to 15s, slow up to 1 minute), description, and parameters. For HTTP methods each parameter also has an "in" that says where the adapter reads it from: query (?name=value), path (fills &#123;name&#125; url-encoded), path_raw (fills &#123;name&#125; unescaped, for URL-in-path APIs), body (a JSON body field), or header. It defaults to the verb's natural location (GET → query, POST/PUT/PATCH → body).</li>
<li>Listing: display name, description, license, and categories for the store page.</li>
<li>Vendor: who you are, how autonomous agents should use the app, and the list of capabilities.</li>
<li>Review and submit: sign the Publisher Release Agreement (type your full legal name and check the box), then the server builds, signs, and verifies the adapter and stores it for review.</li>
Expand Down
107 changes: 93 additions & 14 deletions src/pages/publish.astro
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,13 @@ const canonicalUrl = 'https://pilotprotocol.network/publish';
#publish .params { margin-top:18px; padding-top:18px; border-top:1px solid var(--c-line); }
#publish .phead { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
#publish .ptable .prow { display:grid; grid-template-columns:1.2fr .9fr 56px 1.7fr 40px; gap:10px; align-items:center; margin-bottom:8px; }
/* HTTP params carry an extra "In" (request-mapping) column. */
#publish .params.http .ptable .prow { grid-template-columns:1.1fr .8fr 50px 1.05fr 1.5fr 40px; align-items:start; }
#publish .params.http .ptable .prow.phdr { align-items:center; }
#publish .ptable .prow.phdr { font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--c-dim); font-weight:600; margin-bottom:8px; }
#publish .p-incell { display:flex; flex-direction:column; gap:4px; }
#publish .phint { font-size:12px; line-height:1.35; color:oklch(0.55 0.12 70); }
#publish .phint code { font-family:var(--mono,monospace); background:#f4efe2; color:var(--c-ink); padding:1px 5px; border-radius:5px; }
#publish .reqcell { display:flex; justify-content:center; align-items:center; }
#publish .reqcell input { width:auto; }
#publish .ptable .p-del { padding:9px 0; }
Expand Down Expand Up @@ -218,9 +224,26 @@ const STEPS = ['Email', 'Identity', 'Backend', 'Methods', 'Listing', 'Vendor', '
let step = 0;


const blankParam = () => ({ name: '', type: 'string', required: false, description: '' });
// in === '' means "use the verb's natural default" (GET→query, write verbs→body),
// resolved in submission(). Kept blank in state so simple forms emit the right
// default with zero clicks; an explicit choice (path/path_raw/header/…) overrides it.
const blankParam = () => ({ name: '', type: 'string', required: false, in: '', description: '' });
const blankCLI = () => ({ args: '', params_as_flags: false, passthrough: false });
const blankMethod = () => ({ name: '', http: { verb: 'GET', path: '' }, cli: blankCLI(), latency: '', description: '', params: [blankParam()] });

// Request-mapping resolution for an HTTP param: where the adapter reads it from.
const PARAM_IN = [
['query', '?name=value, url-encoded. Default for GET.'],
['path', 'fills {name} in the path, url-encoded (REST).'],
['path_raw', 'fills {name} in the path UNescaped — for URL-in-path APIs, e.g. a fetch/proxy that takes GET /<rawurl>.'],
['body', 'JSON body field. Default for POST/PUT/PATCH.'],
['header', 'sent as a request header.'],
];
const HTTP_VERBS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
// The natural default location for a param given the method verb.
function defaultIn(verb){ return verb === 'GET' || verb === 'DELETE' ? 'query' : 'body'; }
// Does the path template reference {name} (for path / path_raw to be meaningful)?
function pathHas(path, name){ return !!name && new RegExp('\\{' + name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + '\\}').test(path || ''); }
const blankHeader = () => ({ name: '', value: '' });

// Version of the Publisher Release Agreement the publisher signs at submit time.
Expand All @@ -243,7 +266,12 @@ if (!state.release) state.release = { signer_name: '', agreed: false };
if (state.app_type == null) state.app_type = 'api';
if (state.backend.command == null) state.backend.command = '';
if (state.backend.env_passthrough == null) state.backend.env_passthrough = '';
state.methods.forEach(m => { if (!m.cli) m.cli = blankCLI(); if (!m.http) m.http = { verb: 'GET', path: '' }; });
state.methods.forEach(m => {
if (!m.cli) m.cli = blankCLI();
if (!m.http) m.http = { verb: 'GET', path: '' };
// Drafts saved before per-param request mapping won't carry `in`.
(m.params || []).forEach(p => { if (p.in == null) p.in = ''; });
});

function load() { try { return JSON.parse(localStorage.getItem(LS)); } catch { return null; } }
function save() { localStorage.setItem(LS, JSON.stringify(state)); }
Expand All @@ -263,7 +291,13 @@ function submission() {
email: state.email,
backend,
methods: state.methods.filter(m=>m.name.trim()).map(m=>{
const base = { name: m.name, description: m.description, latency: m.latency, params: m.params.filter(p=>p.name.trim()) };
// HTTP params carry an `in` (request-mapping location); CLI params don't.
const params = m.params.filter(p=>p.name.trim()).map(p=>{
const base = { name: p.name, type: p.type, required: !!p.required, description: p.description };
if (!cli) base.in = p.in || defaultIn(m.http.verb);
return base;
});
const base = { name: m.name, description: m.description, latency: m.latency, params };
if (cli) base.cli = { args: m.cli.passthrough ? [] : csv(m.cli.args), params_as_flags: !!m.cli.params_as_flags, passthrough: !!m.cli.passthrough };
else base.http = { verb: m.http.verb, path: m.http.path };
return base;
Expand Down Expand Up @@ -436,8 +470,8 @@ function cliBackendHTML(){

// ── method route cells (per app type) ──
function httpRouteCells(m){
return `<div class="field"><span class="ghead">Verb</span><select class="m-verb"><option ${m.http.verb==='GET'?'selected':''}>GET</option><option ${m.http.verb==='POST'?'selected':''}>POST</option></select></div>
<div class="field"><span class="ghead">Path ${info('Backend route. GET → params become the query string; POSTJSON body.')}</span><input class="m-path" placeholder="/search" value="${esc(m.http.path)}"></div>`;
return `<div class="field"><span class="ghead">Verb</span><select class="m-verb">${HTTP_VERBS.map(v=>`<option ${m.http.verb===v?'selected':''}>${v}</option>`).join('')}</select></div>
<div class="field"><span class="ghead">Path ${info('Backend route, e.g. /search or /items/{id}. Use {name} placeholders to insert a param into the path (set that param\'s "in" to path or path_raw below). By default GET/DELETE params become the query string and POST/PUT/PATCH params become the JSON body.')}</span><input class="m-path" placeholder="/search or /items/{id}" value="${esc(m.http.path)}"></div>`;
}
// The CLI route lives in a full-width row below the name/latency grid.
function cliRouteRow(m){
Expand Down Expand Up @@ -465,20 +499,40 @@ function methodCard(m,i){
${cli ? cliRouteRow(m) : ''}
<div class="field"><span class="ghead">Description ${info('Shown in the help doc agents read. Be specific about what it returns.')}</span>
<textarea class="m-description" placeholder="Search the corpus; returns ranked results with url, title, score.">${esc(m.description)}</textarea></div>
<div class="params">
<div class="params${cli?'':' http'}">
<div class="phead"><span class="ghead" style="margin:0">Parameters</span><button class="iconbtn p-add" type="button">+ parameter</button></div>
<div class="ptable">
<div class="prow phdr"><span>Name</span><span>Type</span><span>Req</span><span>Description</span><span></span></div>
${m.params.map((p,pi)=>paramRow(p,pi)).join('')}
<div class="prow phdr"><span>Name</span><span>Type</span><span>Req</span>${cli?'':`<span>In ${info('Where the adapter reads this param from when it calls your backend. query/path/path_raw fill the URL, body is a JSON field, header is a request header. Defaults to the natural location for the verb (GET → query, POST → body).')}</span>`}<span>Description</span><span></span></div>
${m.params.map((p,pi)=>paramRow(p,pi,m)).join('')}
</div>
</div>
</div>`;
}
function paramRow(p,pi){
function paramRow(p,pi,m){
const cli = state.app_type==='cli';
if (cli) {
return `<div class="prow" data-pi="${pi}">
<input class="p-name" placeholder="q" value="${esc(p.name)}">
<select class="p-type">${['string','int','number','bool','object'].map(t=>`<option ${p.type===t?'selected':''}>${t}</option>`).join('')}</select>
<label class="reqcell"><input type="checkbox" class="p-req" ${p.required?'checked':''}></label>
<input class="p-desc" placeholder="what this parameter is" value="${esc(p.description)}">
<button class="iconbtn danger p-del" type="button" title="Remove">✕</button></div>`;
}
const eff = p.in || defaultIn(m.http.verb);
const opts = PARAM_IN.map(([v,d])=>{
const isDefault = !p.in && v===eff;
const lbl = v + (isDefault ? ' (default)' : '');
return `<option value="${v}" ${p.in===v?'selected':''} title="${esc(d)}">${lbl}</option>`;
}).join('');
// path / path_raw only make sense if {name} is in the path template — hint when not.
const needsPath = (eff==='path'||eff==='path_raw');
const hint = needsPath && !pathHas(m.http.path, p.name)
? `<div class="phint">Add <code>{${esc(p.name||'name')}}</code> to the path to place this param there.</div>` : '';
return `<div class="prow" data-pi="${pi}">
<input class="p-name" placeholder="q" value="${esc(p.name)}">
<select class="p-type">${['string','int','number','bool'].map(t=>`<option ${p.type===t?'selected':''}>${t}</option>`).join('')}</select>
<select class="p-type">${['string','int','number','bool','object'].map(t=>`<option ${p.type===t?'selected':''}>${t}</option>`).join('')}</select>
<label class="reqcell"><input type="checkbox" class="p-req" ${p.required?'checked':''}></label>
<div class="p-incell"><select class="p-in">${opts}</select>${hint}</div>
<input class="p-desc" placeholder="what this parameter is" value="${esc(p.description)}">
<button class="iconbtn danger p-del" type="button" title="Remove">✕</button></div>`;
}
Expand Down Expand Up @@ -518,17 +572,30 @@ function wire() {
document.querySelectorAll('#methods .mcard').forEach(cardEl=>{ const i=+cardEl.dataset.mi; const m=state.methods[i];
const on=(sel,fn)=>{ const el=cardEl.querySelector(sel); if(el) el.addEventListener('input',()=>{fn(el);save();schedulePreview();}); };
on('.m-name',el=>{m.name=el.value; cardEl.querySelector('.mname').textContent=el.value||ns()+'.method';});
on('.m-verb',el=>m.http.verb=el.value); on('.m-path',el=>m.http.path=el.value);
// Verb change shifts the natural param default (and the (default) labels) and
// the path string change toggles the {name} hints — re-render to reflect both.
const verbEl=cardEl.querySelector('.m-verb'); if(verbEl) verbEl.addEventListener('change',()=>{m.http.verb=verbEl.value;save();render();schedulePreview();});
const pathEl=cardEl.querySelector('.m-path'); if(pathEl) pathEl.addEventListener('input',()=>{m.http.path=pathEl.value;save();schedulePreview();
// Refresh just the param hints live without losing focus on the path field.
cardEl.querySelectorAll('.params .prow[data-pi]').forEach(pr=>{ const pi=+pr.dataset.pi; const pp=m.params[pi]; const cell=pr.querySelector('.p-incell'); if(!cell)return;
const eff=pp.in||defaultIn(m.http.verb); const old=cell.querySelector('.phint'); if(old)old.remove();
if((eff==='path'||eff==='path_raw') && !pathHas(m.http.path, pp.name)){ const h=document.createElement('div'); h.className='phint'; h.innerHTML='Add <code>{'+esc(pp.name||'name')+'}</code> to the path to place this param there.'; cell.appendChild(h); }
});
});
on('.m-args',el=>m.cli.args=el.value);
const mflags=cardEl.querySelector('.m-flags'); if(mflags) mflags.addEventListener('change',()=>{m.cli.params_as_flags=mflags.checked;save();schedulePreview();});
const mpass=cardEl.querySelector('.m-passthrough'); if(mpass) mpass.addEventListener('change',()=>{m.cli.passthrough=mpass.checked;save();render();schedulePreview();});
on('.m-latency',el=>m.latency=el.value); on('.m-description',el=>m.description=el.value);
const del=cardEl.querySelector('.m-del'); if(del) del.onclick=()=>{ state.methods.splice(i,1); if(!state.methods.length)state.methods.push(blankMethod()); save(); render(); };
cardEl.querySelector('.p-add').onclick=()=>{ m.params.push(blankParam()); save(); render(); };
cardEl.querySelectorAll('.params .prow[data-pi]').forEach(pr=>{ const pi=+pr.dataset.pi; const p=m.params[pi];
pr.querySelector('.p-name').addEventListener('input',e=>{p.name=e.target.value;save();schedulePreview();});
// Refresh only this row's {name} hint without re-rendering (keeps input focus).
const refreshHint=()=>{ const cell=pr.querySelector('.p-incell'); if(!cell)return; const eff=p.in||defaultIn(m.http.verb); const old=cell.querySelector('.phint'); if(old)old.remove();
if((eff==='path'||eff==='path_raw') && !pathHas(m.http.path, p.name)){ const h=document.createElement('div'); h.className='phint'; h.innerHTML='Add <code>{'+esc(p.name||'name')+'}</code> to the path to place this param there.'; cell.appendChild(h); } };
pr.querySelector('.p-name').addEventListener('input',e=>{p.name=e.target.value;save();refreshHint();schedulePreview();});
pr.querySelector('.p-type').addEventListener('input',e=>{p.type=e.target.value;save();schedulePreview();});
pr.querySelector('.p-req').addEventListener('change',e=>{p.required=e.target.checked;save();});
const pin=pr.querySelector('.p-in'); if(pin) pin.addEventListener('change',e=>{p.in=e.target.value;save();refreshHint();schedulePreview();});
pr.querySelector('.p-desc').addEventListener('input',e=>{p.description=e.target.value;save();});
pr.querySelector('.p-del').onclick=()=>{ m.params.splice(pi,1); if(!m.params.length)m.params.push(blankParam()); save(); render(); };
});
Expand Down Expand Up @@ -577,7 +644,17 @@ function validateStep(){
if(badm) bad('e-methods','Each CLI method needs a '+ns()+'.-prefixed name, a latency, a description, and either arguments, params-as-flags, or passthrough'); else show('e-methods','');
} else {
const badm=ms.find(m=>!m.latency||!m.description.trim()||!/^\//.test(m.http.path)||!m.name.startsWith(ns()+'.'));
if(badm) bad('e-methods','Each method needs a '+ns()+'.-prefixed name, path starting with /, a latency, and a description'); else show('e-methods','');
if(badm){ bad('e-methods','Each method needs a '+ns()+'.-prefixed name, path starting with /, a latency, and a description'); }
else {
// path / path_raw params must reference a {name} placeholder in the path.
let badp=null;
ms.forEach(m=>m.params.filter(p=>p.name.trim()).forEach(p=>{
const eff=p.in||defaultIn(m.http.verb);
if((eff==='path'||eff==='path_raw') && !pathHas(m.http.path, p.name) && !badp) badp={m,p};
}));
if(badp) bad('e-methods','Param "'+badp.p.name+'" is mapped to '+(badp.p.in)+' but {'+badp.p.name+'} is not in '+badp.m.name+'’s path. Add {'+badp.p.name+'} to the path, or change its In.');
else show('e-methods','');
}
}
}
return ok;
Expand All @@ -604,14 +681,16 @@ function renderReview(){
// is the <br> separators in Methods. All user-controlled text goes through esc().
const cli=state.app_type==='cli';
const routeStr=m=>cli ? (m.cli.passthrough?'passthrough':((m.cli.args||[]).join(' ')||'flags')+(m.cli.params_as_flags?' +flags':'')) : `${m.http.verb} ${m.http.path}`;
// For HTTP, show each param's request-mapping location, e.g. q→query, id→path.
const paramsStr=m=>(m.params||[]).length ? (cli ? (m.params||[]).map(p=>p.name).join(', ') : (m.params||[]).map(p=>`${p.name}→${p.in}`).join(', ')) : '';
const rows=[
['App ID',esc(s.id)],['Version',esc(s.version)],['Description',esc(s.description)],
['App type',cli?'CLI':'HTTP API'],
cli ? ['Command',esc((s.backend.command||[]).join(' '))] : ['Backend',esc(s.backend.base_url)],
...(cli
? ((s.backend.env_passthrough||[]).length ? [['Env passthrough',esc((s.backend.env_passthrough||[]).join(', '))]] : [])
: s.backend.headers.map(h=>['Header',esc(h.name+': '+h.value)])),
['Methods',s.methods.map(m=>esc(`${m.name} (${routeStr(m)}, ${m.latency})`)).join('<br>')],
['Methods',s.methods.map(m=>{ const ps=paramsStr(m); return esc(`${m.name} (${routeStr(m)}, ${m.latency})`)+(ps?` <span class="muted">— ${esc(ps)}</span>`:''); }).join('<br>')],
['Display name',esc(s.listing.display_name)],['License',esc(s.listing.license)],
['App description',esc(s.listing.app_description)],['Categories',esc((s.listing.categories||[]).join(', '))],
['Vendor',esc(s.vendor.name+(s.vendor.url?' · '+s.vendor.url:''))],['Email',esc(s.email)],
Expand Down
Loading