diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html
index 93b475c..8e27b4c 100644
--- a/zddc/internal/apps/embedded/archive.html
+++ b/zddc/internal/apps/embedded/archive.html
@@ -836,6 +836,25 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; }
}
+/* Inline action button appended to a toast by zddc.cap.handleForbidden
+ when an Elevate path is offered. Stops click propagation on its own
+ so clicking the button doesn't also dismiss the toast. */
+.zddc-toast__action {
+ display: inline-block;
+ margin-left: 0.75rem;
+ padding: 0.25rem 0.75rem;
+ background: var(--accent, var(--text));
+ color: var(--bg);
+ border: none;
+ border-radius: var(--radius);
+ font-size: 0.8125rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+.zddc-toast__action:hover {
+ filter: brightness(1.1);
+}
+
/* shared/elevation.css — admin-elevation toggle in the tool header.
Renders only for users with admin scope (handled by elevation.js;
the placeholder is `.hidden` by default). When visible, sits left
@@ -2563,7 +2582,7 @@ td[data-field="trackingNumber"] {
@@ -10859,6 +10878,170 @@ window.app.modules.filtering = {
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})();
+// 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. Fetches
+// /.profile/access?path= 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 };
+})();
+