@@ -1684,7 +1684,9 @@ export function isPublicAudienceRead(
16841684 * Uniform, for the reason the datasource admin door gives for gating even its
16851685 * static driver catalog: a family whose floor has one hole has to be read
16861686 * route by route. Writes are not judged here — the save door keeps its own
1687- * admission (`metaWriteCapabilityVerdict`).
1687+ * admission (`metaWriteCapabilityVerdict`), and a write of a type whose own
1688+ * write door requires more is judged by the twin below
1689+ * ({@link metaTypeWriteRefusal}, #21124).
16881690 *
16891691 * Both transports ask {@link metaTypeReadRefusal} at their single `/meta`
16901692 * entry, right after the anonymous deny: `RestServer`'s guarded registrar
@@ -1761,6 +1763,110 @@ export function metaTypeReadRefusal(
17611763 } ;
17621764}
17631765
1766+ // ── The type-level write capability ───────────────────────────────────────────
1767+
1768+ /**
1769+ * [#21124] The metadata types whose `/meta` WRITES require a platform
1770+ * capability beyond the authoring admission every write door already asks
1771+ * (`metaWriteCapabilityVerdict`) — the write-side twin of
1772+ * {@link META_TYPE_READ_CAPABILITIES}, judged the same way: per TYPE, once, at
1773+ * each transport's `/meta` entry, before any handler resolves the protocol or
1774+ * writes the store; never per document.
1775+ *
1776+ * ## The row, and why the capability
1777+ *
1778+ * - `datasource` → `manage_platform_settings`. The datasource family's own
1779+ * write doors — `POST /api/v1/datasources`, `PATCH` and `DELETE
1780+ * /api/v1/datasources/:name` (`admin-routes.ts` in
1781+ * `@objectstack/service-datasource`, `DATASOURCE_ADMIN_CAPABILITY`) —
1782+ * create, update and remove the same stored definition a `/meta` write of
1783+ * this type persists, and admit only this capability. One operation reached
1784+ * through two mounted doors cannot admit two different sets of callers.
1785+ *
1786+ * ## Why `external_catalog` has no row here
1787+ *
1788+ * Its READ row exists because its read door requires
1789+ * `manage_platform_settings`. Its WRITE door — `POST
1790+ * /datasources/:name/external/refresh-catalog` — requires
1791+ * `FEDERATION_WRITE_CAPABILITY` (`manage_metadata`, in
1792+ * `./external-datasource-routes.ts`), which every `/meta` write door already
1793+ * demands. ⛔ A row here names the capability the type's own WRITE door
1794+ * requires — matched, never minted; a type whose write door asks nothing beyond
1795+ * the authoring admission has no row.
1796+ *
1797+ * ## Which requests
1798+ *
1799+ * Every write verb — `PUT`, `POST`, `PATCH`, `DELETE` — whose `:type` segment
1800+ * folds to a listed type, on every route shape: the item save in each of its
1801+ * modes (a draft included), the reset, `/publish` and `/rollback`, and any
1802+ * write verb a transport answers on a read route. A route with no `:type`
1803+ * segment (`POST /meta/_migrate-stored`) is not judged here. The authoring
1804+ * admission still runs after this one: a holder of the capability is also
1805+ * asked for `manage_metadata` by the door itself, as before.
1806+ */
1807+ export const META_TYPE_WRITE_CAPABILITIES : Readonly < Record < string , string > > = Object . freeze ( {
1808+ datasource : 'manage_platform_settings' ,
1809+ } ) ;
1810+
1811+ /** The verbs {@link metaTypeWriteRefusal} judges. */
1812+ const META_WRITE_VERBS : ReadonlySet < string > = new Set ( [ 'PUT' , 'POST' , 'PATCH' , 'DELETE' ] ) ;
1813+
1814+ /**
1815+ * [#21124] Why a `/meta` write of a {@link META_TYPE_WRITE_CAPABILITIES} type is
1816+ * not taken — the same two arms, envelope and codes as
1817+ * {@link MetaTypeReadRefusal}.
1818+ */
1819+ export type MetaTypeWriteRefusal = MetaTypeReadRefusal ;
1820+
1821+ /**
1822+ * [#21124] THE type-level write admission of the `/meta` surface — see
1823+ * {@link META_TYPE_WRITE_CAPABILITIES} for the row and the reason.
1824+ *
1825+ * Same inputs and same folding as {@link metaTypeReadRefusal}: `method` the
1826+ * request's verb, `type` the RAW `:type` segment (folded here through
1827+ * `canonicalMetaUrlType` and `pluralToSingular`, so `/meta/datasources`
1828+ * cannot fall outside the row `/meta/datasource` is in), `caller` the
1829+ * request's resolved execution context.
1830+ *
1831+ * Answers `undefined` — go on, nothing sent — for a read verb, for an unlisted
1832+ * type, and for a caller whose resolved `systemPermissions` hold the row's
1833+ * capability. Otherwise the refusal, decided before the door resolves the
1834+ * protocol, so nothing is written and the answer is the same whether or not
1835+ * the named item exists. The message names the capability and nothing else.
1836+ *
1837+ * ⛔ No `isSystem` arm, for the reason {@link metaTypeReadRefusal} gives:
1838+ * inbound HTTP never carries `isSystem`, and the datasource admin door reads
1839+ * the held set alone.
1840+ */
1841+ export function metaTypeWriteRefusal (
1842+ method : unknown ,
1843+ type : unknown ,
1844+ caller : unknown ,
1845+ ) : MetaTypeWriteRefusal | undefined {
1846+ const verb = String ( method ?? '' ) . toUpperCase ( ) ;
1847+ if ( ! META_WRITE_VERBS . has ( verb ) ) return undefined ;
1848+ if ( typeof type !== 'string' || type . length === 0 ) return undefined ;
1849+ const canonical = canonicalMetaUrlType ( type ) ;
1850+ const folded = Object . prototype . hasOwnProperty . call ( META_TYPE_WRITE_CAPABILITIES , canonical )
1851+ ? canonical
1852+ : pluralToSingular ( type ) ;
1853+ if ( ! Object . prototype . hasOwnProperty . call ( META_TYPE_WRITE_CAPABILITIES , folded ) ) return undefined ;
1854+ const capability = META_TYPE_WRITE_CAPABILITIES [ folded ] ;
1855+ const ctx = caller && typeof caller === 'object'
1856+ ? caller as { userId ?: unknown ; systemPermissions ?: unknown }
1857+ : undefined ;
1858+ if ( ! ctx ?. userId ) {
1859+ return { status : ANONYMOUS_DENY_STATUS , code : ANONYMOUS_DENY_CODE , message : ANONYMOUS_DENY_MESSAGE } ;
1860+ }
1861+ const held = Array . isArray ( ctx . systemPermissions ) ? ctx . systemPermissions : [ ] ;
1862+ if ( held . includes ( capability ) ) return undefined ;
1863+ return {
1864+ status : 403 ,
1865+ code : 'PERMISSION_DENIED' ,
1866+ message : `Writing ${ folded } metadata requires the \`${ capability } \` capability.` ,
1867+ } ;
1868+ }
1869+
17641870// ── The list route's unknown-type refusal ─────────────────────────────────────
17651871
17661872/**
0 commit comments