src/shares.js
Rama main · réplica de lectura del repositorio original.
import { randomBytes } from 'node:crypto';
import { getForm } from './forms.js';
import { uuid } from './ids.js';
import { WeaveError } from './store.js';
import { grantReaches } from './access.js';
import { renderDocumentPage, escapeHtml } from './markdown.js';
export const SHARE_KINDS = ['entity', 'entities', 'table', 'space', 'view', 'workspace'];
export const SHARE_MODES = ['read', 'comment', 'edit', 'manage', 'submit'];
export const SHARE_VISIBILITIES = ['public', 'private'];
export const SHARE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const RUNG = { read: 0, comment: 1, edit: 2, manage: 3 };
const TOKEN = /^wv[sv]_[A-Za-z0-9_-]{16,}$/;
const own = (map, id) => (map && typeof id === 'string' && Object.hasOwn(map, id) ? map[id] : null);
const nowMs = (weave) => (typeof weave.now === 'function' ? weave.now() : new Date()).getTime();
const views = (weave) => weave.state.meta.views ?? {};
const liveSpace = (weave, id) => {
const sp = own(weave.state.spaces, id);
return sp && !sp.deletedAt && !sp.system ? sp : null;
};
const liveTable = (weave, id) => {
const db = own(weave.state.tables, id);
return db && !db.deletedAt && !db.system && liveSpace(weave, db.spaceId) ? db : null;
};
const liveEntity = (weave, id) => {
const e = own(weave.state.entities, id);
return e && !e.deletedAt && liveTable(weave, e.dbId) ? e : null;
};
const invalid = (msg) => new WeaveError(msg, 'invalid');
const registry = (what) => invalid(`${what} belongs to the system registry; a share reaches the records people made`);
const gone = (what, ref) => new WeaveError(`${what} '${ref}' not found`, 'not-found');
function resolveScope(weave, scope) {
const kind = scope?.kind;
if (kind === 'entities') {
if (!Array.isArray(scope.ids) || !scope.ids.length || scope.ids.length > 1000) throw invalid('Select between 1 and 1000 entries');
const ids = [...new Set(scope.ids.map(id => resolveScope(weave, { kind: 'entity', id }).id))];
return { kind, ids };
}
if (!SHARE_KINDS.includes(kind)) throw invalid(`A share's scope.kind is one of ${SHARE_KINDS.join(', ')}; got ${JSON.stringify(kind ?? null)}`);
if (kind === 'workspace') return { kind, id: weave.state.meta.id };
const ref = scope.id == null ? '' : String(scope.id);
if (!ref) throw invalid(`Name the ${kind} to share in scope.id`);
if (kind === 'entity') {
const e = weave.getEntity(ref);
if (e.deletedAt) throw gone('Entity', ref);
if (!liveEntity(weave, e.id)) throw registry(`Entity '${ref}'`);
return { kind, id: e.id };
}
if (kind === 'table') {
const db = weave.getTable(ref);
if (db.deletedAt) throw gone('Table', ref);
if (!liveTable(weave, db.id)) throw registry(`Table '${ref}'`);
return { kind, id: db.id };
}
if (kind === 'space') {
const sp = weave.getSpace(ref);
if (!liveSpace(weave, sp.id)) throw registry(`Space '${ref}'`);
return { kind, id: sp.id };
}
return { kind, id: weave.getView(ref).id };
}
function alive(weave, scope) {
switch (scope?.kind) {
case 'entities': return scope.ids.some(id => !!liveEntity(weave, id));
case 'entity': return !!liveEntity(weave, scope.id);
case 'table': return !!liveTable(weave, scope.id);
case 'space': return !!liveSpace(weave, scope.id);
case 'view': return !!own(views(weave), scope.id);
case 'workspace': return scope.id === weave.state.meta.id;
default: return false;
}
}
const live = (weave, g) => !!g?.token && !g.revokedAt && !(g.expiresAt && Date.parse(g.expiresAt) <= nowMs(weave)) && alive(weave, g.scope);
export function scopeTitle(weave, scope) {
try {
switch (scope.kind) {
case 'entities': return `${scope.ids.length} selected entries`;
case 'entity': {
const e = weave.getEntity(scope.id);
const db = weave.state.tables[e.dbId];
const name = weave.entityName(e).trim();
return `${db.name} #${e.publicId}${name ? ` · ${name}` : ''}`;
}
case 'table': return weave.qualifiedName(weave.getTable(scope.id));
case 'space': return weave.getSpace(scope.id).name;
case 'view': return weave.getView(scope.id).name;
default: return weave.state.meta.title ?? weave.state.meta.name ?? 'workspace';
}
} catch {
return null;
}
}
const publicGrant = (weave, g) => ({ ...g, createdBy: weave.actorName(g.createdBy ?? null), scope: structuredClone(g.scope), url: `/s/${g.token}`, title: scopeTitle(weave, g.scope), status: g.revokedAt ? 'revoked' : live(weave, g) ? 'active' : 'expired' });
function expiration(weave, opts = {}) {
const durations = { '1h': 3600000, '1d': 86400000, '7d': 604800000, '30d': SHARE_TTL_MS, '90d': 7776000000 };
if (opts.expiry === 'never') return null;
if (opts.expiry != null) {
if (!Object.hasOwn(durations, opts.expiry)) throw invalid('Choose a valid expiry');
return new Date(nowMs(weave) + durations[opts.expiry]).toISOString();
}
if (opts.expiresAt != null && opts.expiresAt !== '') {
const at = Date.parse(opts.expiresAt);
if (!Number.isFinite(at) || at <= nowMs(weave)) throw invalid('expiresAt must be a future date');
return new Date(at).toISOString();
}
return new Date(nowMs(weave) + SHARE_TTL_MS).toISOString();
}
export function mint(weave, opts = {}) {
const { scope, mode = 'read', visibility = 'public', label = '', formId, audience } = opts;
if (audience != null) throw invalid('Named audiences are not available in this beta');
const target = resolveScope(weave, scope);
if (target.kind === 'entities' && mode !== 'read') throw invalid('Selected-entry shares are read-only in this beta');
if (!SHARE_MODES.includes(mode)) throw invalid(`A share's mode is one of ${SHARE_MODES.join(', ')}; got ${JSON.stringify(mode)}`);
if (mode === 'comment') throw new WeaveError('Comment links wait for comments by share; mint read, edit or manage for now', 'forbidden');
if (!SHARE_VISIBILITIES.includes(visibility)) throw invalid(`A share's visibility is public or private; got ${JSON.stringify(visibility)}`);
const expires = expiration(weave, opts);
let submissionFormId;
if (mode === 'submit') {
const form = getForm(weave, formId);
if (target.kind !== 'table' || form.kind !== 'Row' || form.tableId !== target.id || form.owner !== weave || !form.shareOnly) throw invalid('Submission links require a share-only record form on the shared table');
submissionFormId = form.id;
}
const grant = {
id: uuid(),
token: 'wvs_' + randomBytes(24).toString('base64url'),
scope: target,
mode,
...(mode === 'submit' ? { formId: submissionFormId } : {}),
expiryVersion: 1,
visibility,
label: String(label ?? '').trim().slice(0, 120),
createdBy: weave.actor ?? null,
createdAt: new Date().toISOString(),
expiresAt: expires,
revokedAt: null,
};
(weave.state.meta.shares ??= {})[grant.id] = grant;
return publicGrant(weave, grant);
}
export function list(weave, { kind = null, id = null, includeInactive = false } = {}) {
return Object.values(weave.state.meta.shares ?? {})
.filter((g) => (includeInactive || live(weave, g)) && (!kind || g.scope.kind === kind) && (!id || g.scope.id === id))
.map((g) => publicGrant(weave, g));
}
export function byToken(weave, token) {
if (typeof token !== 'string' || !TOKEN.test(token)) return null;
const g = Object.values(weave.state.meta.shares ?? {}).find((x) => x.token === token);
return live(weave, g) ? publicGrant(weave, g) : null;
}
export function revoke(weave, id, { any = true } = {}) {
const g = own(weave.state.meta.shares, id);
if (!g) throw gone('Share', id);
if (!any && !weave.isActor(g.createdBy)) throw new WeaveError('An editor revokes the links it made; an architect revokes any', 'forbidden');
if (g.revokedAt) return { grant: publicGrant(weave, g), changed: false };
g.revokedAt = new Date().toISOString();
return { grant: publicGrant(weave, g), changed: true };
}
export function renew(weave, id) {
const g = own(weave.state.meta.shares, id);
if (!g || g.revokedAt) throw gone('Share', id);
g.expiresAt = new Date(nowMs(weave) + SHARE_TTL_MS).toISOString();
return publicGrant(weave, g);
}
export function update(weave, id, patch = {}) {
const g = own(weave.state.meta.shares, id);
if (!g || g.revokedAt) throw gone('Share', id);
if (Object.keys(patch).some(key => !['label', 'expiry', 'expiresAt'].includes(key))) throw invalid('Edit only the label or expiration; create a new link to change access');
const next = { ...g };
if ('expiry' in patch || 'expiresAt' in patch) next.expiresAt = expiration(weave, patch);
if ('label' in patch) next.label = String(patch.label ?? '').trim().slice(0, 120);
next.expiryVersion = 1;
Object.assign(g, next);
return publicGrant(weave, g);
}
export function regenerate(weave, id, patch = {}) {
const g = update(weave, id, patch);
const saved = weave.state.meta.shares[g.id];
saved.token = 'wvs_' + randomBytes(24).toString('base64url');
saved.regeneratedAt = new Date(nowMs(weave)).toISOString();
return publicGrant(weave, saved);
}
export function expireLegacy(state, now = Date.now()) {
let changed = false;
for (const g of Object.values(state.meta?.shares ?? {})) {
if (g.expiresAt != null || g.expiryVersion === 1) continue;
g.expiresAt = new Date(now + SHARE_TTL_MS).toISOString();
changed = true;
}
return changed;
}
export const auditDetail = (g) => ({ kind: g.scope.kind, scope: g.title ?? g.scope.id, mode: g.mode, visibility: g.visibility, ...(g.label ? { label: g.label } : {}) });
export function liftViewShares(state) {
let changed = false;
for (const v of Object.values(state.meta?.views ?? {})) {
if (!v.shareToken) continue;
const shares = (state.meta.shares ??= {});
if (!Object.values(shares).some((g) => g.token === v.shareToken)) {
const id = uuid();
shares[id] = {
id, token: v.shareToken, scope: { kind: 'view', id: v.id }, mode: 'read', visibility: 'public',
label: v.name ?? '', createdBy: v.createdBy ?? null, createdAt: v.createdAt ?? new Date().toISOString(),
expiresAt: null, revokedAt: null,
};
}
delete v.shareToken;
changed = true;
}
return changed;
}
export function redactTokens(out) {
for (const g of Object.values(out.meta?.shares ?? {})) delete g.token;
}
export function keepTokens(meta, prior) {
for (const g of Object.values(meta.shares ?? {})) {
const was = own(prior?.shares, g.id);
if (g.token === undefined && was?.token) g.token = was.token;
}
}
export function withinScope(weave, grant, { spaceId = null, tableId = null, entityId = null, viewId = null } = {}) {
const scope = grant?.scope;
if (!scope) return false;
let e = null;
let db = null;
let sp = null;
if (entityId != null) {
e = liveEntity(weave, entityId);
if (!e) return false;
tableId = e.dbId;
}
if (tableId != null) {
db = liveTable(weave, tableId);
if (!db) return false;
spaceId = db.spaceId;
}
if (spaceId != null) {
sp = liveSpace(weave, spaceId);
if (!sp || !grantReaches(weave, grant, sp)) return false;
}
if (viewId != null && !own(views(weave), viewId)) return false;
if (!sp && viewId == null) return false;
switch (scope.kind) {
case 'workspace': return scope.id === weave.state.meta.id;
case 'space': return !!sp && sp.id === scope.id;
case 'table': return !!db && db.id === scope.id;
case 'entities': return !!e && scope.ids.includes(e.id);
case 'entity': return !!e && e.id === scope.id;
case 'view': {
if (viewId != null) return viewId === scope.id && !db;
const v = own(views(weave), scope.id);
if (!v || !db) return false;
const blocks = (v.blocks ?? []).filter((b) => b.dbId === db.id);
if (!blocks.length) return false;
if (!e) return true;
return blocks.some((b) => !b.where?.length || weave.query(db.id, { where: b.where }).items.some((x) => x.id === e.id));
}
default: return false;
}
}
function inScopeOf(weave, grant, db) {
const { kind, id } = grant.scope;
if (kind === 'entities') return eid => grant.scope.ids.includes(eid) && !!liveEntity(weave, eid);
if (kind === 'entity') return (eid) => eid === id && !!liveEntity(weave, eid);
if (!withinScope(weave, grant, { tableId: db.id })) return () => false;
const blocks = kind === 'view' ? (own(views(weave), id)?.blocks ?? []).filter((b) => b.dbId === db.id) : [];
if (kind !== 'view' || blocks.some((b) => !b.where?.length)) return (eid) => !!liveEntity(weave, eid);
const ids = new Set(blocks.flatMap((b) => weave.query(db.id, { where: b.where }).items.map((x) => x.id)));
return (eid) => ids.has(eid) && !!liveEntity(weave, eid);
}
const touches = (weave, grant, db) => withinScope(weave, grant, { tableId: db.id })
|| (grant.scope.kind === 'entities' && grant.scope.ids.some(id => liveEntity(weave, id)?.dbId === db.id))
|| (grant.scope.kind === 'entity' && liveEntity(weave, grant.scope.id)?.dbId === db.id);
export function shareDoor(path, authz) {
const m = path.match(/^\/s\/(wv[sv]_[A-Za-z0-9_-]+)(\/.*)?$/);
if (m) return { token: m[1], rest: m[2] ?? '', page: !(m[2] ?? '').startsWith('/api/') };
if (authz && /^Bearer\s+wvs_/i.test(authz)) {
return { token: authz.replace(/^Bearer\s+/i, '').trim(), rest: path, page: !path.startsWith('/api/') };
}
return null;
}
const answer = (status, data) => ({ status, data });
const refuse = (status, error) => answer(status, { error, code: status === 404 ? 'not-found' : 'forbidden' });
const missing = () => refuse(404, 'Not found');
const OUTSIDE = 'A share link reaches the records, documents and files in its scope, nothing else';
const OMIT_SELECTED = new Set(['relation', 'lookup', 'rollup', 'formula', 'view', 'field']);
export function sharedRead(weave, grant, entity) {
const row = typeof entity === 'string' ? weave.readEntity(entity) : entity;
if (grant?.scope.kind !== 'entities') return row;
const db = weave.getTable(row.dbId);
const allowed = new Set(Object.values(db.fields).filter(field => !OMIT_SELECTED.has(field.type)).map(field => field.name));
const fields = Object.fromEntries(Object.entries(row.fields ?? {}).filter(([name]) => allowed.has(name)));
const raw = Object.fromEntries(Object.entries(row.raw ?? {}).filter(([name]) => allowed.has(name)));
const docs = Object.fromEntries(Object.entries(row.docs ?? {}).filter(([name]) => allowed.has(name)));
return {
id: row.id, publicId: row.publicId, db: row.db, dbId: row.dbId,
name: allowed.has(db.fields[db.nameFieldId]?.name) ? row.name : `#${row.publicId}`,
fields, raw, docs, doc: allowed.has(row.docField) ? row.doc ?? '' : '', docField: allowed.has(row.docField) ? row.docField : null,
createdAt: row.createdAt, updatedAt: row.updatedAt,
notice: 'Related and computed fields are omitted from selected-entry shares.',
};
}
function selectedQueryAllowed(weave, db, opts) {
const direct = name => typeof name === 'string' && !name.includes('.') && Object.values(db.fields).some(field => field.name === name && !OMIT_SELECTED.has(field.type));
const where = node => {
if (node == null) return true;
if (Array.isArray(node)) return node.length === 0 || (typeof node[0] === 'string' ? direct(node[0]) : node.every(where));
if (typeof node !== 'object') return false;
return Object.entries(node).every(([key, value]) => ['and', 'or'].includes(key) && Array.isArray(value) && value.every(where));
};
return !opts.view && !opts.groupBy && !opts.select && !opts.search
&& (!opts.fields || (Array.isArray(opts.fields) && opts.fields.every(direct)))
&& where(opts.where)
&& (!opts.sort || (Array.isArray(opts.sort) && opts.sort.every(part => direct(typeof part === 'string' ? part : part?.field))));
}
export function shareGate(weave, grant, { method, path, body = {}, searchParams = null, viewerZone = null }) {
if (grant.mode === 'submit') return refuse(403, 'A submission link only accepts its form');
const rung = grant.scope.kind === 'entities' ? RUNG.read : RUNG[grant.mode] ?? 0;
const read = method === 'GET' || method === 'HEAD';
const decode = (s) => { try { return decodeURIComponent(s); } catch { return s; } };
const entityIn = (ref) => {
try {
const e = weave.getEntity(decode(ref));
return withinScope(weave, grant, { entityId: e.id }) ? e : null;
} catch { return null; }
};
const tableOf = (ref) => { try { return weave.getTable(decode(ref)); } catch { return null; } };
let m;
if (path === '/api/share' && read) return null;
if (path === '/api/markdown' && method === 'POST') return null;
if (path === '/api/tables' && read) {
return answer(200, weave.listTables().filter((db) => touches(weave, grant, db))
.map((db) => ({ id: db.id, name: db.name, qualified: weave.qualifiedName(db), spaceId: db.spaceId })));
}
if (path === '/api/spaces' && read) {
return answer(200, weave.listSpaces().filter((sp) => weave.listTables(sp.id).some((db) => touches(weave, grant, db))));
}
if ((m = path.match(/^\/api\/tables\/([^/]+)$/)) && read) {
const db = tableOf(m[1]);
if (!db || !touches(weave, grant, db)) return missing();
if (grant.scope.kind === 'entities') {
const fields = Object.fromEntries(Object.entries(db.fields).filter(([, field]) => !OMIT_SELECTED.has(field.type)));
return answer(200, { id: db.id, name: db.name, spaceId: db.spaceId, fields, fieldOrder: db.fieldOrder.filter(id => fields[id]), nameFieldId: fields[db.nameFieldId] ? db.nameFieldId : null });
}
return null;
}
if ((m = path.match(/^\/api\/tables\/([^/]+)\/(entities|query)$/))) {
const db = tableOf(m[1]);
if (!db || !touches(weave, grant, db)) return missing();
if (m[2] === 'entities' && method === 'POST') {
if (!withinScope(weave, grant, { tableId: db.id })) return refuse(403, 'This share link reaches one record; it cannot add records beside it');
return rung >= RUNG.manage ? null : refuse(403, `A ${grant.mode} link cannot add records; that takes manage`);
}
if (!(m[2] === 'entities' ? read : method === 'POST')) return refuse(403, OUTSIDE);
const opts = m[2] === 'query'
? { ...(body ?? {}), includeDeleted: false, viewerZone }
: {
limit: searchParams?.has('limit') ? Number(searchParams.get('limit')) : null,
offset: Number(searchParams?.get('offset') ?? 0),
viewerZone,
};
const narrowed = ['entity', 'entities', 'view'].includes(grant.scope.kind);
if (grant.scope.kind === 'entities' && !selectedQueryAllowed(weave, db, opts)) return refuse(403, 'Selected-entry queries use only direct fields, without related search, saved views or projections');
if (narrowed && (opts.groupBy || opts.select)) return refuse(403, 'A selected-entry share does not expose grouped counts or projections');
const res = weave.query(db.id, narrowed ? { ...opts, relations: 'full', countAll: false, trashCount: false, limit: null, offset: 0 } : opts);
const inScope = inScopeOf(weave, grant, db);
const matched = (res.items ?? []).filter((x) => inScope(x.id)).map(row => sharedRead(weave, grant, row));
const offset = narrowed ? Math.max(0, Number(opts.offset) || 0) : 0;
const items = narrowed ? matched.slice(offset, opts.limit == null ? undefined : offset + Math.max(0, Number(opts.limit) || 0)) : matched;
return answer(200, narrowed ? { items, total: matched.length } : { ...res, items });
}
if ((m = path.match(/^\/api\/files\/([^/]+)$/)) && read) {
const owner = Object.values(weave.state.entities).find((e) => (e.files ?? []).some((f) => f.id === m[1]));
return owner && withinScope(weave, grant, { entityId: owner.id }) ? null : missing();
}
if ((m = path.match(/^\/api\/entities\/([^/]+)(?:\/(doc|state|link|unlink|comments(?:\/[^/]+)?))?$/))) {
const tail = m[2] ?? '';
if (tail.startsWith('comments')) return refuse(403, 'Comments by share link are not built yet');
if (!entityIn(m[1])) return missing();
if (read && tail === '' && grant.scope.kind === 'entities') return answer(200, sharedRead(weave, grant, entityIn(m[1]).id));
if (read && (tail === '' || tail === 'doc')) return null;
if (method === 'DELETE' && tail === '') {
if (rung < RUNG.manage) return refuse(403, `A ${grant.mode} link cannot delete records; that takes manage`);
return searchParams?.get('hard') ? refuse(403, 'A share link moves records to the trash; it never purges them') : null;
}
const writes = (tail === '' && method === 'PATCH') || (tail === 'doc' && ['PUT', 'POST'].includes(method))
|| (['state', 'link', 'unlink'].includes(tail) && method === 'POST');
if (!writes) return refuse(403, OUTSIDE);
return rung >= RUNG.edit ? null : refuse(403, `A ${grant.mode} link cannot change records; that takes edit`);
}
return refuse(403, OUTSIDE);
}
const cellText = (v) => {
if (v == null) return '';
if (Array.isArray(v)) return v.map((x) => (x && typeof x === 'object' ? x.name ?? '' : x)).join(', ');
if (typeof v === 'object') return v.name ?? '';
return String(v);
};
const md = (s) => String(s).replace(/\|/g, '¦').replace(/[[\]]/g, (c) => (c === '[' ? '(' : ')')).replace(/\r?\n/g, ' ');
function rowsTable(weave, db, items, base, fieldIds = db.fieldOrder, grant = null) {
const cols = fieldIds.map((id) => db.fields[id]).filter((f) => f && f.type !== 'document' && f.type !== 'view' && !(grant?.scope.kind === 'entities' && OMIT_SELECTED.has(f.type)));
if (!items.length) return '_No records._';
const lines = [`| ${cols.map((f) => md(f.name)).join(' | ')} |`, `|${cols.map(() => '---').join('|')}|`];
for (const item of items) {
const e = sharedRead(weave, grant, item.id);
lines.push(`| ${cols.map((f) => {
const text = md(cellText(e.fields[f.name]));
return f.id === db.nameFieldId ? `[${text || `#${e.publicId}`}](${base}/e/${e.id}/entity.html)` : text;
}).join(' | ')} |`);
}
return lines.join('\n');
}
const count = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
const FOOT = '\n\n_Shared read-only from a weave workspace._';
function page(title, subtitle, markdown) {
return renderDocumentPage({ title, subtitle, markdown: markdown + FOOT });
}
const rowsIn = (weave, grant, db, where = null, { sort = null, search = '' } = {}) => {
const inScope = inScopeOf(weave, grant, db);
const opts = { ...(where?.length ? { where } : {}), ...(sort?.length ? { sort } : {}), ...(search ? { search } : {}) };
return weave.query(db.id, opts).items.filter((x) => inScope(x.id));
};
const defaultViewOf = (db) => (Array.isArray(db.tableViews) ? db.tableViews[0] ?? null : null);
const filterWhere = (weave, db, filters) => Object.entries(filters ?? {}).flatMap(([name, states]) => {
const f = weave.findField(db, name);
if (!f || !Array.isArray(states) || !states.length) return [];
if (f.type === 'toggle') return [[f.name, 'in', states.map((s) => s === f.config?.on)]];
return [[f.name, 'in', states]];
});
export function sharePage(weave, grant, rest, { method, prefix = '', searchParams = new URLSearchParams() }) {
if (method !== 'GET' && method !== 'HEAD') return { status: 405 };
const base = `${prefix}/s/${grant.token}`;
const { kind, id } = grant.scope;
if (kind === 'entities' && (rest === '' || rest === '/')) {
const tables = weave.listTables().filter(db => touches(weave, grant, db));
return { html: page('Shared entries', `${grant.scope.ids.length} selected entries`, tables.map(db => rowsTable(weave, db, rowsIn(weave, grant, db), base, db.fieldOrder, grant)).join('\n\n') + '\n\n_Related and computed fields are omitted from selected-entry shares._') };
}
if (rest === '' || rest === '/') {
const to = { entity: `/e/${id}/entity.html`, table: `/table/${id}`, space: `/space/${id}`, view: `/view/${id}`, workspace: '/workspace' }[kind];
return { redirect: base + to };
}
let m;
if ((m = rest.match(/^\/e\/([^/]+)\/((?:doc(?:\/[^/]+)?|entity)\.(?:md|mmd|html|pdf)|deck\.html)$/))) {
let e;
try { e = weave.getEntity(decodeURIComponent(m[1])); } catch { return { status: 404 }; }
return withinScope(weave, grant, { entityId: e.id }) ? { path: `/e/${e.id}/${m[2]}` } : { status: 404 };
}
if ((m = rest.match(/^\/table\/([^/]+)$/))) {
const db = liveTable(weave, m[1]);
if (!db || !withinScope(weave, grant, { tableId: db.id })) return { status: 404 };
const v = (db.tableViews ?? []).find(view => view.id === searchParams.get('view')) ?? defaultViewOf(db);
const direction = searchParams.get('sort');
const sort = ['asc', 'desc'].includes(direction) ? [{ field: db.fields[db.nameFieldId].name, dir: direction }] : v?.sort;
const search = searchParams.has('search') ? searchParams.get('search').slice(0, 500) : v?.search;
const items = rowsIn(weave, grant, db, filterWhere(weave, db, v?.filters), { sort, search });
const space = weave.state.spaces[db.spaceId]?.name;
const controls = `<section aria-label="Temporary view controls"><label for="shared-view">Saved view</label> <select id="shared-view">${(db.tableViews ?? []).map(view => `<option value="${escapeHtml(view.id)}"${view.id === v?.id ? ' selected' : ''}>${escapeHtml(view.name)}</option>`).join('')}</select> <label for="shared-search">Search</label> <input id="shared-search" type="search" maxlength="500" value="${escapeHtml(search ?? '')}"> <label for="shared-sort">Sort</label> <select id="shared-sort"><option value="">Saved sort</option><option value="asc"${direction === 'asc' ? ' selected' : ''}>Name A–Z</option><option value="desc"${direction === 'desc' ? ' selected' : ''}>Name Z–A</option></select> <button id="shared-apply" type="button">Apply</button> <button id="shared-reset" type="button">Reset</button><p>Search and sort are temporary. Changes are never saved.</p></section><script>const go=(reset,view)=>{const u=new URL(location.href);u.search='';if(view)u.searchParams.set('view',view);if(!reset){u.searchParams.set('search',document.getElementById('shared-search').value);u.searchParams.set('sort',document.getElementById('shared-sort').value)}location.assign(u.href)};document.getElementById('shared-view').onchange=e=>go(true,e.target.value);document.getElementById('shared-apply').onclick=()=>go(false,document.getElementById('shared-view').value);document.getElementById('shared-reset').onclick=()=>go(true,document.getElementById('shared-view').value);document.getElementById('shared-search').onkeydown=e=>{if(e.key==='Enter')document.getElementById('shared-apply').click()};</script>`;
return { html: page(db.name, `${space} • ${count(items.length, 'record')}`, rowsTable(weave, db, items, base, v?.fields ?? db.fieldOrder)).replace('<body>', `<body>${controls}`) };
}
if ((m = rest.match(/^\/space\/([^/]+)$/))) {
const sp = liveSpace(weave, m[1]);
if (!sp || !withinScope(weave, grant, { spaceId: sp.id })) return { status: 404 };
const tables = weave.listTables(sp.id).filter((db) => withinScope(weave, grant, { tableId: db.id }));
const lines = tables.map((db) => `- [${md(db.name)}](${base}/table/${db.id}) · ${count(rowsIn(weave, grant, db).length, 'record')}`);
return { html: page(sp.name, count(tables.length, 'table'), lines.join('\n') || '_No tables._') };
}
if ((m = rest.match(/^\/view\/([^/]+)$/))) {
if (!withinScope(weave, grant, { viewId: m[1] })) return { status: 404 };
const v = own(views(weave), m[1]);
const blocks = (v.blocks ?? []).map((b) => {
const db = liveTable(weave, b.dbId);
if (!db) return '## (deleted table)\n\n_No records._';
return `## ${md(weave.qualifiedName(db))}\n\n${rowsTable(weave, db, rowsIn(weave, grant, db, b.where), base)}`;
});
return { html: page(v.name, count(blocks.length, 'block'), blocks.join('\n\n')) };
}
if (rest === '/workspace' && kind === 'workspace') {
const spaces = weave.listSpaces().filter((sp) => liveSpace(weave, sp.id));
const lines = spaces.map((sp) => `- [${md(sp.name)}](${base}/space/${sp.id}) · ${count(weave.listTables(sp.id).filter((db) => liveTable(weave, db.id)).length, 'table')}`);
const ws = weave.state.meta;
return { html: page(ws.title ?? ws.name ?? 'workspace', count(spaces.length, 'space'), lines.join('\n') || '_No spaces._') };
}
return { status: 404 };
}
export function scopedMention(weave, grant, plain, base) {
return (kind, ref) => {
try {
if (kind === 'workspace') return grant.scope.kind === 'workspace' ? { ...plain(kind, ref), href: `${base}/workspace` } : null;
if (kind === 'space') {
const sp = weave.findSpace(ref);
return sp && withinScope(weave, grant, { spaceId: sp.id }) ? { ...plain(kind, ref), href: `${base}/space/${sp.id}` } : null;
}
if (kind === 'table') {
const db = weave.findTable(ref);
return db && withinScope(weave, grant, { tableId: db.id }) ? { ...plain(kind, ref), href: `${base}/table/${db.id}` } : null;
}
const e = weave.getEntity(ref);
if (!withinScope(weave, grant, { entityId: e.id })) return null;
const hit = plain(kind, ref);
if (!hit) return null;
if (grant.scope.kind === 'entities') {
const name = sharedRead(weave, grant, e.id).name;
return { label: name, name, href: `${base}/e/${e.id}/doc.html` };
}
return { ...hit, href: `${base}/e/${e.id}/doc.html` };
} catch {
return null;
}
};
}