ZDDC/shared/cap.js
ZDDC defed434cc 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>
2026-05-21 08:50:49 -05:00

163 lines
6.9 KiB
JavaScript

// shared/cap.js — client-side capability helpers for permission gating.
//
// Three small helpers, exposed under window.zddc.cap, that wrap the
// server's verbs / /.profile/access?path / 403 missing_verb surface:
//
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
// /.profile/access?path=<urlpath> and
// memoises per-path for the session.
// Used by tools to gate top-of-page
// affordances (Publish, +Add row,
// +New folder) on PathVerbs.
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
// "rwcda"-subset) for the listed verb.
// Transition: falls back to
// node.writable for 'w' when verbs
// is absent, so the legacy field still
// drives gating on old listings.
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
// parses the JSON body for
// missing_verb and renders a toast.
// Offers "Elevate" when the path's
// /.profile/access?path= reports a
// path_can_elevate_grant covering the
// missing verb.
//
// Tools using this module must concat shared/cap.js AFTER shared/
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.cap) return;
var pathCache = new Map(); // path → AccessView (or null sentinel)
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 {
var url = '/.profile/access';
if (path) url += '?path=' + encodeURIComponent(path);
var resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
// at(path) — fetch path-scoped access view, memoised per path
// within the page session. Cache is page-scoped: any elevation
// toggle forces a hard reload (see shared/elevation.js), which
// resets the cache so stale-after-elevation isn't a concern. Pass
// null/undefined for the global view (no ?path=).
async function at(path) {
var key = path || '';
if (pathCache.has(key)) return pathCache.get(key);
var view = await fetchAccess(path);
pathCache.set(key, view);
return view;
}
// has(node, verb) — check a per-entry verbs string for a single
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
// Transition shim: when node.verbs is absent, fall back to
// node.writable for 'w' so the legacy field keeps editor save
// buttons working on old listings — drop this fallback once every
// tool's loader sets node.verbs unconditionally.
function has(node, verb) {
if (!node) return false;
if (typeof node.verbs === 'string') {
return node.verbs.indexOf(verb) !== -1;
}
if (verb === 'w' && typeof node.writable === 'boolean') {
return node.writable;
}
return false;
}
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
var VERB_LABELS = {
r: 'read',
w: 'write',
c: 'create',
d: 'delete',
a: 'edit access rules'
};
// handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to
// decide whether to offer an Elevate action. opts.context is an
// optional string prefix shown before the verb message ("Save",
// "Delete", etc.) — purely cosmetic.
//
// Best-effort: when the body isn't JSON or missing_verb is
// absent, falls back to a plain "Forbidden" toast. Returns the
// Promise so callers can await before chaining.
async function handleForbidden(resp, opts) {
opts = opts || {};
var missing = '';
try {
var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb;
}
} catch (_e) { /* non-JSON body */ }
var prefix = opts.context ? (opts.context + ': ') : '';
var verbLabel = VERB_LABELS[missing] || missing || '';
var msg;
if (verbLabel) {
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
} else {
msg = prefix + 'Forbidden.';
}
// Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable
// action appended to the toast message; clicking sets the
// elevation cookie and reloads, matching the header toggle.
var canOffer = false;
if (opts.path && missing) {
var view = await at(opts.path);
if (view && typeof view.path_can_elevate_grant === 'string'
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
canOffer = true;
}
}
var toastFn = (window.zddc && window.zddc.toast) || function () {};
var el = toastFn(msg, 'error', { durationMs: 8000 });
if (canOffer && el && el.appendChild) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'zddc-toast__action';
btn.textContent = 'Elevate';
btn.addEventListener('click', function (ev) {
ev.stopPropagation(); // don't dismiss the toast
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
window.zddc.elevation.setElevated(true);
window.location.reload();
}
});
el.appendChild(btn);
}
}
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})();