feat(form): pre-flight Submit gate + cap-toast on 403

Two changes to the form tool's submit path:

  - Submit button hides when /.profile/access?path=<submission dir>
    reports no 'c' verb. The form-status line surfaces a short
    explanation so the user knows why the button disappeared.
  - 403 on POST routes through zddc.cap.handleForbidden, which
    renders an error toast naming the missing verb and offers
    Elevate when the path-scoped view reports an elevation grant
    covering it. The existing "You are not allowed to submit here"
    status line still appears as the in-form indicator.

Also guards shared/cap.js's fetchAccess against file:// URLs —
calling fetch() on a file:// page logs a browser-level error that
shows up as test-runner noise. Short-circuiting to null lets
offline tools (browse on a picked folder, form opened standalone
from a file URL) silently degrade to "no path-scoped info" and
fall back to whatever existing gate they had.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-21 08:50:49 -05:00
parent 34208a5bd7
commit defed434cc
3 changed files with 38 additions and 0 deletions

View file

@ -79,6 +79,29 @@
const submitBtn = document.getElementById('submit-btn'); const submitBtn = document.getElementById('submit-btn');
if (submitBtn) { if (submitBtn) {
submitBtn.addEventListener('click', app.modules.post.submit); submitBtn.addEventListener('click', app.modules.post.submit);
// Pre-flight gate: hide Submit when the cascade denies
// create at the submission directory. Server still
// enforces on POST — this just avoids dangling an
// affordance that would 403. Submission directory is the
// parent of submitUrl; fall back to the page URL when
// submitUrl is absent (file:// / no-context mode).
if (window.zddc && window.zddc.cap && app.context && app.context.submitUrl) {
const subUrl = app.context.submitUrl;
const dir = subUrl.replace(/\/[^\/]*$/, '/') || subUrl;
window.zddc.cap.at(dir).then(function (view) {
if (!view) return;
const verbs = view.path_verbs || '';
if (verbs.indexOf('c') === -1) {
submitBtn.hidden = true;
const status = document.getElementById('form-status');
if (status) {
status.textContent = "You don't have permission to submit here.";
status.hidden = false;
status.classList.add('is-error');
}
}
});
}
} }
} }

View file

@ -56,6 +56,12 @@
showStatus('Please correct the errors below.', 'error'); showStatus('Please correct the errors below.', 'error');
} else if (res.status === 403) { } else if (res.status === 403) {
showStatus('You are not allowed to submit here.', 'error'); showStatus('You are not allowed to submit here.', 'error');
if (window.zddc && window.zddc.cap) {
window.zddc.cap.handleForbidden(res, {
context: 'Submit',
path: app.context.submitUrl
});
}
} else if (res.status === 409) { } else if (res.status === 409) {
showStatus('A submission with this filename already exists.', 'error'); showStatus('A submission with this filename already exists.', 'error');
} else { } else {

View file

@ -34,6 +34,15 @@
var pathCache = new Map(); // path → AccessView (or null sentinel) var pathCache = new Map(); // path → AccessView (or null sentinel)
async function fetchAccess(path) { async function fetchAccess(path) {
// file:// pages have no server to fetch /.profile/access from;
// calling fetch() there logs a browser-level error before our
// catch even runs. Short-circuit so offline tools (browse on
// a picked folder, form opened from a file URL) silently
// degrade to "no path-scoped info, fall back to existing
// gating signals".
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
return null;
}
try { try {
var url = '/.profile/access'; var url = '/.profile/access';
if (path) url += '?path=' + encodeURIComponent(path); if (path) url += '?path=' + encodeURIComponent(path);