diff --git a/src/pages/plain/publish.astro b/src/pages/plain/publish.astro index fda90eb..a001c56 100644 --- a/src/pages/plain/publish.astro +++ b/src/pages/plain/publish.astro @@ -18,7 +18,7 @@ import PlainLayout from '../../layouts/PlainLayout.astro';
  • Email: where we send your submission confirmation and the review decision.
  • Identity: app id (io.pilot.<name>), version (semver), and a one-line description.
  • Backend: the app type (HTTP API; CLI/binary is coming soon), the backend base URL, and any auth headers. Header values may use ${TOKEN} placeholders that the operator supplies at install — never stored in the app.
  • -
  • Methods: each method an agent can call — name (<ns>.<verb>), HTTP route, latency class (fast under 5s, medium up to 15s, slow up to 1 minute), description, and parameters.
  • +
  • Methods: each method an agent can call — name (<ns>.<verb>), HTTP route (verb GET/POST/PUT/PATCH/DELETE and a path template that may contain {name} 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 {name} url-encoded), path_raw (fills {name} 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).
  • Listing: display name, description, license, and categories for the store page.
  • Vendor: who you are, how autonomous agents should use the app, and the list of capabilities.
  • 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.
  • diff --git a/src/pages/publish.astro b/src/pages/publish.astro index dcc833f..7683dcc 100644 --- a/src/pages/publish.astro +++ b/src/pages/publish.astro @@ -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; } @@ -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 /.'], + ['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. @@ -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)); } @@ -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; @@ -436,8 +470,8 @@ function cliBackendHTML(){ // ── method route cells (per app type) ── function httpRouteCells(m){ - return `
    Verb
    -
    Path ${info('Backend route. GET → params become the query string; POST → JSON body.')}
    `; + return `
    Verb
    +
    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.')}
    `; } // The CLI route lives in a full-width row below the name/latency grid. function cliRouteRow(m){ @@ -465,20 +499,40 @@ function methodCard(m,i){ ${cli ? cliRouteRow(m) : ''}
    Description ${info('Shown in the help doc agents read. Be specific about what it returns.')}
    -
    +
    Parameters
    -
    NameTypeReqDescription
    - ${m.params.map((p,pi)=>paramRow(p,pi)).join('')} +
    NameTypeReq${cli?'':`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).')}`}Description
    + ${m.params.map((p,pi)=>paramRow(p,pi,m)).join('')}
    `; } -function paramRow(p,pi){ +function paramRow(p,pi,m){ + const cli = state.app_type==='cli'; + if (cli) { + return `
    + + + + +
    `; + } + 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 ``; + }).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) + ? `
    Add {${esc(p.name||'name')}} to the path to place this param there.
    ` : ''; return `
    - + +
    ${hint}
    `; } @@ -518,7 +572,16 @@ 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 {'+esc(pp.name||'name')+'} 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();}); @@ -526,9 +589,13 @@ function wire() { 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 {'+esc(p.name||'name')+'} 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(); }; }); @@ -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; @@ -604,6 +681,8 @@ function renderReview(){ // is the
    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'], @@ -611,7 +690,7 @@ function renderReview(){ ...(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('
    ')], + ['Methods',s.methods.map(m=>{ const ps=paramsStr(m); return esc(`${m.name} (${routeStr(m)}, ${m.latency})`)+(ps?` — ${esc(ps)}`:''); }).join('
    ')], ['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)],