refactor: 프론트엔드 리펙토링

This commit is contained in:
donghyeon-ka
2026-09-18 22:05:42 +09:00
parent 5cc41467ae
commit ec7f20e2ee
100 changed files with 6005 additions and 2867 deletions
@@ -0,0 +1,224 @@
body, html {
margin:0; padding: 0;
height: 100%;
}
body {
font-family: Helvetica Neue, Helvetica, Arial;
font-size: 14px;
color:#333;
}
.small { font-size: 12px; }
*, *:after, *:before {
-webkit-box-sizing:border-box;
-moz-box-sizing:border-box;
box-sizing:border-box;
}
h1 { font-size: 20px; margin: 0;}
h2 { font-size: 14px; }
pre {
font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
margin: 0;
padding: 0;
-moz-tab-size: 2;
-o-tab-size: 2;
tab-size: 2;
}
a { color:#0074D9; text-decoration:none; }
a:hover { text-decoration:underline; }
.strong { font-weight: bold; }
.space-top1 { padding: 10px 0 0 0; }
.pad2y { padding: 20px 0; }
.pad1y { padding: 10px 0; }
.pad2x { padding: 0 20px; }
.pad2 { padding: 20px; }
.pad1 { padding: 10px; }
.space-left2 { padding-left:55px; }
.space-right2 { padding-right:20px; }
.center { text-align:center; }
.clearfix { display:block; }
.clearfix:after {
content:'';
display:block;
height:0;
clear:both;
visibility:hidden;
}
.fl { float: left; }
@media only screen and (max-width:640px) {
.col3 { width:100%; max-width:100%; }
.hide-mobile { display:none!important; }
}
.quiet {
color: #7f7f7f;
color: rgba(0,0,0,0.5);
}
.quiet a { opacity: 0.7; }
.fraction {
font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 10px;
color: #555;
background: #E8E8E8;
padding: 4px 5px;
border-radius: 3px;
vertical-align: middle;
}
div.path a:link, div.path a:visited { color: #333; }
table.coverage {
border-collapse: collapse;
margin: 10px 0 0 0;
padding: 0;
}
table.coverage td {
margin: 0;
padding: 0;
vertical-align: top;
}
table.coverage td.line-count {
text-align: right;
padding: 0 5px 0 20px;
}
table.coverage td.line-coverage {
text-align: right;
padding-right: 10px;
min-width:20px;
}
table.coverage td span.cline-any {
display: inline-block;
padding: 0 5px;
width: 100%;
}
.missing-if-branch {
display: inline-block;
margin-right: 5px;
border-radius: 3px;
position: relative;
padding: 0 4px;
background: #333;
color: yellow;
}
.skip-if-branch {
display: none;
margin-right: 10px;
position: relative;
padding: 0 4px;
background: #ccc;
color: white;
}
.missing-if-branch .typ, .skip-if-branch .typ {
color: inherit !important;
}
.coverage-summary {
border-collapse: collapse;
width: 100%;
}
.coverage-summary tr { border-bottom: 1px solid #bbb; }
.keyline-all { border: 1px solid #ddd; }
.coverage-summary td, .coverage-summary th { padding: 10px; }
.coverage-summary tbody { border: 1px solid #bbb; }
.coverage-summary td { border-right: 1px solid #bbb; }
.coverage-summary td:last-child { border-right: none; }
.coverage-summary th {
text-align: left;
font-weight: normal;
white-space: nowrap;
}
.coverage-summary th.file { border-right: none !important; }
.coverage-summary th.pct { }
.coverage-summary th.pic,
.coverage-summary th.abs,
.coverage-summary td.pct,
.coverage-summary td.abs { text-align: right; }
.coverage-summary td.file { white-space: nowrap; }
.coverage-summary td.pic { min-width: 120px !important; }
.coverage-summary tfoot td { }
.coverage-summary .sorter {
height: 10px;
width: 7px;
display: inline-block;
margin-left: 0.5em;
background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
}
.coverage-summary .sorted .sorter {
background-position: 0 -20px;
}
.coverage-summary .sorted-desc .sorter {
background-position: 0 -10px;
}
.status-line { height: 10px; }
/* yellow */
.cbranch-no { background: yellow !important; color: #111; }
/* dark red */
.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
.low .chart { border:1px solid #C21F39 }
.highlighted,
.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
background: #C21F39 !important;
}
/* medium red */
.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
/* light red */
.low, .cline-no { background:#FCE1E5 }
/* light green */
.high, .cline-yes { background:rgb(230,245,208) }
/* medium green */
.cstat-yes { background:rgb(161,215,106) }
/* dark green */
.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
.high .chart { border:1px solid rgb(77,146,33) }
/* dark yellow (gold) */
.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
.medium .chart { border:1px solid #f9cd0b; }
/* light yellow */
.medium { background: #fff4c2; }
.cstat-skip { background: #ddd; color: #111; }
.fstat-skip { background: #ddd; color: #111 !important; }
.cbranch-skip { background: #ddd !important; color: #111; }
span.cline-neutral { background: #eaeaea; }
.coverage-summary td.empty {
opacity: .5;
padding-top: 4px;
padding-bottom: 4px;
line-height: 1;
color: #888;
}
.cover-fill, .cover-empty {
display:inline-block;
height: 12px;
}
.chart {
line-height: 0;
}
.cover-empty {
background: white;
}
.cover-full {
border-right: none !important;
}
pre.prettyprint {
border: none !important;
padding: 0 !important;
margin: 0 !important;
}
.com { color: #999 !important; }
.ignore-none { color: #999; font-weight: normal; }
.wrapper {
min-height: 100%;
height: auto !important;
height: 100%;
margin: 0 auto -48px;
}
.footer, .push {
height: 48px;
}
@@ -0,0 +1,87 @@
/* eslint-disable */
var jumpToCode = (function init() {
// Classes of code we would like to highlight in the file view
var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
// Elements to highlight in the file listing view
var fileListingElements = ['td.pct.low'];
// We don't want to select elements that are direct descendants of another match
var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
// Selector that finds elements on the page to which we can jump
var selector =
fileListingElements.join(', ') +
', ' +
notSelector +
missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
// The NodeList of matching elements
var missingCoverageElements = document.querySelectorAll(selector);
var currentIndex;
function toggleClass(index) {
missingCoverageElements
.item(currentIndex)
.classList.remove('highlighted');
missingCoverageElements.item(index).classList.add('highlighted');
}
function makeCurrent(index) {
toggleClass(index);
currentIndex = index;
missingCoverageElements.item(index).scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
});
}
function goToPrevious() {
var nextIndex = 0;
if (typeof currentIndex !== 'number' || currentIndex === 0) {
nextIndex = missingCoverageElements.length - 1;
} else if (missingCoverageElements.length > 1) {
nextIndex = currentIndex - 1;
}
makeCurrent(nextIndex);
}
function goToNext() {
var nextIndex = 0;
if (
typeof currentIndex === 'number' &&
currentIndex < missingCoverageElements.length - 1
) {
nextIndex = currentIndex + 1;
}
makeCurrent(nextIndex);
}
return function jump(event) {
if (
document.getElementById('fileSearch') === document.activeElement &&
document.activeElement != null
) {
// if we're currently focused on the search input, we don't want to navigate
return;
}
switch (event.which) {
case 78: // n
case 74: // j
goToNext();
break;
case 66: // b
case 75: // k
case 80: // p
goToPrevious();
break;
}
};
})();
window.addEventListener('keydown', jumpToCode);
Binary file not shown.

After

Width:  |  Height:  |  Size: 445 B

@@ -0,0 +1,116 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for All files</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="prettify.css" />
<link rel="stylesheet" href="base.css" />
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1>All files</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Statements</span>
<span class='fraction'>11/11</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Branches</span>
<span class='fraction'>8/8</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>5/5</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Lines</span>
<span class='fraction'>11/11</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line high'></div>
<div class="pad1">
<table class="coverage-summary">
<thead>
<tr>
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
</tr>
</thead>
<tbody><tr>
<td class="file high" data-value="reference-http-gateway.ts"><a href="reference-http-gateway.ts.html">reference-http-gateway.ts</a></td>
<td data-value="100" class="pic high">
<div class="chart"><div class="cover-fill cover-full" style="width: 100%"></div><div class="cover-empty" style="width: 0%"></div></div>
</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="11" class="abs high">11/11</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="8" class="abs high">8/8</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="5" class="abs high">5/5</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="11" class="abs high">11/11</td>
</tr>
</tbody>
</table>
</div>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-09-18T11:39:00.427Z
</div>
<script src="prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="sorter.js"></script>
<script src="block-navigation.js"></script>
</body>
</html>
@@ -0,0 +1 @@
.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,331 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for reference-http-gateway.ts</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="prettify.css" />
<link rel="stylesheet" href="base.css" />
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="index.html">All files</a> reference-http-gateway.ts</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Statements</span>
<span class='fraction'>11/11</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Branches</span>
<span class='fraction'>8/8</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>5/5</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Lines</span>
<span class='fraction'>11/11</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line high'></div>
<pre><table class="coverage">
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
<a name='L2'></a><a href='#L2'>2</a>
<a name='L3'></a><a href='#L3'>3</a>
<a name='L4'></a><a href='#L4'>4</a>
<a name='L5'></a><a href='#L5'>5</a>
<a name='L6'></a><a href='#L6'>6</a>
<a name='L7'></a><a href='#L7'>7</a>
<a name='L8'></a><a href='#L8'>8</a>
<a name='L9'></a><a href='#L9'>9</a>
<a name='L10'></a><a href='#L10'>10</a>
<a name='L11'></a><a href='#L11'>11</a>
<a name='L12'></a><a href='#L12'>12</a>
<a name='L13'></a><a href='#L13'>13</a>
<a name='L14'></a><a href='#L14'>14</a>
<a name='L15'></a><a href='#L15'>15</a>
<a name='L16'></a><a href='#L16'>16</a>
<a name='L17'></a><a href='#L17'>17</a>
<a name='L18'></a><a href='#L18'>18</a>
<a name='L19'></a><a href='#L19'>19</a>
<a name='L20'></a><a href='#L20'>20</a>
<a name='L21'></a><a href='#L21'>21</a>
<a name='L22'></a><a href='#L22'>22</a>
<a name='L23'></a><a href='#L23'>23</a>
<a name='L24'></a><a href='#L24'>24</a>
<a name='L25'></a><a href='#L25'>25</a>
<a name='L26'></a><a href='#L26'>26</a>
<a name='L27'></a><a href='#L27'>27</a>
<a name='L28'></a><a href='#L28'>28</a>
<a name='L29'></a><a href='#L29'>29</a>
<a name='L30'></a><a href='#L30'>30</a>
<a name='L31'></a><a href='#L31'>31</a>
<a name='L32'></a><a href='#L32'>32</a>
<a name='L33'></a><a href='#L33'>33</a>
<a name='L34'></a><a href='#L34'>34</a>
<a name='L35'></a><a href='#L35'>35</a>
<a name='L36'></a><a href='#L36'>36</a>
<a name='L37'></a><a href='#L37'>37</a>
<a name='L38'></a><a href='#L38'>38</a>
<a name='L39'></a><a href='#L39'>39</a>
<a name='L40'></a><a href='#L40'>40</a>
<a name='L41'></a><a href='#L41'>41</a>
<a name='L42'></a><a href='#L42'>42</a>
<a name='L43'></a><a href='#L43'>43</a>
<a name='L44'></a><a href='#L44'>44</a>
<a name='L45'></a><a href='#L45'>45</a>
<a name='L46'></a><a href='#L46'>46</a>
<a name='L47'></a><a href='#L47'>47</a>
<a name='L48'></a><a href='#L48'>48</a>
<a name='L49'></a><a href='#L49'>49</a>
<a name='L50'></a><a href='#L50'>50</a>
<a name='L51'></a><a href='#L51'>51</a>
<a name='L52'></a><a href='#L52'>52</a>
<a name='L53'></a><a href='#L53'>53</a>
<a name='L54'></a><a href='#L54'>54</a>
<a name='L55'></a><a href='#L55'>55</a>
<a name='L56'></a><a href='#L56'>56</a>
<a name='L57'></a><a href='#L57'>57</a>
<a name='L58'></a><a href='#L58'>58</a>
<a name='L59'></a><a href='#L59'>59</a>
<a name='L60'></a><a href='#L60'>60</a>
<a name='L61'></a><a href='#L61'>61</a>
<a name='L62'></a><a href='#L62'>62</a>
<a name='L63'></a><a href='#L63'>63</a>
<a name='L64'></a><a href='#L64'>64</a>
<a name='L65'></a><a href='#L65'>65</a>
<a name='L66'></a><a href='#L66'>66</a>
<a name='L67'></a><a href='#L67'>67</a>
<a name='L68'></a><a href='#L68'>68</a>
<a name='L69'></a><a href='#L69'>69</a>
<a name='L70'></a><a href='#L70'>70</a>
<a name='L71'></a><a href='#L71'>71</a>
<a name='L72'></a><a href='#L72'>72</a>
<a name='L73'></a><a href='#L73'>73</a>
<a name='L74'></a><a href='#L74'>74</a>
<a name='L75'></a><a href='#L75'>75</a>
<a name='L76'></a><a href='#L76'>76</a>
<a name='L77'></a><a href='#L77'>77</a>
<a name='L78'></a><a href='#L78'>78</a>
<a name='L79'></a><a href='#L79'>79</a>
<a name='L80'></a><a href='#L80'>80</a>
<a name='L81'></a><a href='#L81'>81</a>
<a name='L82'></a><a href='#L82'>82</a>
<a name='L83'></a><a href='#L83'>83</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">6x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import {
defineFeatureHttpOperationForRoutes,
type FeatureHttpBinding,
} from "../../../adapters/http/index.ts";
import {
CREATE_REFERENCE_RESOURCE_CONTRACT,
GET_REFERENCE_RESOURCE_CONTRACT,
LIST_REFERENCE_RESOURCES_CONTRACT,
} from "../contracts/reference-feature-contract-contribution.ts";
import {
REFERENCE_FEATURE_CONTRACT,
} from "../contracts/reference-feature-contract.ts";
import {
mapReferenceResourceListPayload,
mapReferenceResourcePayload,
} from "../contracts/reference-mapper.ts";
import type {
ReferenceGateway,
} from "../application/reference-feature-api.ts";
&nbsp;
export type ReferenceFeatureRouteId =
keyof typeof REFERENCE_FEATURE_CONTRACT.routes;
&nbsp;
const defineReferenceHttpOperation =
defineFeatureHttpOperationForRoutes&lt;ReferenceFeatureRouteId&gt;();
&nbsp;
export const REFERENCE_HTTP_OPERATIONS = Object.freeze({
LIST_REFERENCE_RESOURCES: defineReferenceHttpOperation({
contract: LIST_REFERENCE_RESOURCES_CONTRACT,
routeId: "REFERENCE_RESOURCE_LIST",
mapSuccess: mapReferenceResourceListPayload,
}),
CREATE_REFERENCE_RESOURCE: defineReferenceHttpOperation({
contract: CREATE_REFERENCE_RESOURCE_CONTRACT,
routeId: "REFERENCE_RESOURCE_LIST",
mapSuccess: mapReferenceResourcePayload,
mapProblem(problem, metadata) {
if (metadata.status === 409) {
return Object.freeze({
kind: "CONFLICT" as const,
code: problem.code ?? "REFERENCE_RESOURCE_CONFLICT",
});
}
if (metadata.status === 422) {
return Object.freeze({
kind: "VALIDATION_REJECTED" as const,
code: problem.code ?? "REFERENCE_RESOURCE_REJECTED",
});
}
return undefined;
},
}),
GET_REFERENCE_RESOURCE: defineReferenceHttpOperation({
contract: GET_REFERENCE_RESOURCE_CONTRACT,
routeId: "REFERENCE_RESOURCE_DETAIL",
mapSuccess: mapReferenceResourcePayload,
}),
} as const);
&nbsp;
export type ReferenceHttpBinding = FeatureHttpBinding&lt;
typeof REFERENCE_HTTP_OPERATIONS
&gt;;
&nbsp;
export function createReferenceHttpGateway(
http: ReferenceHttpBinding,
): ReferenceGateway {
return Object.freeze({
list(filters, context) {
return http.execute("LIST_REFERENCE_RESOURCES", filters, context);
},
create(command, context) {
return http.execute("CREATE_REFERENCE_RESOURCE", command, context);
},
get(resourceId, context) {
return http.execute(
"GET_REFERENCE_RESOURCE",
Object.freeze({ resourceId }),
context,
);
},
});
}
&nbsp;</pre></td></tr></table></pre>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-09-18T11:39:00.427Z
</div>
<script src="prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="sorter.js"></script>
<script src="block-navigation.js"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

@@ -0,0 +1,210 @@
/* eslint-disable */
var addSorting = (function() {
'use strict';
var cols,
currentSort = {
index: 0,
desc: false
};
// returns the summary table element
function getTable() {
return document.querySelector('.coverage-summary');
}
// returns the thead element of the summary table
function getTableHeader() {
return getTable().querySelector('thead tr');
}
// returns the tbody element of the summary table
function getTableBody() {
return getTable().querySelector('tbody');
}
// returns the th element for nth column
function getNthColumn(n) {
return getTableHeader().querySelectorAll('th')[n];
}
function onFilterInput() {
const searchValue = document.getElementById('fileSearch').value;
const rows = document.getElementsByTagName('tbody')[0].children;
// Try to create a RegExp from the searchValue. If it fails (invalid regex),
// it will be treated as a plain text search
let searchRegex;
try {
searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive
} catch (error) {
searchRegex = null;
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
let isMatch = false;
if (searchRegex) {
// If a valid regex was created, use it for matching
isMatch = searchRegex.test(row.textContent);
} else {
// Otherwise, fall back to the original plain text search
isMatch = row.textContent
.toLowerCase()
.includes(searchValue.toLowerCase());
}
row.style.display = isMatch ? '' : 'none';
}
}
// loads the search box
function addSearchBox() {
var template = document.getElementById('filterTemplate');
var templateClone = template.content.cloneNode(true);
templateClone.getElementById('fileSearch').oninput = onFilterInput;
template.parentElement.appendChild(templateClone);
}
// loads all columns
function loadColumns() {
var colNodes = getTableHeader().querySelectorAll('th'),
colNode,
cols = [],
col,
i;
for (i = 0; i < colNodes.length; i += 1) {
colNode = colNodes[i];
col = {
key: colNode.getAttribute('data-col'),
sortable: !colNode.getAttribute('data-nosort'),
type: colNode.getAttribute('data-type') || 'string'
};
cols.push(col);
if (col.sortable) {
col.defaultDescSort = col.type === 'number';
colNode.innerHTML =
colNode.innerHTML + '<span class="sorter"></span>';
}
}
return cols;
}
// attaches a data attribute to every tr element with an object
// of data values keyed by column name
function loadRowData(tableRow) {
var tableCols = tableRow.querySelectorAll('td'),
colNode,
col,
data = {},
i,
val;
for (i = 0; i < tableCols.length; i += 1) {
colNode = tableCols[i];
col = cols[i];
val = colNode.getAttribute('data-value');
if (col.type === 'number') {
val = Number(val);
}
data[col.key] = val;
}
return data;
}
// loads all row data
function loadData() {
var rows = getTableBody().querySelectorAll('tr'),
i;
for (i = 0; i < rows.length; i += 1) {
rows[i].data = loadRowData(rows[i]);
}
}
// sorts the table using the data for the ith column
function sortByIndex(index, desc) {
var key = cols[index].key,
sorter = function(a, b) {
a = a.data[key];
b = b.data[key];
return a < b ? -1 : a > b ? 1 : 0;
},
finalSorter = sorter,
tableBody = document.querySelector('.coverage-summary tbody'),
rowNodes = tableBody.querySelectorAll('tr'),
rows = [],
i;
if (desc) {
finalSorter = function(a, b) {
return -1 * sorter(a, b);
};
}
for (i = 0; i < rowNodes.length; i += 1) {
rows.push(rowNodes[i]);
tableBody.removeChild(rowNodes[i]);
}
rows.sort(finalSorter);
for (i = 0; i < rows.length; i += 1) {
tableBody.appendChild(rows[i]);
}
}
// removes sort indicators for current column being sorted
function removeSortIndicators() {
var col = getNthColumn(currentSort.index),
cls = col.className;
cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, '');
col.className = cls;
}
// adds sort indicators for current column being sorted
function addSortIndicators() {
getNthColumn(currentSort.index).className += currentSort.desc
? ' sorted-desc'
: ' sorted';
}
// adds event listeners for all sorter widgets
function enableUI() {
var i,
el,
ithSorter = function ithSorter(i) {
var col = cols[i];
return function() {
var desc = col.defaultDescSort;
if (currentSort.index === i) {
desc = !currentSort.desc;
}
sortByIndex(i, desc);
removeSortIndicators();
currentSort.index = i;
currentSort.desc = desc;
addSortIndicators();
};
};
for (i = 0; i < cols.length; i += 1) {
if (cols[i].sortable) {
// add the click event handler on the th so users
// dont have to click on those tiny arrows
el = getNthColumn(i).querySelector('.sorter').parentElement;
if (el.addEventListener) {
el.addEventListener('click', ithSorter(i));
} else {
el.attachEvent('onclick', ithSorter(i));
}
}
}
}
// adds sorting functionality to the UI
return function() {
if (!getTable()) {
return;
}
cols = loadColumns();
loadData();
addSearchBox();
addSortIndicators();
enableUI();
};
})();
window.addEventListener('load', addSorting);
@@ -0,0 +1,38 @@
TN:
SF:src/features/reference-feature/adapters/reference-http-gateway.ts
FN:37,mapProblem
FN:64,createReferenceHttpGateway
FN:68,list
FN:71,create
FN:74,get
FNF:5
FNH:5
FNDA:5,mapProblem
FNDA:8,createReferenceHttpGateway
FNDA:1,list
FNDA:6,create
FNDA:3,get
DA:25,2
DA:27,2
DA:38,5
DA:39,2
DA:44,3
DA:45,2
DA:50,1
DA:67,8
DA:69,1
DA:72,6
DA:75,3
LF:11
LH:11
BRDA:38,0,0,2
BRDA:38,0,1,3
BRDA:41,1,0,2
BRDA:41,1,1,1
BRDA:44,2,0,2
BRDA:44,2,1,1
BRDA:47,3,0,2
BRDA:47,3,1,1
BRF:8
BRH:8
end_of_record
+57 -1
View File
@@ -81,6 +81,55 @@
"script": "check:types:fixture:reference-operation",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2741:"
},
{
"id": "check-types-fixture-http-operation-input",
"script": "check:types:fixture:http-operation-input",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2353:"
},
{
"id": "check-types-fixture-http-wire-mapper",
"script": "check:types:fixture:http-wire-mapper",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2322:"
},
{
"id": "check-types-fixture-http-operation-id",
"script": "check:types:fixture:http-operation-id",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2345:"
},
{
"id": "check-types-fixture-http-route-id",
"script": "check:types:fixture:http-route-id",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2322:"
},
{
"id": "check-types-fixture-feature-contribution-input",
"script": "check:types:fixture:feature-contribution-input",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2322:"
},
{
"id": "check-types-fixture-feature-capability-selection",
"script": "check:types:fixture:feature-capability-selection",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2339:"
},
{
"id": "check-types-fixture-direct-feature-composition",
"script": "check:types:fixture:direct-feature-composition",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2345:"
},
{
@@ -95,7 +144,7 @@
"script": "check:types:fixture:route-runtime",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2353:"
"expectedDiagnosticId": "error TS2740:"
},
{
"id": "check-types-fixture-page-action",
@@ -1698,6 +1747,13 @@
"check-types-fixture-feature-input",
"check-types-fixture-failure-kind",
"check-types-fixture-reference-operation",
"check-types-fixture-http-operation-input",
"check-types-fixture-http-wire-mapper",
"check-types-fixture-http-operation-id",
"check-types-fixture-http-route-id",
"check-types-fixture-feature-contribution-input",
"check-types-fixture-feature-capability-selection",
"check-types-fixture-direct-feature-composition",
"check-types-fixture-async-overlay",
"check-types-fixture-route-runtime",
"check-types-fixture-page-action",
+117 -22
View File
@@ -12,97 +12,192 @@
{
"path": "src/adapters/http/bounded-body-reader.ts",
"owner": "http-runtime",
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 90 }
"minimum": {
"lines": 95,
"statements": 95,
"functions": 95,
"branches": 90
}
},
{
"path": "src/adapters/http/bounded-json.ts",
"owner": "http-runtime",
"minimum": { "lines": 85, "statements": 84, "functions": 95, "branches": 78 }
"minimum": {
"lines": 85,
"statements": 84,
"functions": 95,
"branches": 78
}
},
{
"path": "src/adapters/http/http-execution-v3.ts",
"owner": "http-runtime",
"minimum": { "lines": 75, "statements": 73, "functions": 70, "branches": 52 }
"minimum": {
"lines": 75,
"statements": 73,
"functions": 70,
"branches": 52
}
},
{
"path": "src/adapters/http/request-builder.ts",
"owner": "http-runtime",
"minimum": { "lines": 85, "statements": 85, "functions": 95, "branches": 82 }
"minimum": {
"lines": 85,
"statements": 85,
"functions": 95,
"branches": 82
}
},
{
"path": "src/adapters/http/retry-policy.ts",
"owner": "http-runtime",
"minimum": { "lines": 80, "statements": 78, "functions": 95, "branches": 78 }
"minimum": {
"lines": 80,
"statements": 78,
"functions": 95,
"branches": 78
}
},
{
"path": "src/adapters/query-cache/server-state-scope-runtime.ts",
"owner": "server-state-runtime",
"minimum": { "lines": 85, "statements": 85, "functions": 85, "branches": 75 }
"minimum": {
"lines": 85,
"statements": 85,
"functions": 85,
"branches": 75
}
},
{
"path": "src/adapters/service-worker/service-worker-lifecycle.ts",
"owner": "service-worker-runtime",
"minimum": { "lines": 64, "statements": 60, "functions": 65, "branches": 43 }
"minimum": {
"lines": 64,
"statements": 60,
"functions": 65,
"branches": 43
}
},
{
"path": "src/adapters/storage/browser-storage-adapter.ts",
"owner": "storage-runtime",
"minimum": { "lines": 60, "statements": 60, "functions": 70, "branches": 60 }
"minimum": {
"lines": 60,
"statements": 60,
"functions": 70,
"branches": 60
}
},
{
"path": "src/adapters/telemetry/best-effort-telemetry.ts",
"owner": "telemetry-runtime",
"minimum": { "lines": 85, "statements": 85, "functions": 70, "branches": 75 }
"minimum": {
"lines": 85,
"statements": 85,
"functions": 70,
"branches": 75
}
},
{
"path": "src/application/create-application.ts",
"owner": "application-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 80, "branches": 68 }
"minimum": {
"lines": 90,
"statements": 90,
"functions": 80,
"branches": 68
}
},
{
"path": "src/application/policies/compatibility.ts",
"owner": "application-policy",
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 75 }
"path": "src/contracts/compatibility.ts",
"owner": "compatibility-contracts",
"minimum": {
"lines": 95,
"statements": 95,
"functions": 95,
"branches": 75
}
},
{
"path": "src/application/policies/performance-budgets.ts",
"owner": "application-policy",
"minimum": { "lines": 80, "statements": 80, "functions": 80, "branches": 40 }
"minimum": {
"lines": 80,
"statements": 80,
"functions": 80,
"branches": 40
}
},
{
"path": "src/application/policies/promotion-readiness.ts",
"owner": "release-runtime",
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 95 }
"minimum": {
"lines": 95,
"statements": 95,
"functions": 95,
"branches": 95
}
},
{
"path": "src/application/use-cases/decide-chunk-recovery.ts",
"owner": "application-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 95, "branches": 85 }
"minimum": {
"lines": 90,
"statements": 90,
"functions": 95,
"branches": 85
}
},
{
"path": "src/bootstrap/load-release-manifest.ts",
"owner": "release-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 }
"minimum": {
"lines": 90,
"statements": 90,
"functions": 90,
"branches": 80
}
},
{
"path": "src/bootstrap/read-bounded-boot-json.ts",
"owner": "bootstrap-runtime",
"minimum": { "lines": 71, "statements": 66, "functions": 48, "branches": 57 }
"minimum": {
"lines": 71,
"statements": 66,
"functions": 48,
"branches": 57
}
},
{
"path": "src/contracts/diagnostics.ts",
"owner": "diagnostics-contracts",
"minimum": { "lines": 68, "statements": 68, "functions": 95, "branches": 58 }
"minimum": {
"lines": 68,
"statements": 68,
"functions": 95,
"branches": 58
}
},
{
"path": "src/features/reference-feature/adapters/reference-http-gateway.ts",
"owner": "reference-feature",
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 90 }
"minimum": {
"lines": 90,
"statements": 90,
"functions": 90,
"branches": 90
}
},
{
"path": "src/presentation/adapters/query/application-query.ts",
"owner": "presentation-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 }
"minimum": {
"lines": 90,
"statements": 90,
"functions": 90,
"branches": 80
}
}
],
"highRiskPaths": [
@@ -116,7 +211,7 @@
"src/adapters/storage/browser-storage-adapter.ts",
"src/adapters/telemetry/best-effort-telemetry.ts",
"src/application/create-application.ts",
"src/application/policies/compatibility.ts",
"src/contracts/compatibility.ts",
"src/application/policies/performance-budgets.ts",
"src/application/policies/promotion-readiness.ts",
"src/application/use-cases/decide-chunk-recovery.ts",
@@ -247,6 +247,8 @@ domain, application state, query cache, global store, diagnostics에 넣지 않
### 3.2 picker baseline과 enhancement
- 접근 가능한 `<input type="file">`가 모든 browser의 canonical baseline이다.
baseline adapter는 `input.click()`으로 이 경로를 활성화한다. `showPicker()`
존재 여부는 portable baseline capability의 판정 기준으로 사용하지 않는다.
- `showOpenFilePicker()``showSaveFilePicker()`는 runtime method별 feature
detection을 거친 progressive enhancement다. UA sniffing을 사용하지 않는다.
- picker 호출은 click/keyboard handler의 첫 browser action이어야 한다. 그 전에
@@ -987,7 +989,7 @@ recipe의 공통 code는 UI/telemetry에 raw DOMException을 노출하지 않기
| `PERMISSION_DENIED` | picker/save permission | baseline/manual fallback |
| `LIMIT_EXCEEDED` | count/bytes/page/buffer budget | 입력 축소 |
| `POLICY_REJECTED` | type/cache/data policy | 저장·전송 금지 |
| `BLOCKED` | IndexedDB older context | 다른 탭 close/retry UI |
| `BLOCKED` | IndexedDB older context가 live open/upgrade를 막는 중 | 다른 탭 close/retry UI |
| `CONFLICT` | revision/generation/idempotency conflict | authoritative re-read |
| `MIGRATION_FAILED` | schema/data migration | read-only/online-only |
| `QUOTA_EXCEEDED` | actual write failure | rollback, reconstructable GC, bounded retry |
@@ -999,6 +1001,12 @@ recipe의 공통 code는 UI/telemetry에 raw DOMException을 노출하지 않기
| `UNAVAILABLE` | browser/worker/storage temporarily unavailable | documented fallback |
| `UNSUPPORTED` | capability absence | baseline/online-only |
`IndexedDbConnectionStatus.BLOCKED`는 대기 중인 live attempt의 observable
상태다. blocked deadline이 끝나 `open()`이 `BLOCKED` failure로 settle되면
그 attempt는 더 이상 열리는 중이 아니므로 status는
`CLOSED / NOT_OPENED`로 전이한다. 늦게 성공한 native connection은 즉시 닫고
settled status를 되살리지 않는다.
user dismissal은 failure가 아니라 outcome이다. browser DOMException name은
adapter에서 이 vocabulary로 mapping하고 raw message/stack은 local bounded
diagnostic에도 기본 저장하지 않는다.
@@ -1085,18 +1093,17 @@ Playwright Chromium, Firefox, WebKit에서 실제 secure-origin API를 검사한
`test:browser-capabilities`의 JUnit을
`verify:browser-capability-evidence`가 읽어 세 engine의 testcase 집합 동일성,
양수 실행 수, zero failure/error/skipped와 skipped/failure node 부재를 강제한다.
현재 checkout의 source suite는 engine마다 정확히 같은 14개 case(File 2,
IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2,
presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다.
promotion artifact는 Chromium/Firefox/WebKit의 14개씩, 총 42개가 모두
실행되어야 한다. 이 host의
WebKit은 필수 native libraries(예:
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았고 현재 보존 artifact도
Chromium/Firefox 14개씩 총 28개만 통과한 상태다. 따라서 promotion evidence를
충족하지 않으며 verifier가 실패하는 것이 정상이다. `INSTALLED` 전에는 필요한
system dependency가 있는 CI/device에서 세 engine 전체 evidence를 새로 생성해야
한다.
browser capability promotion은 source suite에서 관찰된 동일 testcase set을
Chromium/Firefox/WebKit에서 모두 실행하는 규칙으로 관리한다. case 수를 문서
상수로 복제하지 않고 `verify:browser-capability-evidence`가 Chromium 결과를
baseline set으로 계산해 Firefox/WebKit의 set 동일성, 양수 실행 수,
failure/error/skipped 0과 failure/skipped node 부재를 검증한다.
capability absence도 skip 사유가 아니다. 예를 들어 어떤 engine에서
`navigator.storage`가 없다면 동일 testcase 안에서 adapter의
`UNSUPPORTED / STORAGE_ESTIMATE / ONLINE_ONLY` 결과를 browser truth로
검증한다. 세 engine을 실행할 수 없는 host의 artifact는 promotion evidence로
사용하지 않는다.
- native input keyboard/focus/same-file reselection/multiple/dismissal
- Chromium conditional picker/save enhancement와 다른 engine fallback
@@ -48,18 +48,39 @@ its business requirement.
## Scenario B — IndexedDB local draft
An executable consumer probe lives at:
The original executable consumer probe remains at:
`tests/contract/consumer-experience/indexeddb-local-draft.test.ts`
with the feature-owned fixture:
with its isolated fixture:
`tests/contract/consumer-experience/fixtures/local-draft-feature.ts`
The fixture models a small local-draft feature with save/find/remove and
optimistic revision checking.
The architecture is now also exercised by a real vertical slice under:
Measured result:
`src/features/local-draft-feature`
It owns the Local Draft domain/application API and binds an
`IndexedDbRepositoryPort<LocalDraft, never>` in its feature adapter. The
cross-capability composition contract lives at:
`tests/contract/reusable-capability/feature-adapter-composition.test.ts`
That test composes one HTTP-only contribution and the IndexedDB-only Local Draft
contribution through the same generic catalog path.
The browser-level composition proof lives at:
`tests/browser-capabilities/local-draft-composition.spec.ts`
It runs against native browser IndexedDB. The test creates the platform
`createIndexedDbRuntime`, exposes that runtime through the typed
`createIndexedDbRepositoryProvider`, composes
`LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION`, then executes Local Draft
save/find/remove through the feature API. The Local Draft feature still does
not import the IndexedDB runtime or native browser API.
Measured probe result:
| Metric | Result |
| --- | ---: |
@@ -81,8 +102,11 @@ The contract test rejects feature source that reaches for
## What this does and does not prove
The probe confirms that **feature business code does not need native IndexedDB
knowledge** once an `IndexedDbRepositoryPort` has been composed.
The contract probe confirms that **feature business code does not need native
IndexedDB knowledge** once an `IndexedDbRepositoryPort` has been composed.
The browser-capability proof additionally confirms that the same feature
contribution works when that port is backed by the repository's real
`createIndexedDbRuntime` and native IndexedDB implementation.
It does not prove that composition of `createIndexedDbRuntime` is cheap.
That constructor still owns substantial infrastructure policy:
@@ -96,10 +120,15 @@ That constructor still owns substantial infrastructure policy:
- durability, scheduling and observation.
That complexity belongs at the composition/platform boundary, not in the
feature. A new `create...Repository<T>` convenience factory should be added
only when a second real product consumer demonstrates which subset is stable
enough to become a reusable profile. Creating one now would guess at policy and
risk producing a universal storage abstraction.
feature. The Local Draft vertical slice is now the second concrete consumer, and
it confirms that the stable seam is the typed repository provider plus a
feature-owned repository identity. It does **not** show that dataset scope,
retention, migration, codec or lifecycle-authority configuration can be safely
collapsed into one universal `create...Repository<T>` factory.
A convenience profile should therefore be introduced only after another
IndexedDB-backed product feature repeats the same infrastructure policy, not
merely because two features consume the same repository port.
## Consumer-quality review checklist
@@ -189,17 +189,18 @@ chunk별 `CapabilityResult<Uint8Array>`를 반환한다. backend upload example
실제 upload feature는 이 예시를 그대로 import하지 않고 purpose와 backend
protocol에 맞게 contract를 더 좁힌다.
현재 checkout의 browser source suite는 engine마다 같은 14개 case(File 2,
IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2,
presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다.
promotion artifact는 Chromium/Firefox/WebKit 각각 14개, 총 42개를 모두
실행해야 한다. WebKit은 현재
host의 필수 native libraries(예:
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았다. 보존 artifact는
Chromium/Firefox 14개씩 총 28개만 통과했으므로
`verify:browser-capability-evidence`가 실패하는 것이 정상이다. 세 engine
evidence가 완성되기 전에는 product 상태를 `INSTALLED`로 올리지 않는다.
browser capability promotion은 source suite가 정의한 동일 testcase set을
Chromium/Firefox/WebKit에서 모두 실행해야 한다. 구체적인 case 개수는 이 결정
문서에 고정하지 않는다. `verify:browser-capability-evidence`가 Chromium
artifact에서 baseline set을 계산하고 Firefox/WebKit과의 set 동일성 및
failure/error/skipped 0을 기계적으로 검증한다.
engine별 native capability 차이는 testcase를 skip하는 이유가 아니다. capability가
없으면 adapter의 명시적 unsupported/degraded result를 같은 testcase에서
검증한다. baseline `<input type="file">` activation은 portable한
`input.click()` 경로를 사용하고, `showOpenFilePicker()` 같은 API는 별도
progressive enhancement로 유지한다. 세 engine evidence가 완성되기 전에는
product 상태를 `INSTALLED`로 올리지 않는다.
## 선택 이후 필요한 구현
@@ -105,6 +105,39 @@ Central installed catalogs are aggregation points only:
Feature-specific composition belongs under the feature itself. Central
catalogs must not grow feature-specific branching logic.
Adapter contributions declare the platform capabilities they consume through
`needs`. The generic contribution seam in
`src/features/feature-adapter-contribution.ts` derives the context from that
list, so an HTTP-only feature cannot reach IndexedDB and an IndexedDB-only
feature does not receive the HTTP executor. It also binds
`featureId -> ApplicationFeatureInputs[featureId]` at the contribution
definition site instead of recovering that relationship with a final catalog
cast.
A contribution becomes composable only through
`defineFeatureAdapterContribution()`. That factory adds the private
contribution brand required by `composeFeatureAdapterInputs()`; a raw object
with the same visible fields is rejected by TypeScript and checked again at
runtime. The negative type fixture
`invalid-direct-feature-composition.ts` is part of FE-GATE-003 so this
authority cannot be bypassed by calling the composer directly.
For IndexedDB-backed features, `createIndexedDbRepositoryProvider()` is the
composition helper that maps feature-owned repository identities to typed
`IndexedDbRepositoryPort` instances. It does not create a universal storage
repository or move dataset/migration/lifecycle policy into the feature.
The repository now has two executable contribution shapes:
- Reference feature: `needs: ["http"]`
- Local Draft feature: `needs: ["indexedDb"]`
Local Draft is a compiled architecture consumer used to prove the second
capability shape; it is not added to the default product manifest. Its
browser-capability test composes the feature over the real
`createIndexedDbRuntime` and native IndexedDB, proving that this seam is not
limited to an in-memory contract fixture.
## Presentation consumer surface
`ApplicationProvider` remains the composition root for presentation, but new
@@ -337,7 +337,14 @@ Run on the landed tree. Only what actually passed is claimed as passing.
| --- | --- | --- |
| `tests/unit/ci-workflow-generation.test.ts` | 82 failed / 325 passed | Identical on the pre-change baseline (`git stash` comparison). The subprocess gates it spawns cannot run in this sandbox. |
| `tests/unit/ci-artifact-contract.test.ts` | fails | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. |
| `tests/unit/security-followup.test.ts`, `tests/unit/provider-guardian-transaction.test.ts`, `tests/unit/risk-coverage.test.ts` | flaky under full-suite load | All three pass in a fresh process (78 passed together). They spawn and reap process groups, so their timing assertions are load sensitive. |
| `tests/unit/security-followup.test.ts`, `tests/unit/provider-guardian-transaction.test.ts`, `tests/unit/risk-coverage.test.ts` | flaky under full-suite load | Historical baseline result. All three passed in a fresh process (78 passed together). |
> Current classification update (2026-09-18): the provider guardian transaction
> suite moved to `tests/integration/provider-guardian-transaction.test.ts`.
> It spawns child processes and exercises filesystem/IPC/process lifecycle, so it
> is not part of the pure unit pool. Its READY/PUBLISHED wait uses a dedicated
> test watchdog rather than treating a 1-second scheduler delay as a production
> protocol deadline.
### Destructive fixture hazard — fixed
@@ -375,7 +382,7 @@ files above plus the two flaky-under-load ones:
| `tests/unit/ci-workflow-generation.test.ts` | 82 | Identical on the pre-change baseline (`git stash` comparison). Its subprocess gates cannot run in this sandbox. |
| `tests/unit/ci-artifact-contract.test.ts` | 19 | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. |
| `tests/unit/security-followup.test.ts` | 2 | Passes in isolation. |
| `tests/unit/provider-guardian-transaction.test.ts` | 1 | Passes in isolation. |
| `tests/unit/provider-guardian-transaction.test.ts` (historical path; now `tests/integration/provider-guardian-transaction.test.ts`) | 1 | Historical baseline: passed in isolation; current suite is classified as process integration. |
1619 passed / 1723 total, and `tests/unit/removal-fixture.test.ts`,
`tests/unit/supply-chain.test.ts` and
@@ -148,6 +148,9 @@ read-only나 online-only가 사용자 작성 내용을 잃게 한다면 먼저 e
### 신호
- `BLOCKED`, `UPGRADE_BLOCKED` 또는 blocked duration bucket 증가
- live attempt가 기다리는 동안 status는 `BLOCKED`
- blocked deadline settle 뒤에는 open result가 `BLOCKED` failure여도
runtime status는 `CLOSED / NOT_OPENED`
- `versionchange` 뒤 connection이 남음
- repeated reload/update loop
- open/maintenance의 `POLICY_REJECTED`: immutable dataset binding missing/mismatch
@@ -500,16 +503,13 @@ browser engine/version, fixture ID, fault phase, bounded counts/buckets와 PASS/
promotion 직전에는 다음 repository evidence도 함께 보존한다.
- `test:browser-capabilities`가 만든 JUnit에서 Chromium, Firefox, WebKit이 동일
14개 testcase set(File 2, IndexedDB 4, OPFS/Cache/StorageManager 각 1,
cross-context invalidation 2, presigned streaming download/multipart
upload/Image CDN 각 1)을 실제 실행해 총 42개이며 failure/error/skipped가 모두
0이어야 한다.
`verify:browser-capability-evidence`가 engine 집합과 testcase 동일성을
기계적으로 검증한다. 현재 artifact는 Chromium/Firefox 14개씩 총 28개
통과했지만 WebKit 실행에 필요한 native libraries(예:
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
`libavif.so.16`과 WPE 계열)가 이 host에 없으므로 아직 promotion 가능 상태가
- `test:browser-capabilities`가 만든 JUnit에서 Chromium, Firefox, WebKit이
source suite의 동일 testcase set을 실제 실행하고 failure/error/skipped가 모두
0이어야 한다. 구체적인 case 수는 이 runbook에 복제하지 않는다.
`verify:browser-capability-evidence`가 Chromium을 baseline으로 testcase set과
engine 집합을 동적으로 검증한다. native capability가 없는 engine도 testcase를
skip하지 않고 adapter의 명시적 `UNSUPPORTED`/degraded 결과를 검증해야 한다.
세 engine 중 하나라도 실행되지 않은 artifact는 promotion 가능 상태
아니다.
- `artifacts/quality/vite-module-inventory.json`에서 optional runtime source root가
production chunk에 없음을 `check:optional-recipes`로 검증한다.
@@ -105,6 +105,24 @@ component suite로 분리되어 있다.
실제 QueryClient 위에서 검증한다. HTTP 자동 retry가 소유자이므로 이 adapter의
query/mutation vendor retry는 꺼져 있다.
같은 기준을 대형 unit suite에도 적용한다. 줄 수를 기준으로 자르지 않고 실패가
가리켜야 하는 behavior owner를 기준으로 분리한다.
- `public-response-cache.test.ts`: 일반 stage/activate/lookup/cache contract
- `public-response-cache-repair.test.ts`: active release repair의 failure atomicity
- `resumable-upload-runtime.test.ts`: upload/reconcile/control-plane 흐름
- `resumable-upload-runtime-teardown.test.ts`: bounded drain/raw provider teardown
- `security-followup.test.ts`: archived local evidence
- `security-provider-evidence.test.ts`: provider signature/supervision/process lifecycle
- `security-promotion-staging.test.ts`: private staging seal/replay/CLI identity
- `tests/integration/provider-guardian-transaction.test.ts`: child process,
filesystem, IPC frame, READY/PUBLISHED handshake와 process lifecycle. pure unit
pool에 두지 않으며 handshake budget은 protocol timeout이 아닌 test watchdog이다.
-`*-fixture.ts`: 해당 owner들 사이에서만 공유하는 deterministic test fixture
이 분리는 production owner와 test failure surface를 맞추기 위한 것이며, 단순
LOC 감축 목적이 아니다.
#### Form과 route 위험
form component/reference feature test가 error summary, 첫 오류 focus, Zod
+6 -2
View File
@@ -11,7 +11,7 @@ Each gate is blocking in its declared scope. Failures are not downgraded with
| unit | `pnpm test:unit` | Node-only domain/application/pure policy/runtime units; no systemd/bwrap/cgroup prerequisite | `artifacts/tests/unit.xml` |
| capability contract | `pnpm test:contract` | reusable capability consumer contracts | `artifacts/tests/contract.xml` |
| component | `pnpm test:component` | React/hook/UI behavior | `artifacts/tests/component.xml` |
| integration | `pnpm test:integration` | HTTP/MSW, IndexedDB and composed browser-runtime boundaries | `artifacts/tests/integration.xml` |
| integration | `pnpm test:integration` | HTTP/MSW, IndexedDB, composed browser-runtime boundaries, child-process/filesystem/IPC integration | `artifacts/tests/integration.xml` |
| system / CI runner | `pnpm test:system` | compatible Linux host with systemd, bubblewrap, cgroup v2 and CI-provider process controls | `artifacts/tests/system.xml` |
| end-to-end | `pnpm test:e2e` | pinned browser engines | `artifacts/tests/e2e/` |
| accessibility | `pnpm test:a11y` | pinned browser engines | `artifacts/tests/a11y.json` |
@@ -64,7 +64,11 @@ CI / release assurance:
A host-level process/sandbox test must not be placed in `tests/unit` merely
because it uses Vitest. The classification follows the system boundary and
prerequisites, not the test framework.
prerequisites, not the test framework. Process-heavy tests that spawn child
processes but do not require privileged host facilities belong in
`tests/integration`; for example the provider guardian transaction protocol
lives at `tests/integration/provider-guardian-transaction.test.ts`. Its
handshake timeout is a test watchdog, not a production protocol deadline.
End-to-end and automated accessibility scenarios run on the pinned Chromium,
Firefox, and WebKit engines. The responsive contract explicitly exercises
+24 -17
View File
@@ -30,22 +30,22 @@
"check:types:node": "tsc --project tsconfig.node.json",
"check:types:test": "tsc --project tsconfig.test.json",
"check:types:recipes": "node scripts/check-optional-recipe-types.ts",
"check:types:fixture": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.ts",
"check:types:fixture:ts-port": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-port-implementation.ts",
"check:types:fixture:ts-result": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-result-narrowing.ts",
"check:types:fixture:application-output": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts",
"check:types:fixture:application-input": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts",
"check:types:fixture:feature-input": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-feature-input.ts",
"check:types:fixture:failure-kind": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-failure-kind.ts",
"check:types:fixture:reference-operation": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-reference-operation.ts",
"check:types:fixture:async-overlay": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts",
"check:types:fixture:route-runtime": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts",
"check:types:fixture:page-action": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-page-action.tsx",
"check:types:fixture:icon-button": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-icon-button.tsx",
"check:types:fixture:i18n-key": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-key.ts",
"check:types:fixture:i18n-params": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-params.ts",
"check:types:fixture:diagnostics": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-diagnostics-port.ts",
"check:types:fixture:image-resolve-signal": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-image-cdn-resolve-signal.ts",
"check:types:fixture": "tsc -p tests/fixtures/typecheck/tsconfig.port-call.json",
"check:types:fixture:ts-port": "tsc -p tests/fixtures/typecheck/tsconfig.port-implementation.json",
"check:types:fixture:ts-result": "tsc -p tests/fixtures/typecheck/tsconfig.result-narrowing.json",
"check:types:fixture:application-output": "tsc -p tests/fixtures/typecheck/tsconfig.application-output.json",
"check:types:fixture:application-input": "tsc -p tests/fixtures/typecheck/tsconfig.application-input.json",
"check:types:fixture:feature-input": "tsc -p tests/fixtures/typecheck/tsconfig.feature-input.json",
"check:types:fixture:failure-kind": "tsc -p tests/fixtures/typecheck/tsconfig.failure-kind.json",
"check:types:fixture:reference-operation": "tsc -p tests/fixtures/typecheck/tsconfig.reference-operation.json",
"check:types:fixture:async-overlay": "tsc -p tests/fixtures/typecheck/tsconfig.async-overlay.json",
"check:types:fixture:route-runtime": "tsc -p tests/fixtures/typecheck/tsconfig.route-runtime.json",
"check:types:fixture:page-action": "tsc -p tests/fixtures/typecheck/tsconfig.page-action.json",
"check:types:fixture:icon-button": "tsc -p tests/fixtures/typecheck/tsconfig.icon-button.json",
"check:types:fixture:i18n-key": "tsc -p tests/fixtures/typecheck/tsconfig.message-key.json",
"check:types:fixture:i18n-params": "tsc -p tests/fixtures/typecheck/tsconfig.message-params.json",
"check:types:fixture:diagnostics": "tsc -p tests/fixtures/typecheck/tsconfig.diagnostics-port.json",
"check:types:fixture:image-resolve-signal": "tsc -p tests/fixtures/typecheck/tsconfig.image-cdn-resolve-signal.json",
"test:runtime-schema": "node scripts/run-vitest.ts run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
"test:unit": "node scripts/run-vitest.ts run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
"test:contract": "node scripts/run-vitest.ts run tests/contract --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/contract.xml",
@@ -124,7 +124,14 @@
"build:app-only": "vite build && node scripts/generate-build-manifest.ts",
"generate:contract-set": "node scripts/generate-contract-set.ts",
"check:types:web-worker": "tsc --project tsconfig.web-worker.json",
"check:types:service-worker": "tsc --project tsconfig.service-worker.json"
"check:types:service-worker": "tsc --project tsconfig.service-worker.json",
"check:types:fixture:http-operation-input": "tsc -p tests/fixtures/typecheck/tsconfig.http-operation-input.json",
"check:types:fixture:http-wire-mapper": "tsc -p tests/fixtures/typecheck/tsconfig.http-wire-mapper.json",
"check:types:fixture:http-operation-id": "tsc -p tests/fixtures/typecheck/tsconfig.http-operation-id.json",
"check:types:fixture:http-route-id": "tsc -p tests/fixtures/typecheck/tsconfig.http-route-id.json",
"check:types:fixture:feature-contribution-input": "tsc -p tests/fixtures/typecheck/tsconfig.feature-contribution-input.json",
"check:types:fixture:feature-capability-selection": "tsc -p tests/fixtures/typecheck/tsconfig.feature-capability-selection.json",
"check:types:fixture:direct-feature-composition": "tsc -p tests/fixtures/typecheck/tsconfig.direct-feature-composition.json"
},
"dependencies": {
"@tanstack/react-query": "5.101.4",
+3 -3
View File
@@ -443,7 +443,7 @@ export type LoadCiGateContractOptions = Readonly<{
}>;
const CANONICAL_GATE_SHAPE_SHA256 =
"372959f50b9c5a228bdc85dce33ca7968c72414ce3b6d5534c9e6eaf554f6ce4";
"43dc4ed1cdaf21a674bd069d42eb2c32276afd8f763d72e52c9f7c3adccbeb58";
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
const normalized = gates.map(
@@ -477,9 +477,9 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
if (contract.gates.length !== 27) {
failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`);
}
if (contract.commands.length !== 84 || commandReferenceCount !== 96) {
if (contract.commands.length !== 91 || commandReferenceCount !== 103) {
failures.push(
`command authority baseline must contain exactly 84 definitions and 96 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
`command authority baseline must contain exactly 91 definitions and 103 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
);
}
if (contract.artifacts.length !== 109) {
+109 -12
View File
@@ -42,18 +42,39 @@ const featureOwnedPaths = [
featureSource,
featureTests,
"tests/integration/http-scenario-catalog.test.ts",
"tests/component/product-feature-switch.test.tsx",
"tests/e2e/reference-form.spec.ts",
"tests/e2e/reference-route.spec.ts",
"tests/mocks",
"tests/fixtures/typecheck/invalid-feature-input.ts",
"tests/fixtures/typecheck/invalid-reference-operation.ts",
"tests/fixtures/typecheck/invalid-http-operation-input.ts",
"tests/fixtures/typecheck/invalid-http-wire-mapper.ts",
"tests/fixtures/typecheck/invalid-http-operation-id.ts",
"tests/fixtures/typecheck/invalid-http-route-id.ts",
"tests/fixtures/typecheck/invalid-feature-contribution-input.ts",
"tests/fixtures/typecheck/invalid-feature-capability-selection.ts",
"tests/fixtures/typecheck/tsconfig.feature-input.json",
"tests/fixtures/typecheck/tsconfig.reference-operation.json",
"tests/fixtures/typecheck/tsconfig.http-operation-input.json",
"tests/fixtures/typecheck/tsconfig.http-wire-mapper.json",
"tests/fixtures/typecheck/tsconfig.http-operation-id.json",
"tests/fixtures/typecheck/tsconfig.http-route-id.json",
"tests/fixtures/typecheck/tsconfig.feature-contribution-input.json",
"tests/fixtures/typecheck/tsconfig.feature-capability-selection.json",
];
const emptyProductManifest = `export const COMPILED_PRODUCT_FEATURE_IDS: readonly string[] = Object.freeze([]);
export const INSTALLED_PRODUCT_FEATURES: readonly never[] = Object.freeze([]);
export const INSTALLED_PRODUCT_FEATURE_IDS: readonly string[] = Object.freeze([]);
`;
const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts";
import { PLATFORM_ROUTE_REGISTRY, type RouteDefinition } from "../contracts/routes.ts";
import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.ts";
import { INSTALLED_PRODUCT_FEATURES } from "./installed-product-manifest.ts";
export const INSTALLED_FEATURE_CONTRACTS: readonly unknown[] = Object.freeze([]);
export const INSTALLED_FEATURE_CONTRACTS = INSTALLED_PRODUCT_FEATURES;
export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY;
export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT;
export const API_OPERATIONS = Object.freeze({});
@@ -64,6 +85,7 @@ export const INVALIDATION_REGISTRY = Object.freeze({
});
export const INVALIDATION_TOPIC_VERSIONS = Object.freeze([]);
export const SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY;
export const ROUTE_FEATURE_OWNER: Readonly<Record<string, string>> = Object.freeze({});
export const NAVIGATION_ROUTES = Object.freeze(
Object.values(ROUTE_REGISTRY)
.filter((definition) => definition.navigationOrder !== null)
@@ -85,14 +107,39 @@ export function routePath(routeId: string): string {
const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
const COMPILED_FEATURE_RUNTIME_CONTRIBUTIONS: readonly Readonly<{
featureId: string;
}>[] = Object.freeze([]);
const INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS =
COMPILED_FEATURE_RUNTIME_CONTRIBUTIONS.filter((contribution) =>
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
);
void INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS;
export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS;
export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME;
`;
const emptyAdapters = `export function createInstalledFeatureInputs(_context: unknown) {
void _context;
return Object.freeze({});
const emptyAdapters = `import {
composeFeatureAdapterInputs,
type InstalledFeatureInputs,
} from "./feature-adapter-contribution.ts";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
const COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS = Object.freeze([] as const);
export function createInstalledFeatureInputs(
context: Readonly<Record<never, never>>,
): InstalledFeatureInputs {
return composeFeatureAdapterInputs(
COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS,
INSTALLED_PRODUCT_FEATURE_IDS,
context,
);
}
`;
@@ -101,9 +148,17 @@ const emptyContractContributions = `import {
type InstalledContractContribution,
type InstalledContractPackageIdentity,
} from "../contracts/external-contract-runtime.ts";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
const COMPILED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
Object.freeze([]);
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
Object.freeze([]);
Object.freeze(
COMPILED_CONTRACT_CONTRIBUTIONS.filter((contribution) =>
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
),
);
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
INSTALLED_CONTRACT_CONTRIBUTIONS,
@@ -181,6 +236,10 @@ try {
force: true,
});
}
await writeFile(
path.join(fixtureRoot, "src/features/installed-product-manifest.ts"),
emptyProductManifest,
);
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-contracts.ts"),
emptyContracts,
@@ -202,6 +261,34 @@ try {
emptyContractContributions,
);
// Runtime profiles are product configuration. Once the feature is physically
// removed, its override key must disappear too or the built config still
// advertises a source capability that no longer exists.
for (const profile of [
"local",
"development",
"staging",
"production",
] as const) {
const profilePath = path.join(
fixtureRoot,
"config/runtime",
`${profile}.json`,
);
const runtimeConfig = JSON.parse(
await readFile(profilePath, "utf8"),
) as {
FEATURE_OVERRIDES?: Record<string, string>;
};
if (runtimeConfig.FEATURE_OVERRIDES) {
delete runtimeConfig.FEATURE_OVERRIDES["reference-feature"];
}
await writeFile(
profilePath,
`${JSON.stringify(runtimeConfig, null, 2)}\n`,
);
}
const retainedCriticalModules = coveragePolicy.criticalModules.filter(
(modulePolicy) => !modulePolicy.path?.startsWith(`${featureSource}/`),
);
@@ -296,6 +383,12 @@ try {
const removedCiScripts = new Set([
"check:types:fixture:feature-input",
"check:types:fixture:reference-operation",
"check:types:fixture:http-operation-input",
"check:types:fixture:http-wire-mapper",
"check:types:fixture:http-operation-id",
"check:types:fixture:http-route-id",
"check:types:fixture:feature-contribution-input",
"check:types:fixture:feature-capability-selection",
"test:http-scenario-evidence",
"test:reference-feature",
]);
@@ -370,13 +463,17 @@ try {
["build", runPnpm("build")],
];
const builtResidue: string[] = [];
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
if (!/\.(?:js|css|html|json)$/.test(file)) continue;
const content = await readFile(file, "utf8");
if (
/REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content)
) {
builtResidue.push(path.relative(fixtureRoot, file));
const buildSucceeded =
checks.find(([name]) => name === "build")?.[1] === true;
if (buildSucceeded) {
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
if (!/\.(?:js|css|html|json)$/.test(file)) continue;
const content = await readFile(file, "utf8");
if (
/REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content)
) {
builtResidue.push(path.relative(fixtureRoot, file));
}
}
}
const routeCatalog = await import(
@@ -108,14 +108,12 @@ export class NativeInputFilePicker implements DisposableFilePicker {
const addInputEvent = options.input.addEventListener;
const removeInputEvent = options.input.removeEventListener;
const getInputAttribute = options.input.getAttribute;
const showPicker = options.input.showPicker;
const click = options.input.click;
if (
typeof addInputEvent !== "function" ||
typeof removeInputEvent !== "function" ||
typeof getInputAttribute !== "function" ||
(typeof showPicker !== "function" &&
typeof click !== "function")
typeof click !== "function"
) {
throw new TypeError("Native file input API is invalid.");
}
@@ -123,10 +121,11 @@ export class NativeInputFilePicker implements DisposableFilePicker {
add: addInputEvent.bind(options.input),
remove: removeInputEvent.bind(options.input),
getAttribute: getInputAttribute.bind(options.input),
activate:
typeof showPicker === "function"
? showPicker.bind(options.input)
: click.bind(options.input),
// The baseline <input type="file"> path intentionally uses click().
// showPicker() exists in multiple engines but is not a portable
// automation/event-interception contract. Enhanced native picking is a
// separate capability owned by EnhancedFilePicker.
activate: click.bind(options.input),
});
const windowHost = options.window ?? globalThis.window;
if (windowHost) {
+217 -54
View File
@@ -1,7 +1,8 @@
import type { Result } from "../../contracts/result.ts";
import type {
MappingResult,
} from "../../contracts/boundary-mapper.ts";
InstalledHttpContract,
} from "../../contracts/external-contract-runtime.ts";
import type { Result } from "../../contracts/result.ts";
import type { MappingResult } from "../../contracts/boundary-mapper.ts";
import {
createFailure,
kindForStatus,
@@ -10,55 +11,199 @@ import {
type FailureKind,
} from "../../contracts/errors.ts";
import type { MutationIntent } from "../../contracts/mutation-intent.ts";
import type { HttpExecutionOutcome } from "./http-execution-v3.ts";
import type {
HttpExecutionOutcome,
SafeResponseMetadata,
} from "./http-execution-v3.ts";
type ValidatorValue<Validator> =
Validator extends Readonly<{
safeParse(value: unknown): infer ParseResult;
}>
? Extract<
ParseResult,
Readonly<{ success: true; data: unknown }>
> extends Readonly<{ data: infer Value }>
? Value
: never
: never;
export type HttpContractInput<Contract> =
Contract extends Readonly<{
contract: Readonly<{ inputValidator: infer Validator }>;
}>
? ValidatorValue<Validator>
: never;
export type HttpContractWireOutput<Contract> =
Contract extends Readonly<{
contract: Readonly<{ outputValidator: infer Validator }>;
}>
? ValidatorValue<Validator>
: never;
export type HttpContractProblem<Contract> =
Contract extends Readonly<{
contract: Readonly<{ problemValidator: infer Validator }>;
}>
? ValidatorValue<Validator>
: never;
export type HttpContractOperationId<Contract> =
Contract extends Readonly<{
contract: Readonly<{ operationId: infer OperationId extends string }>;
}>
? OperationId
: never;
export type InstalledHttpOperationExecutor = Readonly<{
execute(
operationId: string,
input: unknown,
execute<Input, WireOutput, Problem>(
contract: InstalledHttpContract<Input, WireOutput, Problem>,
input: Input,
context: Readonly<{
routeId: string;
signal?: AbortSignal;
intent?: MutationIntent;
}>,
): Promise<HttpExecutionOutcome<unknown, unknown>>;
): Promise<HttpExecutionOutcome<WireOutput, Problem>>;
}>;
type FeatureHttpOperationSpec<Value> = Readonly<{
operationId: string;
routeId: string;
mapSuccess(value: unknown): MappingResult<Value>;
export type FeatureHttpProblemMapping = Readonly<{
kind: FailureKind;
code: string;
}>;
export type FeatureHttpOperation<Input, Value> =
FeatureHttpOperationSpec<Value> &
Readonly<{
/**
* Compile-time only carrier. Feature bindings remain plain frozen objects
* at runtime while preserving each operation's input/value pair.
*/
__types?: Readonly<{
input: Input;
value: Value;
}>;
type FeatureHttpOperationSpec<
Contract,
Value,
RouteId extends string,
> = Readonly<{
contract: Contract;
routeId: RouteId;
mapSuccess(
value: HttpContractWireOutput<Contract>,
): MappingResult<Value>;
mapProblem?(
problem: HttpContractProblem<Contract>,
metadata: SafeResponseMetadata,
): FeatureHttpProblemMapping | undefined;
}>;
export type FeatureHttpOperation<
Contract,
Value,
RouteId extends string = string,
> = FeatureHttpOperationSpec<Contract, Value, RouteId> &
Readonly<{
operationId: HttpContractOperationId<Contract>;
/**
* Compile-time only carrier. The runtime object has no extra type payload;
* the contract remains the authority for input/wire/problem types.
*/
__types?: Readonly<{
value: Value;
}>;
}>;
export function defineFeatureHttpOperation<Input, Value>(
spec: FeatureHttpOperationSpec<Value>,
): FeatureHttpOperation<Input, Value> {
return Object.freeze(spec) as FeatureHttpOperation<Input, Value>;
type ContractValidity<Contract> =
HttpContractOperationId<Contract> extends never
? never
: HttpContractInput<Contract> extends never
? never
: HttpContractWireOutput<Contract> extends never
? never
: HttpContractProblem<Contract> extends never
? never
: unknown;
function defineFeatureHttpOperationForRoute<
const RouteId extends string,
const Contract,
Value,
>(
spec: FeatureHttpOperationSpec<Contract, Value, RouteId> &
ContractValidity<Contract>,
): FeatureHttpOperation<Contract, Value, RouteId> {
return Object.freeze({
...spec,
operationId: (spec.contract as Readonly<{
contract: Readonly<{ operationId: HttpContractOperationId<Contract> }>;
}>).contract.operationId,
}) as FeatureHttpOperation<Contract, Value, RouteId>;
}
type OperationInput<Operation> =
Operation extends FeatureHttpOperation<infer Input, unknown> ? Input : never;
export function defineFeatureHttpOperation<
const Contract,
Value,
const RouteId extends string,
>(
spec: FeatureHttpOperationSpec<Contract, Value, RouteId> &
ContractValidity<Contract>,
): FeatureHttpOperation<Contract, Value, RouteId> {
return defineFeatureHttpOperationForRoute(spec);
}
/**
* Feature-local route typing without introducing a dependency from the HTTP
* capability to the presentation route registry.
*/
export function defineFeatureHttpOperationForRoutes<
const RouteId extends string,
>() {
return function defineRouteBoundFeatureHttpOperation<
const Contract,
Value,
>(
spec: FeatureHttpOperationSpec<Contract, Value, RouteId> &
ContractValidity<Contract>,
): FeatureHttpOperation<Contract, Value, RouteId> {
return defineFeatureHttpOperationForRoute(spec);
};
}
type FeatureHttpOperationShape = Readonly<{
contract: object;
operationId: string;
routeId: string;
mapSuccess: (...args: never[]) => unknown;
mapProblem?: (...args: never[]) => unknown;
}>;
type OperationContract<Operation> =
Operation extends Readonly<{ contract: infer Contract }> ? Contract : never;
type OperationInput<Operation> = HttpContractInput<OperationContract<Operation>>;
type OperationWireOutput<Operation> =
HttpContractWireOutput<OperationContract<Operation>>;
type OperationProblem<Operation> =
HttpContractProblem<OperationContract<Operation>>;
type OperationValue<Operation> =
Operation extends FeatureHttpOperation<unknown, infer Value> ? Value : never;
Operation extends FeatureHttpOperation<infer _Contract, infer Value, string>
? Value
: never;
type InvalidOperationRegistryKeys<
Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
> = {
[OperationId in keyof Operations & string]:
OperationId extends Operations[OperationId]["operationId"]
? Operations[OperationId]["operationId"] extends OperationId
? never
: OperationId
: OperationId;
}[keyof Operations & string];
type ValidOperationRegistry<
Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
> = InvalidOperationRegistryKeys<Operations> extends never
? unknown
: Readonly<{
__operationRegistryKeyMismatch: InvalidOperationRegistryKeys<Operations>;
}>;
export type FeatureHttpBinding<
Operations extends Readonly<
Record<string, FeatureHttpOperation<unknown, unknown>>
>,
Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
> = Readonly<{
execute<OperationId extends keyof Operations & string>(
operationId: OperationId,
@@ -73,18 +218,15 @@ export type FeatureHttpBinding<
/**
* Capability-specific feature binding for the installed HTTP runtime.
*
* The platform owns transport/outcome normalization. A feature contributes only
* operation identity, route identity, typed input and its wire-to-domain mapper.
* This keeps HTTP lifecycle/failure semantics out of feature application code
* without collapsing storage/realtime/transfer into a universal repository.
* The installed contract is the single type authority. A feature contributes
* only a route identity plus wire-to-domain/problem interpretation. Transport,
* retry, validation and effect-certainty stay in the reusable HTTP capability.
*/
export function createFeatureHttpBinding<
const Operations extends Readonly<
Record<string, FeatureHttpOperation<unknown, unknown>>
>,
const Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
>(
executor: InstalledHttpOperationExecutor,
operations: Operations,
operations: Operations & ValidOperationRegistry<Operations>,
): FeatureHttpBinding<Operations> {
for (const [registryId, operation] of Object.entries(operations)) {
if (registryId !== operation.operationId) {
@@ -104,28 +246,47 @@ export function createFeatureHttpBinding<
}> = {},
): Promise<Result<OperationValue<Operations[OperationId]>, ApiFailure>> {
const operation = operations[operationId];
const outcome = await executor.execute(operation.operationId, input, {
type SelectedOperation = Operations[OperationId];
type Input = OperationInput<SelectedOperation>;
type WireOutput = OperationWireOutput<SelectedOperation>;
type Problem = OperationProblem<SelectedOperation>;
type Value = OperationValue<SelectedOperation>;
const contract = operation.contract as InstalledHttpContract<
Input,
WireOutput,
Problem
>;
const outcome = await executor.execute(contract, input, {
routeId: operation.routeId,
...(context.signal === undefined ? {} : { signal: context.signal }),
...(context.intent === undefined ? {} : { intent: context.intent }),
});
return projectExecutionOutcome<
OperationValue<Operations[OperationId]>
>(
return projectExecutionOutcome(
operation.operationId,
operation.mapSuccess as (
value: unknown,
) => MappingResult<OperationValue<Operations[OperationId]>>,
operation.mapSuccess as (value: WireOutput) => MappingResult<Value>,
operation.mapProblem as
| ((
problem: Problem,
metadata: SafeResponseMetadata,
) => FeatureHttpProblemMapping | undefined)
| undefined,
outcome,
);
},
});
}
function projectExecutionOutcome<Value>(
function projectExecutionOutcome<WireOutput, Problem, Value>(
operationId: string,
mapSuccess: (value: unknown) => MappingResult<Value>,
outcome: HttpExecutionOutcome<unknown, unknown>,
mapSuccess: (value: WireOutput) => MappingResult<Value>,
mapProblem:
| ((
problem: Problem,
metadata: SafeResponseMetadata,
) => FeatureHttpProblemMapping | undefined)
| undefined,
outcome: HttpExecutionOutcome<WireOutput, Problem>,
): Result<Value, ApiFailure> {
switch (outcome.kind) {
case "SUCCESS": {
@@ -139,13 +300,15 @@ function projectExecutionOutcome<Value>(
{ effect: outcome.effect },
);
}
case "PROBLEM":
case "PROBLEM": {
const mapped = mapProblem?.(outcome.problem, outcome.metadata);
return failure(
kindForStatus(outcome.metadata.status),
mapped?.kind ?? kindForStatus(outcome.metadata.status),
operationId,
"CONTRACT_PROBLEM",
mapped?.code ?? "CONTRACT_PROBLEM",
{ httpStatus: outcome.metadata.status, effect: outcome.effect },
);
}
case "UNAUTHENTICATED":
return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", {
effect: outcome.effect,
+6
View File
@@ -22,8 +22,14 @@ export {
export {
createFeatureHttpBinding,
defineFeatureHttpOperation,
defineFeatureHttpOperationForRoutes,
type FeatureHttpBinding,
type FeatureHttpOperation,
type FeatureHttpProblemMapping,
type HttpContractInput,
type HttpContractOperationId,
type HttpContractProblem,
type HttpContractWireOutput,
type InstalledHttpOperationExecutor,
} from "./feature-http-binding.ts";
/** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */
@@ -21,9 +21,23 @@ export type IndexedDbSynchronizationState =
| "CONFIRMED";
export type IndexedDbConnectionStatus =
| Readonly<{ kind: "CLOSED"; reason: "NOT_OPENED" | "VERSION_CHANGE" | "FORCED" }>
| Readonly<{
kind: "CLOSED";
/**
* NOT_OPENED means the current open attempt has settled without a live
* connection. A blocked upgrade may therefore be observed as BLOCKED
* while it is waiting, then transition to CLOSED/NOT_OPENED when its
* blocked deadline fails closed.
*/
reason: "NOT_OPENED" | "VERSION_CHANGE" | "FORCED";
}>
| Readonly<{ kind: "OPENING"; targetVersion: number }>
| Readonly<{
/**
* A live open/upgrade attempt is currently waiting for another
* IndexedDB context. BLOCKED is transient and is not the settled state
* after the blocked deadline has expired.
*/
kind: "BLOCKED";
currentVersion: number;
targetVersion: number;
+14 -15
View File
@@ -9,6 +9,7 @@ import {
createContractHttpExecutor,
createHttpClient,
type HttpExecutionObservation,
type InstalledHttpOperationExecutor,
} from "../adapters/http/index.ts";
import {
attemptBucket,
@@ -480,19 +481,13 @@ export async function createRuntimeAdapters(
},
observe: createHttpObservationProjector({ diagnostics, telemetry }),
});
const contractOperations = Object.freeze({
async execute(
operationId: string,
input: unknown,
executionContext: Readonly<{
routeId: string;
signal?: AbortSignal;
intent?: MutationIntent;
}>,
) {
const operation =
COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId);
if (!operation) {
const contractOperations: InstalledHttpOperationExecutor = Object.freeze({
async execute(operation, input, executionContext) {
const installed =
COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(
operation.contract.operationId,
);
if (!installed) {
return Object.freeze({
kind: "CONTRACT_VIOLATION" as const,
effect: "NOT_STARTED" as const,
@@ -502,7 +497,11 @@ export async function createRuntimeAdapters(
}),
});
}
const outcome = await contractHttp.execute(operation, input, {
// composeContractContributions deliberately snapshots every installed
// contract. Runtime authority is therefore the validated registry entry,
// not object identity with the feature-owned source declaration.
const executableContract = installed as typeof operation;
const outcome = await contractHttp.execute(executableContract, input, {
routeId: executionContext.routeId,
scope: serverStateScope.getSnapshot(),
...(executionContext.signal === undefined
@@ -519,7 +518,7 @@ export async function createRuntimeAdapters(
},
});
const featureInputs = createInstalledFeatureInputs({
contractOperations,
http: contractOperations,
});
return Object.freeze({
@@ -0,0 +1,176 @@
import type {
ApplicationFeatureId,
ApplicationFeatureInputs,
} from "../application/ports/in/application-api.ts";
import type {
InstalledHttpOperationExecutor,
} from "../adapters/http/index.ts";
/**
* Features augment this registry with the repository identities they consume.
* The provider is a composition capability; feature use cases never import it.
*/
export interface FeatureIndexedDbRepositories {}
export type FeatureIndexedDbRepositoryId = Extract<
keyof FeatureIndexedDbRepositories,
string
>;
export type IndexedDbRepositoryProvider = Readonly<{
get<RepositoryId extends FeatureIndexedDbRepositoryId>(
repositoryId: RepositoryId,
): FeatureIndexedDbRepositories[RepositoryId];
}>;
export function createIndexedDbRepositoryProvider(
repositories: Readonly<Partial<FeatureIndexedDbRepositories>>,
): IndexedDbRepositoryProvider {
const snapshot = Object.freeze({ ...repositories });
return Object.freeze({
get<RepositoryId extends FeatureIndexedDbRepositoryId>(
repositoryId: RepositoryId,
): FeatureIndexedDbRepositories[RepositoryId] {
const repository = snapshot[repositoryId];
if (!repository) {
throw new TypeError(
`IndexedDB repository is not installed: ${repositoryId}`,
);
}
return repository as FeatureIndexedDbRepositories[RepositoryId];
},
});
}
export type FeaturePlatformCapabilities = Readonly<{
http: InstalledHttpOperationExecutor;
indexedDb: IndexedDbRepositoryProvider;
}>;
export type FeaturePlatformCapabilityId =
keyof FeaturePlatformCapabilities;
export type FeatureCapabilitySelection<
Needs extends readonly FeaturePlatformCapabilityId[],
> = Pick<FeaturePlatformCapabilities, Needs[number]>;
const FEATURE_ADAPTER_CONTRIBUTION = Symbol(
"feature-adapter-contribution",
);
type FeatureAdapterContributionDefinition<
FeatureId extends ApplicationFeatureId,
Needs extends readonly FeaturePlatformCapabilityId[],
> = Readonly<{
featureId: FeatureId;
needs: Needs;
createInput(
context: FeatureCapabilitySelection<Needs>,
): Readonly<{
featureId: FeatureId;
input: ApplicationFeatureInputs[FeatureId];
}>;
}>;
export type FeatureAdapterContribution<
FeatureId extends ApplicationFeatureId,
Needs extends readonly FeaturePlatformCapabilityId[],
> = FeatureAdapterContributionDefinition<FeatureId, Needs> &
Readonly<{
[FEATURE_ADAPTER_CONTRIBUTION]: true;
}>;
export function defineFeatureAdapterContribution<
const FeatureId extends ApplicationFeatureId,
const Needs extends readonly FeaturePlatformCapabilityId[],
>(
contribution: FeatureAdapterContributionDefinition<FeatureId, Needs>,
): FeatureAdapterContribution<FeatureId, Needs> {
return Object.freeze({
...contribution,
needs: Object.freeze([...contribution.needs]) as unknown as Needs,
[FEATURE_ADAPTER_CONTRIBUTION]: true as const,
});
}
type ContributionShape = Readonly<{
[FEATURE_ADAPTER_CONTRIBUTION]: true;
featureId: ApplicationFeatureId;
needs: readonly FeaturePlatformCapabilityId[];
createInput: (...args: never[]) => unknown;
}>;
type ContributionNeeds<Contribution> =
Contribution extends Readonly<{
needs: readonly (infer Capability)[];
}>
? Extract<Capability, FeaturePlatformCapabilityId>
: never;
export type ContributionsCapabilityContext<
Contributions extends readonly ContributionShape[],
> = Pick<
FeaturePlatformCapabilities,
ContributionNeeds<Contributions[number]>
>;
export type InstalledFeatureInputs = Readonly<
Partial<ApplicationFeatureInputs>
>;
function setFeatureInput<FeatureId extends ApplicationFeatureId>(
target: Partial<ApplicationFeatureInputs>,
featureId: FeatureId,
input: ApplicationFeatureInputs[FeatureId],
): void {
const writable = target as Record<
ApplicationFeatureId,
ApplicationFeatureInputs[ApplicationFeatureId]
>;
writable[featureId] = input;
}
function invokeContribution<
FeatureId extends ApplicationFeatureId,
Needs extends readonly FeaturePlatformCapabilityId[],
>(
contribution: FeatureAdapterContribution<FeatureId, Needs>,
context: FeaturePlatformCapabilities,
) {
return contribution.createInput(context);
}
/**
* Dynamic catalog assembly stays centralized here. Type authority lives at each
* feature contribution; the only dynamic write is isolated in setFeatureInput.
*/
export function composeFeatureAdapterInputs<
const Contributions extends readonly ContributionShape[],
>(
contributions: Contributions,
enabledFeatureIds: readonly string[],
context: ContributionsCapabilityContext<Contributions>,
): InstalledFeatureInputs {
const enabled = new Set<string>(enabledFeatureIds);
const result: Partial<ApplicationFeatureInputs> = {};
for (const contribution of contributions) {
if (contribution[FEATURE_ADAPTER_CONTRIBUTION] !== true) {
throw new TypeError(
"Feature adapter contribution must be defined through defineFeatureAdapterContribution()",
);
}
if (!enabled.has(contribution.featureId)) continue;
const installed = invokeContribution(
contribution as unknown as FeatureAdapterContribution<
ApplicationFeatureId,
readonly FeaturePlatformCapabilityId[]
>,
context as FeaturePlatformCapabilities,
);
setFeatureInput(result, installed.featureId, installed.input);
}
return Object.freeze(result);
}
+17 -19
View File
@@ -1,4 +1,8 @@
import type { ApplicationFeatureInputs } from "../application/ports/in/application-api.ts";
import {
composeFeatureAdapterInputs,
type ContributionsCapabilityContext,
type InstalledFeatureInputs,
} from "./feature-adapter-contribution.ts";
import { REFERENCE_FEATURE_ADAPTER_CONTRIBUTION } from "./reference-feature/adapters/create-reference-feature-input.ts";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
@@ -6,28 +10,22 @@ const COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS = Object.freeze([
REFERENCE_FEATURE_ADAPTER_CONTRIBUTION,
] as const);
type FeatureAdapterContext = Parameters<
(typeof COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS)[number]["createInput"]
>[0];
type InstalledFeatureInputs = Readonly<Partial<ApplicationFeatureInputs>>;
type InstalledFeatureCapabilityContext =
ContributionsCapabilityContext<
typeof COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS
>;
/**
* Central adapter composition only selects and aggregates feature-owned
* contributions. The feature owns how its application input is bound to
* reusable platform capabilities.
* contributions. The feature owns how its application input is bound to the
* reusable platform capabilities declared by its `needs` list.
*/
export function createInstalledFeatureInputs(
context: FeatureAdapterContext,
context: InstalledFeatureCapabilityContext,
): InstalledFeatureInputs {
const entries = COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS
.filter((contribution) =>
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
)
.map((contribution) => {
const installed = contribution.createInput(context);
return [installed.featureId, installed.input] as const;
});
return Object.freeze(Object.fromEntries(entries)) as InstalledFeatureInputs;
return composeFeatureAdapterInputs(
COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS,
INSTALLED_PRODUCT_FEATURE_IDS,
context,
);
}
@@ -0,0 +1,41 @@
import type {
IndexedDbRepositoryPort,
} from "../../../application/ports/browser-file-storage/index.ts";
import {
defineFeatureAdapterContribution,
} from "../../feature-adapter-contribution.ts";
import type {} from "../../feature-adapter-contribution.ts";
import {
createLocalDraftFeatureInput,
} from "../application/local-draft-api.ts";
import type { LocalDraft } from "../domain/local-draft.ts";
import {
LOCAL_DRAFT_FEATURE_ID,
LOCAL_DRAFT_REPOSITORY_ID,
} from "../contracts/local-draft-contract.ts";
import { createLocalDraftStore } from "./local-draft-indexeddb-store.ts";
declare module "../../feature-adapter-contribution.ts" {
interface FeatureIndexedDbRepositories {
"local-draft": IndexedDbRepositoryPort<LocalDraft, never>;
}
}
export function createLocalDraftFeatureInstalledInput(context: Readonly<{
indexedDb: Readonly<{
get(repositoryId: "local-draft"): IndexedDbRepositoryPort<LocalDraft, never>;
}>;
}>) {
const repository = context.indexedDb.get(LOCAL_DRAFT_REPOSITORY_ID);
return Object.freeze({
featureId: LOCAL_DRAFT_FEATURE_ID,
input: createLocalDraftFeatureInput(createLocalDraftStore(repository)),
});
}
export const LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION =
defineFeatureAdapterContribution({
featureId: LOCAL_DRAFT_FEATURE_ID,
needs: ["indexedDb"] as const,
createInput: createLocalDraftFeatureInstalledInput,
});
@@ -0,0 +1,105 @@
import type {
BrowserDataFailure,
IndexedDbRepositoryPort,
} from "../../../application/ports/browser-file-storage/index.ts";
import {
createFailure,
type AppFailure,
type FailureKind,
} from "../../../contracts/errors.ts";
import type { Result } from "../../../contracts/result.ts";
import type {
LocalDraftStore,
LocalDraftRecord,
RemoveLocalDraftCommand,
SaveLocalDraftCommand,
} from "../application/local-draft-api.ts";
import type { LocalDraft } from "../domain/local-draft.ts";
function failureKind(failure: BrowserDataFailure): FailureKind {
if (failure.code === "QUOTA_EXCEEDED") return "STORAGE_QUOTA_EXCEEDED";
if (failure.code === "CONFLICT") return "CONFLICT";
if (failure.code === "ABORTED") return "REQUEST_ABORTED";
return "STORAGE_UNAVAILABLE";
}
function mapFailure(
failure: BrowserDataFailure,
operationId: string,
): AppFailure {
return createFailure(failureKind(failure), operationId, 0, {
code: `INDEXEDDB_${failure.code}`,
});
}
function failed<Value>(
failure: BrowserDataFailure,
operationId: string,
): Result<Value> {
return Object.freeze({
ok: false as const,
error: mapFailure(failure, operationId),
});
}
/**
* Feature-owned adapter over the reusable IndexedDB repository capability.
* Native IDB objects, migrations, connection leases and quota internals do not
* cross this file.
*/
export function createLocalDraftStore(
repository: IndexedDbRepositoryPort<LocalDraft, never>,
): LocalDraftStore {
return Object.freeze({
async save(command: SaveLocalDraftCommand, signal?: AbortSignal) {
const result = await repository.compareAndSwap({
key: command.draft.draftId,
value: command.draft,
expectedRevision: command.expectedRevision,
idempotencyKey: command.idempotencyKey,
signal,
});
return result.ok
? Object.freeze({
ok: true as const,
value: Object.freeze({ revision: result.value.revision }),
})
: failed<Readonly<{ revision: number }>>(
result.error,
"LOCAL_DRAFT_SAVE",
);
},
async find(draftId: string, signal?: AbortSignal) {
const result = await repository.read(draftId, signal);
if (!result.ok) {
return failed<LocalDraftRecord | null>(
result.error,
"LOCAL_DRAFT_FIND",
);
}
return Object.freeze({
ok: true as const,
value:
result.value === null
? null
: Object.freeze({
draft: result.value.value,
revision: result.value.revision,
}),
});
},
async remove(command: RemoveLocalDraftCommand, signal?: AbortSignal) {
const result = await repository.remove({
key: command.draftId,
expectedRevision: command.expectedRevision,
idempotencyKey: command.idempotencyKey,
signal,
});
return result.ok
? Object.freeze({ ok: true as const, value: undefined })
: failed<void>(result.error, "LOCAL_DRAFT_REMOVE");
},
});
}
@@ -0,0 +1,72 @@
import type { Result } from "../../../contracts/result.ts";
import type {} from "../../../application/ports/in/application-api.ts";
import type { LocalDraft } from "../domain/local-draft.ts";
export type LocalDraftRecord = Readonly<{
draft: LocalDraft;
revision: number;
}>;
export type SaveLocalDraftCommand = Readonly<{
draft: LocalDraft;
expectedRevision: number | null;
idempotencyKey: string;
}>;
export type RemoveLocalDraftCommand = Readonly<{
draftId: string;
expectedRevision: number;
idempotencyKey: string;
}>;
export type LocalDraftStore = Readonly<{
save(
command: SaveLocalDraftCommand,
signal?: AbortSignal,
): Promise<Result<Readonly<{ revision: number }>>>;
find(
draftId: string,
signal?: AbortSignal,
): Promise<Result<LocalDraftRecord | null>>;
remove(
command: RemoveLocalDraftCommand,
signal?: AbortSignal,
): Promise<Result<void>>;
}>;
export type LocalDraftFeatureInput = Readonly<{
saveDraft(
command: SaveLocalDraftCommand,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<Result<Readonly<{ revision: number }>>>;
findDraft(
draftId: string,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<Result<LocalDraftRecord | null>>;
removeDraft(
command: RemoveLocalDraftCommand,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<Result<void>>;
}>;
declare module "../../../application/ports/in/application-api.ts" {
interface ApplicationFeatureInputs {
"local-draft": LocalDraftFeatureInput;
}
}
export function createLocalDraftFeatureInput(
store: LocalDraftStore,
): LocalDraftFeatureInput {
return Object.freeze({
saveDraft(command, context) {
return store.save(command, context?.signal);
},
findDraft(draftId, context) {
return store.find(draftId, context?.signal);
},
removeDraft(command, context) {
return store.remove(command, context?.signal);
},
});
}
@@ -0,0 +1,2 @@
export const LOCAL_DRAFT_FEATURE_ID = "local-draft" as const;
export const LOCAL_DRAFT_REPOSITORY_ID = "local-draft" as const;
@@ -0,0 +1,21 @@
export type LocalDraft = Readonly<{
draftId: string;
title: string;
body: string;
}>;
export function createLocalDraft(values: LocalDraft): LocalDraft {
const draftId = values.draftId.trim();
const title = values.title.trim();
if (!draftId || !title) {
throw new TypeError("Local draft requires draftId and title");
}
if (title.length > 160 || values.body.length > 100_000) {
throw new TypeError("Local draft exceeds bounded field limits");
}
return Object.freeze({
draftId,
title,
body: values.body,
});
}
+4 -2
View File
@@ -19,8 +19,10 @@ augmentation으로 `"reference-feature": ReferenceFeatureInput`을 기여하므
별도 cast나 runtime shape 확인 없이 정확한 input type을 받는다. 다만 동적 호출로
설치되지 않은 ID가 들어오는 경우를 위해 `get`의 runtime guard도 유지한다.
예측 가능한 실패는 `src/application/result.ts`의 공통
`Result<Value, Failure = AppFailure>`로 반환한다. `AppFailure.kind`
예측 가능한 실패는 canonical shared contract인
`src/contracts/result.ts``Result<Value, Failure = AppFailure>`로 반환한다.
`src/application/result.ts`는 기존 호출자를 위한 deprecated migration shim일
뿐 새 코드의 import 경로가 아니다. `AppFailure.kind`
`ERROR_REGISTRY` key에서 파생된 닫힌 vocabulary이며, transport 호환 이름인
`ApiFailure`는 같은 type의 alias다.
@@ -2,6 +2,9 @@ import {
createFeatureHttpBinding,
type InstalledHttpOperationExecutor,
} from "../../../adapters/http/index.ts";
import {
defineFeatureAdapterContribution,
} from "../../feature-adapter-contribution.ts";
import { createReferenceFeatureInput } from "../application/reference-feature-api.ts";
import {
REFERENCE_FEATURE_ID,
@@ -17,15 +20,14 @@ export type InstalledContractOperationExecutor =
/**
* Feature-owned composition seam.
*
* The feature contributes typed operation descriptors and its application
* gateway. HTTP lifecycle/error normalization stays in the reusable capability
* binding rather than being repeated by every product feature.
* The feature receives only the HTTP capability it declares in `needs`.
* HTTP lifecycle/error normalization stays in the reusable capability binding.
*/
export function createReferenceFeatureInstalledInput(context: Readonly<{
contractOperations: InstalledHttpOperationExecutor;
http: InstalledHttpOperationExecutor;
}>) {
const http = createFeatureHttpBinding(
context.contractOperations,
context.http,
REFERENCE_HTTP_OPERATIONS,
);
@@ -35,7 +37,9 @@ export function createReferenceFeatureInstalledInput(context: Readonly<{
});
}
export const REFERENCE_FEATURE_ADAPTER_CONTRIBUTION = Object.freeze({
featureId: REFERENCE_FEATURE_ID,
createInput: createReferenceFeatureInstalledInput,
});
export const REFERENCE_FEATURE_ADAPTER_CONTRIBUTION =
defineFeatureAdapterContribution({
featureId: REFERENCE_FEATURE_ID,
needs: ["http"] as const,
createInput: createReferenceFeatureInstalledInput,
});
@@ -1,40 +1,57 @@
import {
defineFeatureHttpOperation,
defineFeatureHttpOperationForRoutes,
type FeatureHttpBinding,
} from "../../../adapters/http/index.ts";
import type {
ReferenceCreateCommand,
ReferenceGateway,
ReferenceListFilters,
} from "../application/reference-feature-api.ts";
import type { ReferenceResource } from "../domain/reference-resource.ts";
import {
CREATE_REFERENCE_RESOURCE_CONTRACT,
GET_REFERENCE_RESOURCE_CONTRACT,
LIST_REFERENCE_RESOURCES_CONTRACT,
} from "../contracts/reference-feature-contract-contribution.ts";
import {
REFERENCE_FEATURE_CONTRACT,
} from "../contracts/reference-feature-contract.ts";
import {
mapReferenceResourceListPayload,
mapReferenceResourcePayload,
} from "../contracts/reference-mapper.ts";
import type {
ReferenceGateway,
} from "../application/reference-feature-api.ts";
export type ReferenceFeatureRouteId =
keyof typeof REFERENCE_FEATURE_CONTRACT.routes;
const defineReferenceHttpOperation =
defineFeatureHttpOperationForRoutes<ReferenceFeatureRouteId>();
export const REFERENCE_HTTP_OPERATIONS = Object.freeze({
LIST_REFERENCE_RESOURCES: defineFeatureHttpOperation<
ReferenceListFilters,
readonly ReferenceResource[]
>({
operationId: "LIST_REFERENCE_RESOURCES",
LIST_REFERENCE_RESOURCES: defineReferenceHttpOperation({
contract: LIST_REFERENCE_RESOURCES_CONTRACT,
routeId: "REFERENCE_RESOURCE_LIST",
mapSuccess: mapReferenceResourceListPayload,
}),
CREATE_REFERENCE_RESOURCE: defineFeatureHttpOperation<
ReferenceCreateCommand,
ReferenceResource
>({
operationId: "CREATE_REFERENCE_RESOURCE",
CREATE_REFERENCE_RESOURCE: defineReferenceHttpOperation({
contract: CREATE_REFERENCE_RESOURCE_CONTRACT,
routeId: "REFERENCE_RESOURCE_LIST",
mapSuccess: mapReferenceResourcePayload,
mapProblem(problem, metadata) {
if (metadata.status === 409) {
return Object.freeze({
kind: "CONFLICT" as const,
code: problem.code ?? "REFERENCE_RESOURCE_CONFLICT",
});
}
if (metadata.status === 422) {
return Object.freeze({
kind: "VALIDATION_REJECTED" as const,
code: problem.code ?? "REFERENCE_RESOURCE_REJECTED",
});
}
return undefined;
},
}),
GET_REFERENCE_RESOURCE: defineFeatureHttpOperation<
Readonly<{ resourceId: string }>,
ReferenceResource
>({
operationId: "GET_REFERENCE_RESOURCE",
GET_REFERENCE_RESOURCE: defineReferenceHttpOperation({
contract: GET_REFERENCE_RESOURCE_CONTRACT,
routeId: "REFERENCE_RESOURCE_DETAIL",
mapSuccess: mapReferenceResourcePayload,
}),
@@ -2,17 +2,22 @@ import { z } from "zod";
import type {
CommandEffectDescriptor,
HttpRequestProjection,
InstalledContractContribution,
InstalledHttpContract,
RuntimeValidator,
} from "../../../contracts/external-contract-runtime.ts";
import { REFERENCE_FEATURE_ID } from "./reference-feature-contract.ts";
import {
createReferenceResourceCommandSchema,
referenceProblemSchema,
referenceResourceDtoSchema,
referenceResourceListQuerySchema,
referenceResourceParamsSchema,
type ReferenceProblem,
} from "./reference-schemas.ts";
export type { ReferenceProblem } from "./reference-schemas.ts";
/**
* §4.8. The single `TEMPLATE_FIXTURE` contribution. It keeps the reference
* HTTP vertical executable as deterministic template data and is excluded from
@@ -53,33 +58,10 @@ function zodValidator<T>(
});
}
const referenceResourceDto = z
.object({
id: z.string().min(1).max(120),
name: z.string().min(1).max(240),
createdAt: z.string().min(1).optional(),
})
.strip();
const problemSchema = z
.object({
type: z.string().min(1).max(512),
title: z.string().min(1).max(240),
status: z.int().min(100).max(599),
code: z.string().min(1).max(120).optional(),
})
.strip();
export type ReferenceProblem = z.output<typeof problemSchema>;
const PROBLEM_VALIDATOR = zodValidator("ReferenceProblem", problemSchema);
const createCommandSchema = z
.object({
name: z.string().trim().min(1).max(120),
note: z.string().trim().max(500).optional(),
})
.strict();
const PROBLEM_VALIDATOR = zodValidator(
"ReferenceProblem",
referenceProblemSchema,
);
/**
* Fixture-side classifier standing in for the package-provided pure bounded
@@ -87,17 +69,17 @@ const createCommandSchema = z
*/
const CREATE_EFFECT: CommandEffectDescriptor<ReferenceProblem> = Object.freeze({
successEffect: "APPLIED_CONFIRMED" as const,
classifyProblem({ status }: Readonly<{ status: number; problem: ReferenceProblem }>) {
if (status === 400 || status === 409 || status === 422) return "NOT_APPLIED";
classifyProblem({
status,
}: Readonly<{ status: number; problem: ReferenceProblem }>) {
if (status === 400 || status === 409 || status === 422) {
return "NOT_APPLIED";
}
return "MAYBE_APPLIED";
},
});
const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
z.output<typeof referenceResourceListQuerySchema>,
readonly z.output<typeof referenceResourceDto>[],
ReferenceProblem
> = Object.freeze({
export const LIST_REFERENCE_RESOURCES_CONTRACT = Object.freeze({
contract: Object.freeze({
operationId: "LIST_REFERENCE_RESOURCES",
method: "GET" as const,
@@ -108,7 +90,7 @@ const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
),
outputValidator: zodValidator(
"ReferenceResourceListPayload",
z.array(referenceResourceDto).max(100),
z.array(referenceResourceDtoSchema).max(100),
),
problemValidator: PROBLEM_VALIDATOR,
acceptedStatuses: Object.freeze([200]),
@@ -139,13 +121,13 @@ const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
authProfileId: "REFERENCE_EXTERNAL_BEARER",
diagnosticsOperation: "reference.list",
}),
});
const GET_REFERENCE_RESOURCE: InstalledHttpContract<
z.output<typeof referenceResourceParamsSchema>,
z.output<typeof referenceResourceDto>,
}) satisfies InstalledHttpContract<
z.output<typeof referenceResourceListQuerySchema>,
readonly z.output<typeof referenceResourceDtoSchema>[],
ReferenceProblem
> = Object.freeze({
>;
export const GET_REFERENCE_RESOURCE_CONTRACT = Object.freeze({
contract: Object.freeze({
operationId: "GET_REFERENCE_RESOURCE",
method: "GET" as const,
@@ -156,7 +138,7 @@ const GET_REFERENCE_RESOURCE: InstalledHttpContract<
),
outputValidator: zodValidator(
"ReferenceResourcePayload",
referenceResourceDto,
referenceResourceDtoSchema,
),
problemValidator: PROBLEM_VALIDATOR,
acceptedStatuses: Object.freeze([200]),
@@ -183,24 +165,24 @@ const GET_REFERENCE_RESOURCE: InstalledHttpContract<
authProfileId: "REFERENCE_EXTERNAL_BEARER",
diagnosticsOperation: "reference.detail",
}),
});
const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
z.output<typeof createCommandSchema>,
z.output<typeof referenceResourceDto>,
}) satisfies InstalledHttpContract<
z.output<typeof referenceResourceParamsSchema>,
z.output<typeof referenceResourceDtoSchema>,
ReferenceProblem
> = Object.freeze({
>;
export const CREATE_REFERENCE_RESOURCE_CONTRACT = Object.freeze({
contract: Object.freeze({
operationId: "CREATE_REFERENCE_RESOURCE",
method: "POST" as const,
pathTemplate: "/api/reference-resources",
inputValidator: zodValidator(
"CreateReferenceResourceCommand",
createCommandSchema,
createReferenceResourceCommandSchema,
),
outputValidator: zodValidator(
"ReferenceResourcePayload",
referenceResourceDto,
referenceResourceDtoSchema,
),
problemValidator: PROBLEM_VALIDATOR,
acceptedStatuses: Object.freeze([200, 201]),
@@ -213,7 +195,7 @@ const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
operationIdentityField: "idempotencyKey",
}),
commandEffect: CREATE_EFFECT,
projectRequest(input: z.output<typeof createCommandSchema>) {
projectRequest(input: z.output<typeof createReferenceResourceCommandSchema>) {
return Object.freeze({
pathValues: Object.freeze({}),
queryEntries: Object.freeze([]),
@@ -235,7 +217,11 @@ const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
authProfileId: "REFERENCE_EXTERNAL_BEARER",
diagnosticsOperation: "reference.create",
}),
});
}) satisfies InstalledHttpContract<
z.output<typeof createReferenceResourceCommandSchema>,
z.output<typeof referenceResourceDtoSchema>,
ReferenceProblem
>;
export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribution =
Object.freeze({
@@ -247,9 +233,9 @@ export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribut
revision: 1 as const,
}),
http: Object.freeze([
LIST_REFERENCE_RESOURCES,
GET_REFERENCE_RESOURCE,
CREATE_REFERENCE_RESOURCE,
LIST_REFERENCE_RESOURCES_CONTRACT,
GET_REFERENCE_RESOURCE_CONTRACT,
CREATE_REFERENCE_RESOURCE_CONTRACT,
]) as readonly InstalledHttpContract<unknown, unknown, unknown>[],
events: Object.freeze([]),
});
@@ -8,6 +8,7 @@ import {
type InstalledBoundaryMapper,
type MappingResult,
} from "../../../contracts/boundary-mapper.ts";
import type { ReferenceResourceDto } from "./reference-schemas.ts";
export type ReferenceResourceView = Readonly<{
resourceId: string;
@@ -16,22 +17,19 @@ export type ReferenceResourceView = Readonly<{
optimistic?: boolean;
}>;
/**
* Operation-specific mapper. The HTTP contract has already validated the wire
* shape, so this mapper owns only wire -> domain construction.
*/
export function mapReferenceResourcePayload(
value: unknown,
dto: ReferenceResourceDto,
): MappingResult<ReferenceResource> {
if (!value || typeof value !== "object") {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
const dto = value as Record<string, unknown>;
if (typeof dto.id !== "string" || typeof dto.name !== "string") {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
try {
return mappingSuccess(
createReferenceResource({
id: dto.id,
displayName: dto.name,
createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null,
createdAt: dto.createdAt ?? null,
}),
);
} catch {
@@ -40,14 +38,8 @@ export function mapReferenceResourcePayload(
}
export function mapReferenceResourceListPayload(
payload: unknown,
payload: readonly ReferenceResourceDto[],
): MappingResult<readonly ReferenceResource[]> {
if (!Array.isArray(payload)) {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
if (payload.length > 100) {
return mappingFailure("OUTPUT_LIMIT_EXCEEDED");
}
const output: ReferenceResource[] = [];
for (const item of payload) {
const mapped = mapReferenceResourcePayload(item);
@@ -57,22 +49,58 @@ export function mapReferenceResourceListPayload(
return mappingSuccess(Object.freeze(output));
}
function isReferenceResourceDto(value: unknown): value is ReferenceResourceDto {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const dto = value as Record<string, unknown>;
return (
typeof dto.id === "string" &&
typeof dto.name === "string" &&
(dto.createdAt === undefined || typeof dto.createdAt === "string")
);
}
function mapUnknownReferenceResourcePayload(
value: unknown,
): MappingResult<ReferenceResource> {
return isReferenceResourceDto(value)
? mapReferenceResourcePayload(value)
: mappingFailure("MAPPING_INVARIANT_REJECTED");
}
function mapUnknownReferenceResourceListPayload(
value: unknown,
): MappingResult<readonly ReferenceResource[]> {
if (
!Array.isArray(value) ||
value.length > 100 ||
!value.every(isReferenceResourceDto)
) {
return mappingFailure(
Array.isArray(value) && value.length > 100
? "OUTPUT_LIMIT_EXCEEDED"
: "MAPPING_INVARIANT_REJECTED",
);
}
return mapReferenceResourceListPayload(value);
}
/**
* Registry-facing compatibility projection. Feature adapters should prefer the
* operation-specific mapper functions above so their result type is exact.
* Registry-facing compatibility projection. The registry is intentionally
* untyped, so its adapter performs the defensive shape check. Feature HTTP
* bindings use the typed operation-specific mappers above.
*/
export function mapReferenceOperation(
operationId: string,
payload: unknown,
): MappingResult<ReferenceResource | readonly ReferenceResource[]> {
if (operationId === "LIST_REFERENCE_RESOURCES") {
return mapReferenceResourceListPayload(payload);
return mapUnknownReferenceResourceListPayload(payload);
}
if (
operationId === "CREATE_REFERENCE_RESOURCE" ||
operationId === "GET_REFERENCE_RESOURCE"
) {
return mapReferenceResourcePayload(payload);
return mapUnknownReferenceResourcePayload(payload);
}
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
@@ -85,7 +113,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({
outputContractId: "ReferenceResourceList",
owner: "feature-frontend-reference-feature-vertical-slice",
maxOutputItems: 100,
map: mapReferenceResourceListPayload,
map: mapUnknownReferenceResourceListPayload,
}),
ReferenceResourceMapper: Object.freeze({
mapperId: "ReferenceResourceMapper",
@@ -94,7 +122,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({
outputContractId: "ReferenceResource",
owner: "feature-frontend-reference-feature-vertical-slice",
maxOutputItems: 1,
map: mapReferenceResourcePayload,
map: mapUnknownReferenceResourcePayload,
}),
} satisfies Readonly<Record<string, InstalledBoundaryMapper>>);
@@ -13,7 +13,7 @@ export const referenceResourceListQuerySchema = z
: Array.isArray(value)
? value
: [value],
z.array(z.string().trim().min(1)),
z.array(z.string().trim().min(1)).readonly(),
)
.optional(),
})
@@ -25,7 +25,7 @@ export const referenceResourceParamsSchema = z
})
.strict();
const referenceResourceDtoSchema = z
export const referenceResourceDtoSchema = z
.object({
id: z.string().min(1).max(120),
name: z.string().min(1).max(240),
@@ -33,6 +33,28 @@ const referenceResourceDtoSchema = z
})
.strip();
export type ReferenceResourceDto = z.output<
typeof referenceResourceDtoSchema
>;
export const referenceProblemSchema = z
.object({
type: z.string().min(1).max(512),
title: z.string().min(1).max(240),
status: z.int().min(100).max(599),
code: z.string().min(1).max(120).optional(),
})
.strip();
export type ReferenceProblem = z.output<typeof referenceProblemSchema>;
export const createReferenceResourceCommandSchema = z
.object({
name: z.string().trim().min(1).max(120),
note: z.string().trim().max(500).optional(),
})
.strict();
const payloadSchemas = {
ReferenceResourceListPayload: z.array(referenceResourceDtoSchema).max(100),
ReferenceResourcePayload: referenceResourceDtoSchema,
@@ -42,12 +64,7 @@ const requestSchemas = {
ReferenceResourceParams: referenceResourceParamsSchema,
ReferenceResourceListQuery: referenceResourceListQuerySchema,
NoRequest: z.object({}).strict(),
CreateReferenceResourceCommand: z
.object({
name: z.string().trim().min(1).max(120),
note: z.string().trim().max(500).optional(),
})
.strict(),
CreateReferenceResourceCommand: createReferenceResourceCommandSchema,
} satisfies Record<string, z.ZodType>;
function project(result: z.ZodSafeParseResult<unknown>) {
@@ -18,13 +18,13 @@ function databaseName(_prefix: string): string {
return `ca-idb-v1:a${token.slice(0, 31)}.n${token.slice(1)}.p${token.split("").reverse().join("").slice(0, 31)}`;
}
async function installLifecycleRuntime(
async function startLifecycleRuntime(
page: Page,
name: string,
targetVersion: 1 | 2,
blockedTimeoutMs = 1_000,
) {
return await page.evaluate(
): Promise<void> {
await page.evaluate(
async ({
currentDatabaseName,
currentTargetVersion,
@@ -173,8 +173,12 @@ async function installLifecycleRuntime(
__indexedDbLifecycleRuntime?: unknown;
}
).__indexedDbLifecycleRuntime = runtime;
const opened = await runtime.open();
return { opened, status: runtime.getStatus() };
const opening = runtime.open();
(
globalThis as unknown as {
__indexedDbLifecycleOpening?: Promise<unknown>;
}
).__indexedDbLifecycleOpening = opening;
},
{
currentDatabaseName: name,
@@ -184,6 +188,35 @@ async function installLifecycleRuntime(
);
}
async function settleLifecycleRuntime(page: Page): Promise<unknown> {
return await page.evaluate(async () => {
const state = globalThis as unknown as {
__indexedDbLifecycleRuntime?: {
getStatus(): unknown;
};
__indexedDbLifecycleOpening?: Promise<unknown>;
};
if (!state.__indexedDbLifecycleRuntime || !state.__indexedDbLifecycleOpening) {
throw new Error("IndexedDB lifecycle runtime is not installed.");
}
const opened = await state.__indexedDbLifecycleOpening;
return {
opened,
status: state.__indexedDbLifecycleRuntime.getStatus(),
};
});
}
async function installLifecycleRuntime(
page: Page,
name: string,
targetVersion: 1 | 2,
blockedTimeoutMs = 1_000,
): Promise<unknown> {
await startLifecycleRuntime(page, name, targetVersion, blockedTimeoutMs);
return await settleLifecycleRuntime(page);
}
async function lifecycleStatus(page: Page): Promise<unknown> {
return await page.evaluate(() => {
const runtime = (
@@ -977,12 +1010,16 @@ test("fails a blocked v2 upgrade closed, then closes its late connection after t
);
expect(blocker).toBe("OPEN");
const blocked = await installLifecycleRuntime(
page,
name,
2,
25,
);
await startLifecycleRuntime(page, name, 2, 500);
await expect
.poll(async () => await lifecycleStatus(page))
.toEqual({
kind: "BLOCKED",
currentVersion: 1,
targetVersion: 2,
});
const blocked = await settleLifecycleRuntime(page);
expect(blocked).toMatchObject({
opened: {
ok: false,
@@ -992,9 +1029,8 @@ test("fails a blocked v2 upgrade closed, then closes its late connection after t
},
},
status: {
kind: "BLOCKED",
currentVersion: 1,
targetVersion: 2,
kind: "CLOSED",
reason: "NOT_OPENED",
},
});
@@ -0,0 +1,244 @@
import type {
IndexedDbRuntimeDependencies,
} from "../../src/adapters/storage/indexeddb/indexeddb-types.ts";
import type { LocalDraft } from "../../src/features/local-draft-feature/domain/local-draft.ts";
import {
expect,
test,
} from "../support/browser/strict-browser-test.ts";
test("composes the local-draft feature over the real browser IndexedDB runtime", async ({
page,
}) => {
await page.route("**/favicon.ico", (route) =>
route.fulfill({ status: 204 }),
);
await page.goto("/config.json");
const result = await page.evaluate(async () => {
const indexedDbModulePath =
"/src/adapters/storage/indexeddb/index.ts";
const contributionModulePath =
"/src/features/feature-adapter-contribution.ts";
const localDraftModulePath =
"/src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts";
const {
createIndexedDbRuntime,
indexedDbPhysicalDatabaseName,
} = (await import(
/* @vite-ignore */ indexedDbModulePath
)) as typeof import("../../src/adapters/storage/indexeddb/index.ts");
const {
composeFeatureAdapterInputs,
createIndexedDbRepositoryProvider,
} = (await import(
/* @vite-ignore */ contributionModulePath
)) as typeof import("../../src/features/feature-adapter-contribution.ts");
const {
LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION,
} = (await import(
/* @vite-ignore */ localDraftModulePath
)) as typeof import("../../src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts");
const suffix = crypto.randomUUID().replaceAll("-", "");
const scope = Object.freeze({
authorityToken: `a${suffix.slice(0, 31)}`,
namespaceToken: `n${suffix.slice(1, 32)}`,
partitionToken: `p${[...suffix].reverse().join("").slice(0, 31)}`,
accountScope: "ORIGIN_SHARED" as const,
});
const storagePolicy = Object.freeze({
owner: "local-draft-feature",
namespace: "local-draft",
classification: "INTERNAL" as const,
authority: "LOCAL_FIRST" as const,
accountScope: "ORIGIN_SHARED" as const,
retention: Object.freeze({ kind: "EXPLICIT_DELETE" as const }),
softBudgetBytes: 1_000_000,
hardBudgetBytes: 2_000_000,
evictionPriority: "USER_AUTHORED" as const,
logoutAction: "KEEP_ORIGIN_SHARED" as const,
accountDeletionAction: "KEEP_ORIGIN_SHARED" as const,
pressureAction: "RETAIN" as const,
unavailableFallback: "READ_ONLY" as const,
});
const config: IndexedDbRuntimeDependencies<
LocalDraft,
LocalDraft,
never
> = {
scope,
storagePolicy,
schemaVersion: 1,
recordStore: "records",
governanceStore: "governance",
retentionStore: "retention",
retentionEligibilityIndex: "by-eligibility",
lifecycleMetadataStores: [],
idempotencyStore: "receipts",
idempotencyExpiryIndex: "by-expiry",
receiptRetentionMs: 60_000,
maxIdempotencyReceipts: 100,
migrations: [
{
id: "local-draft-schema-v1",
fromVersion: 0,
toVersion: 1,
operations: [
{
kind: "CREATE_STORE",
name: "governance",
keyPath: "bindingKey",
},
{
kind: "CREATE_STORE",
name: "retention",
keyPath: "recordKey",
indexes: [
{
name: "by-eligibility",
keyPath: "eligibleAtEpochMs",
},
],
},
{
kind: "CREATE_STORE",
name: "records",
keyPath: "key",
},
{
kind: "CREATE_STORE",
name: "receipts",
keyPath: "idempotencyKey",
indexes: [
{
name: "by-expiry",
keyPath: "expiresAtEpochMs",
},
],
},
],
},
],
codec: {
currentVersion: 1,
encode: (value) => ({ ok: true, value }),
measureStoredBytes: (value) =>
new TextEncoder().encode(JSON.stringify(value)).byteLength,
decode: (version, value) => {
if (
version !== 1 ||
value === null ||
typeof value !== "object"
) {
return { ok: false };
}
const candidate = value as Partial<LocalDraft>;
return typeof candidate.draftId === "string" &&
typeof candidate.title === "string" &&
typeof candidate.body === "string"
? { ok: true, value: candidate as LocalDraft }
: { ok: false };
},
fingerprint: async (value) => {
const bytes = new TextEncoder().encode(
JSON.stringify([value.draftId, value.title, value.body]),
);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(
new Uint8Array(digest),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
},
},
queryPolicy: {
plan: () => ({ limit: 1 }),
},
authorizeLifecycle: () => ({
authorized: true,
proofToken: "localdraftauthorityproof_001",
}),
};
const runtime = createIndexedDbRuntime(config);
const databaseName = indexedDbPhysicalDatabaseName(scope);
const opened = await runtime.open();
try {
const indexedDb = createIndexedDbRepositoryProvider(
Object.freeze({ "local-draft": runtime }),
);
const composed = composeFeatureAdapterInputs(
Object.freeze([LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION] as const),
Object.freeze(["local-draft"]),
Object.freeze({ indexedDb }),
);
const localDraft = composed["local-draft"];
if (!localDraft) {
throw new Error("local-draft feature input was not composed");
}
const draft = Object.freeze({
draftId: "draft-1",
title: "Architecture notes",
body: "Stored through the reusable IndexedDB capability.",
});
const saved = await localDraft.saveDraft({
draft,
expectedRevision: null,
idempotencyKey: "local-draft-create-1",
});
const found = await localDraft.findDraft(draft.draftId);
const removed = await localDraft.removeDraft({
draftId: draft.draftId,
expectedRevision: saved.ok ? saved.value.revision : 1,
idempotencyKey: "local-draft-remove-1",
});
const afterRemove = await localDraft.findDraft(draft.draftId);
return {
opened,
saved,
found,
removed,
afterRemove,
composedFeatureIds: Object.keys(composed),
runtimeStatus: runtime.getStatus(),
};
} finally {
runtime.close();
await new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase(databaseName);
request.onsuccess = () => resolve();
request.onerror = () =>
reject(request.error ?? new Error("IndexedDB cleanup failed"));
request.onblocked = () =>
reject(new Error("IndexedDB cleanup was blocked"));
});
}
});
expect(result.opened).toEqual({ ok: true, value: undefined });
expect(result.composedFeatureIds).toEqual(["local-draft"]);
expect(result.saved).toEqual({ ok: true, value: { revision: 1 } });
expect(result.found).toMatchObject({
ok: true,
value: {
draft: {
draftId: "draft-1",
title: "Architecture notes",
body: "Stored through the reusable IndexedDB capability.",
},
revision: 1,
},
});
expect(result.removed).toEqual({ ok: true, value: undefined });
expect(result.afterRemove).toEqual({ ok: true, value: null });
expect(result.runtimeStatus).toMatchObject({
kind: "READY",
schemaVersion: 1,
});
});
@@ -1,29 +1,48 @@
import { expect, test } from "../support/browser/strict-browser-test.ts";
test("executes the storage durability adapter against the real browser StorageManager", async ({
test("executes the storage durability adapter against the real browser StorageManager capability", async ({
page,
}) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const observation = await page.evaluate(async () => {
const modulePath =
"/src/adapters/browser-file-storage/storage-manager-adapter.ts";
const { createStorageDurabilityAdapter } = await import(
/* @vite-ignore */ modulePath
);
const hasStorageManager = navigator.storage !== undefined;
const adapter = createStorageDurabilityAdapter(navigator.storage);
return adapter.inspect();
return {
hasStorageManager,
result: await adapter.inspect(),
};
});
expect(result.ok).toBe(true);
if (result.ok) {
if (!observation.hasStorageManager) {
expect(observation.result).toEqual({
ok: false,
error: {
code: "UNSUPPORTED",
operation: "STORAGE_ESTIMATE",
retryable: false,
recovery: "ONLINE_ONLY",
},
});
return;
}
expect(observation.result.ok).toBe(true);
if (observation.result.ok) {
expect(["UNKNOWN", "NORMAL", "PRESSURE", "CRITICAL"]).toContain(
result.value.pressure,
observation.result.value.pressure,
);
expect(
result.value.usageBytes === null || result.value.usageBytes >= 0,
observation.result.value.usageBytes === null ||
observation.result.value.usageBytes >= 0,
).toBe(true);
expect(
result.value.quotaBytes === null || result.value.quotaBytes >= 0,
observation.result.value.quotaBytes === null ||
observation.result.value.quotaBytes >= 0,
).toBe(true);
}
});
+3 -1
View File
@@ -71,7 +71,9 @@ describe("generic application router", () => {
await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }),
).toBeVisible();
expect(window.location.pathname).toBe("/examples/ui");
expect(document.title).toBe("UI 구성요소 · Frontend Skeleton");
await waitFor(() =>
expect(document.title).toBe("UI 구성요소 · Frontend Skeleton"),
);
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import type {
IndexedDbRepositoryPort,
} from "../../../src/application/ports/browser-file-storage/index.ts";
import type {
InstalledHttpOperationExecutor,
} from "../../../src/adapters/http/index.ts";
import {
composeFeatureAdapterInputs,
defineFeatureAdapterContribution,
type IndexedDbRepositoryProvider,
} from "../../../src/features/feature-adapter-contribution.ts";
import {
LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION,
} from "../../../src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts";
import type { LocalDraft } from "../../../src/features/local-draft-feature/domain/local-draft.ts";
type TestHttpFeatureInput = Readonly<{
ping(): Promise<"pong">;
}>;
declare module "../../../src/application/ports/in/application-api.ts" {
interface ApplicationFeatureInputs {
"test-http-feature": TestHttpFeatureInput;
}
}
const TEST_HTTP_FEATURE_ADAPTER_CONTRIBUTION =
defineFeatureAdapterContribution({
featureId: "test-http-feature",
needs: ["http"] as const,
createInput() {
return Object.freeze({
featureId: "test-http-feature" as const,
input: Object.freeze({
async ping() {
return "pong" as const;
},
}),
});
},
});
function localDraftRepository() {
let stored: Readonly<{ value: LocalDraft; revision: number }> | null = null;
const repository = Object.freeze({
async compareAndSwap(input: Readonly<{ value: LocalDraft }>) {
stored = Object.freeze({ value: input.value, revision: 1 });
return Object.freeze({
ok: true as const,
value: Object.freeze({ revision: 1 }),
});
},
async read() {
return Object.freeze({ ok: true as const, value: stored });
},
async remove() {
stored = null;
return Object.freeze({
ok: true as const,
value: Object.freeze({ revision: 2 }),
});
},
}) as unknown as IndexedDbRepositoryPort<LocalDraft, never>;
return repository;
}
describe("feature adapter composition", () => {
it("composes HTTP-only and IndexedDB-only concrete contributions together", async () => {
const repository = localDraftRepository();
const get = ((repositoryId: string) => {
expect(repositoryId).toBe("local-draft");
return repository;
}) as unknown as IndexedDbRepositoryProvider["get"];
const indexedDb = Object.freeze({ get }) satisfies IndexedDbRepositoryProvider;
const http = Object.freeze({
execute: async () => {
throw new Error("HTTP should not execute during composition");
},
}) as unknown as InstalledHttpOperationExecutor;
const inputs = composeFeatureAdapterInputs(
Object.freeze([
TEST_HTTP_FEATURE_ADAPTER_CONTRIBUTION,
LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION,
] as const),
Object.freeze(["test-http-feature", "local-draft"]),
Object.freeze({ http, indexedDb }),
);
expect(Object.keys(inputs).sort()).toEqual([
"local-draft",
"test-http-feature",
]);
await expect(inputs["test-http-feature"]?.ping()).resolves.toBe("pong");
const localDraft = inputs["local-draft"];
if (!localDraft) throw new Error("local-draft input was not composed");
const draft = Object.freeze({
draftId: "draft-1",
title: "Local draft",
body: "Body",
});
await expect(
localDraft.saveDraft({
draft,
expectedRevision: null,
idempotencyKey: "save-draft-1",
}),
).resolves.toEqual({ ok: true, value: { revision: 1 } });
await expect(localDraft.findDraft("draft-1")).resolves.toEqual({
ok: true,
value: { draft, revision: 1 },
});
});
});
@@ -3,57 +3,129 @@ import { describe, expect, it, vi } from "vitest";
import {
createFeatureHttpBinding,
defineFeatureHttpOperation,
type HttpExecutionOutcome,
type InstalledHttpOperationExecutor,
} from "../../../src/adapters/http/index.ts";
import {
mappingFailure,
mappingSuccess,
} from "../../../src/contracts/boundary-mapper.ts";
import type {
InstalledHttpContract,
RuntimeValidator,
} from "../../../src/contracts/external-contract-runtime.ts";
type ResourceInput = Readonly<{ resourceId: string }>;
type ResourceWire = Readonly<{ id: string; title: string }>;
type ResourceProblem = Readonly<{ code?: string }>;
type Resource = Readonly<{ id: string; title: string }>;
const OPERATIONS = Object.freeze({
LOAD_RESOURCE: defineFeatureHttpOperation<
Readonly<{ resourceId: string }>,
Resource
>({
function validator<T>(schemaId: string): RuntimeValidator<T> {
return Object.freeze({
schemaId,
safeParse(value: unknown) {
return Object.freeze({
success: true as const,
data: value as T,
});
},
});
}
const LOAD_RESOURCE_CONTRACT = Object.freeze({
contract: Object.freeze({
operationId: "LOAD_RESOURCE",
method: "GET" as const,
pathTemplate: "/resources/{resourceId}",
inputValidator: validator<ResourceInput>("ResourceInput"),
outputValidator: validator<ResourceWire>("ResourceWire"),
problemValidator: validator<ResourceProblem>("ResourceProblem"),
acceptedStatuses: Object.freeze([200]),
emptyBodyStatuses: Object.freeze([]),
retrySemantics: "SAFE" as const,
requestBody: "NONE" as const,
responseBody: "REQUIRED_JSON" as const,
commandRecovery: null,
commandEffect: null,
projectRequest(input: ResourceInput) {
return Object.freeze({
pathValues: Object.freeze({ resourceId: input.resourceId }),
queryEntries: Object.freeze([]),
body: null,
});
},
}),
frontend: Object.freeze({
policyId: "TEST_LOAD_RESOURCE",
requestByteLimit: 0,
responseByteLimit: 8_192,
totalDeadlineMs: 1_000,
retryBudget: 0 as const,
authProfileId: "TEST",
diagnosticsOperation: "test.load-resource",
}),
}) satisfies InstalledHttpContract<ResourceInput, ResourceWire, ResourceProblem>;
const OPERATIONS = Object.freeze({
LOAD_RESOURCE: defineFeatureHttpOperation({
contract: LOAD_RESOURCE_CONTRACT,
routeId: "RESOURCE_DETAIL",
mapSuccess(value) {
if (
!value ||
typeof value !== "object" ||
typeof (value as Record<string, unknown>).id !== "string" ||
typeof (value as Record<string, unknown>).title !== "string"
) {
if (value.title.length === 0) {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
const candidate = value as Readonly<{ id: string; title: string }>;
return mappingSuccess(
Object.freeze({ id: candidate.id, title: candidate.title }),
Object.freeze({ id: value.id, title: value.title }),
);
},
mapProblem(problem, metadata) {
if (metadata.status !== 409) return undefined;
return Object.freeze({
kind: "CONFLICT" as const,
code: problem.code ?? "RESOURCE_CONFLICT",
});
},
}),
} as const);
function scriptedExecutor<WireOutput, Problem>(
outcome: HttpExecutionOutcome<WireOutput, Problem>,
) {
const calls: Array<
Readonly<{
contract: unknown;
input: unknown;
context: unknown;
}>
> = [];
const implementation = async (
contract: unknown,
input: unknown,
context: unknown,
) => {
calls.push(Object.freeze({ contract, input, context }));
return outcome;
};
const execute = vi.fn(implementation) as unknown as
InstalledHttpOperationExecutor["execute"];
return {
executor: Object.freeze({ execute }) satisfies InstalledHttpOperationExecutor,
calls,
execute,
};
}
describe("feature HTTP binding", () => {
it("keeps typed feature input while platform owns route/context execution", async () => {
const execute = vi.fn<InstalledHttpOperationExecutor["execute"]>(
async (_operationId, input, context) => {
expect(input).toEqual({ resourceId: "resource-1" });
expect(context.routeId).toBe("RESOURCE_DETAIL");
return Object.freeze({
kind: "SUCCESS" as const,
value: Object.freeze({ id: "resource-1", title: "Reference" }),
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
});
},
);
const binding = createFeatureHttpBinding(
Object.freeze({ execute }),
OPERATIONS,
it("keeps contract-derived input while platform owns route/context execution", async () => {
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
Object.freeze({
kind: "SUCCESS" as const,
value: Object.freeze({ id: "resource-1", title: "Reference" }),
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
}),
);
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
const result = await binding.execute("LOAD_RESOURCE", {
resourceId: "resource-1",
@@ -63,23 +135,27 @@ describe("feature HTTP binding", () => {
ok: true,
value: { id: "resource-1", title: "Reference" },
});
expect(execute).toHaveBeenCalledTimes(1);
expect(scripted.calls).toEqual([
{
contract: LOAD_RESOURCE_CONTRACT,
input: { resourceId: "resource-1" },
context: { routeId: "RESOURCE_DETAIL" },
},
]);
});
it("normalizes transport failure before it crosses the feature gateway", async () => {
const executor: InstalledHttpOperationExecutor = Object.freeze({
async execute() {
return Object.freeze({
kind: "TRANSPORT_FAILURE" as const,
failure: Object.freeze({
kind: "TIMEOUT" as const,
retryable: true,
}),
effect: "NOT_STARTED" as const,
});
},
});
const binding = createFeatureHttpBinding(executor, OPERATIONS);
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
Object.freeze({
kind: "TRANSPORT_FAILURE" as const,
failure: Object.freeze({
kind: "TIMEOUT" as const,
retryable: true,
}),
effect: "NOT_STARTED" as const,
}),
);
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
const result = await binding.execute("LOAD_RESOURCE", {
resourceId: "resource-1",
@@ -92,18 +168,16 @@ describe("feature HTTP binding", () => {
expect(result.error.effect).toBe("NOT_STARTED");
});
it("turns feature mapper rejection into the shared mapping failure", async () => {
const executor: InstalledHttpOperationExecutor = Object.freeze({
async execute() {
return Object.freeze({
kind: "SUCCESS" as const,
value: Object.freeze({ unexpected: true }),
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
});
},
});
const binding = createFeatureHttpBinding(executor, OPERATIONS);
it("turns a domain mapper rejection into the shared mapping failure", async () => {
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
Object.freeze({
kind: "SUCCESS" as const,
value: Object.freeze({ id: "resource-1", title: "" }),
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
}),
);
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
const result = await binding.execute("LOAD_RESOURCE", {
resourceId: "resource-1",
@@ -114,4 +188,29 @@ describe("feature HTTP binding", () => {
expect(result.error.kind).toBe("MAPPING_CONTRACT_VIOLATION");
expect(result.error.code).toBe("MAPPING_INVARIANT_REJECTED");
});
it("lets the feature interpret a typed business problem", async () => {
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
Object.freeze({
kind: "PROBLEM" as const,
problem: Object.freeze({ code: "RESOURCE_NAME_EXISTS" }),
metadata: Object.freeze({ status: 409 }),
effect: "NOT_APPLIED" as const,
}),
);
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
const result = await binding.execute("LOAD_RESOURCE", {
resourceId: "resource-1",
});
expect(result).toMatchObject({
ok: false,
error: {
kind: "CONFLICT",
code: "RESOURCE_NAME_EXISTS",
httpStatus: 409,
},
});
});
});
@@ -32,7 +32,7 @@ describe("reference feature boundary contracts", () => {
effect: "MAYBE_APPLIED" as const,
}));
const installed = createReferenceFeatureInstalledInput({
contractOperations: { execute },
http: { execute },
});
await expect(
@@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest";
import { createContractHttpExecutor } from "../../../src/adapters/http/index.ts";
import { createHttpObservationProjector } from "../../../src/bootstrap/runtime-adapters.ts";
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts";
function scopeSnapshot() {
return Object.freeze({
@@ -34,17 +33,9 @@ describe("reference feature HTTP diagnostics", () => {
telemetry: { emit: vi.fn() },
}),
});
const operations = new Map(
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.map((operation) => [
operation.contract.operationId,
operation,
]),
);
const installed = createReferenceFeatureInstalledInput({
contractOperations: Object.freeze({
async execute(operationId, input, context) {
const operation = operations.get(operationId);
if (!operation) throw new Error("Unregistered reference operation");
http: Object.freeze({
async execute(operation, input, context) {
return contractHttp.execute(operation, input, {
routeId: context.routeId,
scope: scopeSnapshot(),
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import {
createFeatureHttpBinding,
type InstalledHttpOperationExecutor,
} from "../../../src/adapters/http/index.ts";
import {
createReferenceHttpGateway,
REFERENCE_HTTP_OPERATIONS,
} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
function executorReturning(
outcome: Readonly<Record<string, unknown>>,
): InstalledHttpOperationExecutor {
const execute = (async () => outcome) as unknown as
InstalledHttpOperationExecutor["execute"];
return Object.freeze({ execute });
}
describe("reference HTTP business problem mapping", () => {
it("maps a typed 409 reference problem to the feature-owned business code", async () => {
const executor = executorReturning(
Object.freeze({
kind: "PROBLEM",
problem: Object.freeze({
type: "https://example.test/problems/reference-conflict",
title: "Reference conflict",
status: 409,
code: "REFERENCE_NAME_ALREADY_EXISTS",
}),
metadata: Object.freeze({ status: 409 }),
effect: "NOT_APPLIED",
}),
);
const gateway = createReferenceHttpGateway(
createFeatureHttpBinding(executor, REFERENCE_HTTP_OPERATIONS),
);
await expect(
gateway.create({ name: "duplicate" }),
).resolves.toMatchObject({
ok: false,
error: {
kind: "CONFLICT",
code: "REFERENCE_NAME_ALREADY_EXISTS",
httpStatus: 409,
},
});
});
it.each([
{
status: 409,
code: undefined,
expectedKind: "CONFLICT",
expectedCode: "REFERENCE_RESOURCE_CONFLICT",
},
{
status: 422,
code: "REFERENCE_NAME_REJECTED",
expectedKind: "VALIDATION_REJECTED",
expectedCode: "REFERENCE_NAME_REJECTED",
},
{
status: 422,
code: undefined,
expectedKind: "VALIDATION_REJECTED",
expectedCode: "REFERENCE_RESOURCE_REJECTED",
},
{
status: 400,
code: "REFERENCE_BAD_REQUEST",
expectedKind: "UNKNOWN_CLIENT_FAILURE",
expectedCode: "CONTRACT_PROBLEM",
},
] as const)(
"projects create problem status $status through the feature-owned mapper/fallback",
async ({ status, code, expectedKind, expectedCode }) => {
const executor = executorReturning(
Object.freeze({
kind: "PROBLEM",
problem: Object.freeze({
type: "https://example.test/problems/reference-" + status,
title: "Reference request rejected",
status,
...(code === undefined ? {} : { code }),
}),
metadata: Object.freeze({ status }),
effect: "NOT_APPLIED",
}),
);
const gateway = createReferenceHttpGateway(
createFeatureHttpBinding(executor, REFERENCE_HTTP_OPERATIONS),
);
await expect(
gateway.create({ name: "rejected" }),
).resolves.toMatchObject({
ok: false,
error: {
kind: expectedKind,
code: expectedCode,
httpStatus: status,
},
});
},
);
it("keeps the platform status fallback when the operation has no problem mapper", async () => {
const executor = executorReturning(
Object.freeze({
kind: "PROBLEM",
problem: Object.freeze({
type: "https://example.test/problems/not-found",
title: "Not found",
status: 404,
}),
metadata: Object.freeze({ status: 404 }),
effect: "NOT_APPLIED",
}),
);
const gateway = createReferenceHttpGateway(
createFeatureHttpBinding(executor, REFERENCE_HTTP_OPERATIONS),
);
await expect(gateway.get("missing")).resolves.toMatchObject({
ok: false,
error: {
kind: "NOT_FOUND",
code: "CONTRACT_PROBLEM",
httpStatus: 404,
},
});
});
});
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import {
type InstalledHttpOperationExecutor,
} from "../../../src/adapters/http/index.ts";
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
/**
@@ -14,22 +17,31 @@ import { createReferenceFeatureInstalledInput } from "../../../src/features/refe
describe("reference feature installed operation executor", () => {
it("preserves the feature route id through the installed operation executor", async () => {
const seen: Array<Readonly<Record<string, unknown>>> = [];
const implementation = async (
contract: Readonly<{ contract: Readonly<{ operationId: string }> }>,
_input: unknown,
context: Readonly<Record<string, unknown>>,
) => {
seen.push(Object.freeze({ ...context }));
const value =
contract.contract.operationId === "LIST_REFERENCE_RESOURCES"
? []
: {
id: "resource-1",
name: "Resource",
};
return Object.freeze({
kind: "SUCCESS" as const,
value,
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
});
};
const execute = implementation as unknown as
InstalledHttpOperationExecutor["execute"];
const installed = createReferenceFeatureInstalledInput({
contractOperations: Object.freeze({
async execute(
_operationId: string,
_input: unknown,
context?: Readonly<Record<string, unknown>>,
) {
seen.push(Object.freeze({ ...(context ?? {}) }));
return Object.freeze({
kind: "SUCCESS" as const,
value: [],
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
});
},
}),
http: Object.freeze({ execute }),
});
await installed.input.listResources({ limit: 20 });
+1 -1
View File
@@ -35,7 +35,7 @@
"src/application/create-application.ts": {
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
},
"src/application/policies/compatibility.ts": {
"src/contracts/compatibility.ts": {
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
},
"src/application/policies/performance-budgets.ts": {
@@ -0,0 +1,33 @@
import type { InstalledHttpOperationExecutor } from "../../../src/adapters/http/index.ts";
import { composeFeatureAdapterInputs } from "../../../src/features/feature-adapter-contribution.ts";
declare module "../../../src/application/ports/in/application-api.ts" {
interface ApplicationFeatureInputs {
"direct-compose-probe": Readonly<{
ping(): "pong";
}>;
}
}
const malformedRawContribution = Object.freeze({
featureId: "direct-compose-probe" as const,
needs: ["http"] as const,
createInput() {
return Object.freeze({
featureId: "direct-compose-probe" as const,
input: Object.freeze({
ping() {
return "pong" as const;
},
}),
});
},
});
composeFeatureAdapterInputs(
Object.freeze([malformedRawContribution] as const),
Object.freeze(["direct-compose-probe"]),
Object.freeze({
http: null as unknown as InstalledHttpOperationExecutor,
}),
);
@@ -0,0 +1,13 @@
import { defineFeatureAdapterContribution } from "../../../src/features/feature-adapter-contribution.ts";
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
defineFeatureAdapterContribution({
featureId: "reference-feature",
needs: ["http"] as const,
createInput(context) {
context.indexedDb;
return createReferenceFeatureInstalledInput({
http: context.http,
});
},
});
@@ -0,0 +1,15 @@
import { defineFeatureAdapterContribution } from "../../../src/features/feature-adapter-contribution.ts";
import type {} from "../../../src/features/reference-feature/application/reference-feature-api.ts";
defineFeatureAdapterContribution({
featureId: "reference-feature",
needs: ["http"] as const,
createInput() {
return {
featureId: "reference-feature" as const,
input: {
definitelyNotReferenceFeatureInput: true,
},
};
},
});
+19
View File
@@ -0,0 +1,19 @@
import {
createFeatureHttpBinding,
defineFeatureHttpOperation,
type InstalledHttpOperationExecutor,
} from "../../../src/adapters/http/index.ts";
import { GET_REFERENCE_RESOURCE_CONTRACT } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts";
import { mapReferenceResourcePayload } from "../../../src/features/reference-feature/contracts/reference-mapper.ts";
declare const executor: InstalledHttpOperationExecutor;
const operations = Object.freeze({
TOTALLY_WRONG_OPERATION_ID: defineFeatureHttpOperation({
contract: GET_REFERENCE_RESOURCE_CONTRACT,
routeId: "REFERENCE_RESOURCE_DETAIL",
mapSuccess: mapReferenceResourcePayload,
}),
});
createFeatureHttpBinding(executor, operations);
@@ -0,0 +1,7 @@
import type { ReferenceHttpBinding } from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
declare const http: ReferenceHttpBinding;
http.execute("GET_REFERENCE_RESOURCE", {
name: "wrong-command-for-get",
});
+15
View File
@@ -0,0 +1,15 @@
import { defineFeatureHttpOperationForRoutes } from "../../../src/adapters/http/index.ts";
import {
type ReferenceFeatureRouteId,
} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
import { GET_REFERENCE_RESOURCE_CONTRACT } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts";
import { mapReferenceResourcePayload } from "../../../src/features/reference-feature/contracts/reference-mapper.ts";
const defineReferenceOperation =
defineFeatureHttpOperationForRoutes<ReferenceFeatureRouteId>();
defineReferenceOperation({
contract: GET_REFERENCE_RESOURCE_CONTRACT,
routeId: "TOTALLY_WRONG_ROUTE",
mapSuccess: mapReferenceResourcePayload,
});
+13
View File
@@ -0,0 +1,13 @@
import { defineFeatureHttpOperation } from "../../../src/adapters/http/index.ts";
import { GET_REFERENCE_RESOURCE_CONTRACT } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts";
defineFeatureHttpOperation({
contract: GET_REFERENCE_RESOURCE_CONTRACT,
routeId: "REFERENCE_RESOURCE_DETAIL",
mapSuccess(value: Readonly<{ unrelatedWireField: number }>) {
return {
ok: true as const,
value,
};
},
});
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-application-input.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-application-output.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-async-overlay.ts"
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"types": [
"node",
"vite/client"
],
"isolatedModules": false
}
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-diagnostics-port.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-direct-feature-composition.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-failure-kind.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-feature-capability-selection.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-feature-contribution-input.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-feature-input.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-http-operation-id.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-http-operation-input.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-http-route-id.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-http-wire-mapper.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-icon-button.tsx"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-image-cdn-resolve-signal.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-message-key.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-message-params.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-page-action.tsx"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-port-call.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-port-implementation.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-reference-operation.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-result-narrowing.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-route-runtime.ts"
]
}
@@ -25,6 +25,13 @@ import { afterEach, describe, expect, it } from "vitest";
* longer. Raising the global default instead would hide a genuinely hung test.
*/
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
/**
* Test watchdog only. This is not a provider protocol deadline. Child-process
* scheduling can be delayed when the repository suite is under load, so the
* watchdog must not encode a one-second protocol requirement that does not
* exist in production.
*/
const PROCESS_HANDSHAKE_WATCHDOG_MS = 5_000;
const roots: string[] = [];
@@ -216,7 +223,7 @@ describe("provider guardian transaction protocol", () => {
}));
const ready = decodeProviderGuardianReady(
await within(readyPayload, 1_000, "guardian READY"),
await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian READY"),
nonce,
);
const raw = await lstat(rawPath);
@@ -231,7 +238,7 @@ describe("provider guardian transaction protocol", () => {
.toEqual({ dev: ready.sealedDev, ino: ready.sealedIno, mode: 0o600 });
child.stdin!.end();
await expect(within(completion, 1_000, "guardian abort")).resolves.toEqual({
await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian abort")).resolves.toEqual({
code: 125,
signal: null,
});
@@ -254,7 +261,7 @@ describe("provider guardian transaction protocol", () => {
if (input) child.stdin!.end(input);
else child.stdin!.end();
await expect(within(completion, 1_000, `${label} guardian EOF`)).resolves.toEqual({
await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, `${label} guardian EOF`)).resolves.toEqual({
code: expectedCode,
signal: null,
});
@@ -289,7 +296,7 @@ describe("provider guardian transaction protocol", () => {
await expect(within(
waitForChild(fixture.child),
1_000,
PROCESS_HANDSHAKE_WATCHDOG_MS,
`${boundKind} partial bootstrap exit`,
)).resolves.toEqual({ code: 126, signal: null });
const boundPath = boundKind === "raw" ? fixture.rawStagingPath : fixture.sealedTempPath;
@@ -320,7 +327,7 @@ describe("provider guardian transaction protocol", () => {
deadlineEpochMs: Date.now() + 2_000,
}));
await expect(within(completion, 1_000, "guardian parent startup death"))
await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian parent startup death"))
.resolves.toEqual({ code: 125, signal: null });
await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))).resolves.toEqual([]);
await expect(readdir(path.join(workspace, "provider-evidence"))).resolves.toEqual(["untrusted"]);
@@ -348,7 +355,7 @@ describe("provider guardian transaction protocol", () => {
deadlineEpochMs: Date.now() + 3_000,
}));
const ready = decodeProviderGuardianReady(
await within(readyPayload, 1_000, "guardian READY"),
await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian READY"),
nonce,
);
const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf);
@@ -379,7 +386,7 @@ describe("provider guardian transaction protocol", () => {
sha256: createHash("sha256").update(bytes).digest("hex"),
}));
decodeProviderGuardianPublished(
await within(publishedPayload, 1_000, "guardian PUBLISHED"),
await within(publishedPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian PUBLISHED"),
nonce,
{ dev: ready.sealedDev, ino: ready.sealedIno },
);
@@ -393,7 +400,7 @@ describe("provider guardian transaction protocol", () => {
await new Promise<void>((resolve) => setTimeout(resolve, 50));
expect(child.exitCode).toBeNull();
child.stdin!.end();
await expect(within(completion, 1_000, "guardian commit EOF")).resolves.toEqual({
await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian commit EOF")).resolves.toEqual({
code: 0,
signal: null,
});
@@ -456,7 +463,7 @@ describe("provider guardian transaction protocol", () => {
const transaction = await establishPublishedGuardian(workspace, Buffer.alloc(32, 0x31));
transaction.child.stdin!.end();
await expect(within(transaction.completion, 1_000, "published guardian EOF"))
await expect(within(transaction.completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "published guardian EOF"))
.resolves.toEqual({ code: 125, signal: null });
await assertTransactionAbsent(transaction);
@@ -487,7 +494,7 @@ describe("provider guardian transaction protocol", () => {
transaction.child.stdin!.write(Buffer.from([0, 0, 0, 1, 0x7b]));
transaction.child.stdin!.end();
await expect(within(transaction.completion, 1_000, "guardian trailing frame"))
await expect(within(transaction.completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian trailing frame"))
.resolves.toEqual({ code: 126, signal: null });
await assertTransactionAbsent(transaction);
});
@@ -505,7 +512,7 @@ describe("provider guardian transaction protocol", () => {
});
process.kill(lease.pid, "SIGKILL");
await expect(within(lease.prematureExit, 1_000, "guardian hard death"))
await expect(within(lease.prematureExit, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian hard death"))
.resolves.toEqual(expect.objectContaining({ message: expect.stringMatching(/SIGKILL/u) }));
await lease.abort();
await expect(lstat(lease.rawPath)).rejects.toMatchObject({ code: "ENOENT" });
@@ -536,8 +543,8 @@ describe("provider guardian transaction protocol", () => {
});
process.kill(guardianPid, "SIGCONT");
await within(rawLink, 1_000, "pre-READY canonical raw link");
await expect(within(starting, 2_000, "pre-READY guardian rejection"))
await within(rawLink, PROCESS_HANDSHAKE_WATCHDOG_MS, "pre-READY canonical raw link");
await expect(within(starting, PROCESS_HANDSHAKE_WATCHDOG_MS, "pre-READY guardian rejection"))
.rejects.toThrow(/provider guardian/u);
const possibleTempLeaves = (await readdir(evidenceRoot)).filter((leaf) =>
leaf.startsWith(".vulnerability-report.json.guardian-")
@@ -587,7 +594,7 @@ describe("provider guardian transaction protocol", () => {
await canaryHandle.close();
process.kill(guardianPid, "SIGKILL");
await expect(within(starting, 2_000, "external-canary guardian rejection"))
await expect(within(starting, PROCESS_HANDSHAKE_WATCHDOG_MS, "external-canary guardian rejection"))
.rejects.toThrow(/provider guardian/u);
expect(await readFile(rawPath)).toEqual(canaryBytes);
expect(await lstat(rawPath)).toMatchObject({
@@ -664,14 +671,14 @@ describe("provider guardian transaction protocol", () => {
deadlineEpochMs: Date.now() + 3_000,
}));
const ready = decodeProviderGuardianReady(
await within(readyPayload, 1_000, "closed-stderr guardian READY"),
await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "closed-stderr guardian READY"),
nonce,
);
child.stderr!.destroy();
await new Promise<void>((resolve) => child.stderr!.once("close", resolve));
child.stdin!.end();
await expect(within(completion, 1_000, "closed-stderr guardian exit"))
await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "closed-stderr guardian exit"))
.resolves.toEqual({ code: 125, signal: null });
await expect(lstat(path.join(rawDirectory, "vulnerability-report.json")))
.rejects.toMatchObject({ code: "ENOENT" });
@@ -697,12 +704,12 @@ describe("provider guardian transaction protocol", () => {
deadlineEpochMs: Date.now() + 350,
}));
const ready = decodeProviderGuardianReady(
await within(readyPayload, 1_000, "deadline guardian READY"),
await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "deadline guardian READY"),
nonce,
);
const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf);
await expect(within(completion, 1_000, "guardian deadline"))
await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian deadline"))
.resolves.toEqual({ code: null, signal: "SIGKILL" });
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(lstat(sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" });
@@ -874,7 +881,7 @@ async function establishPublishedGuardian(
deadlineEpochMs: Date.now() + 3_000,
}));
const ready = decodeProviderGuardianReady(
await within(readyPayload, 1_000, "guardian READY"),
await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian READY"),
nonce,
);
const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf);
@@ -896,7 +903,7 @@ async function establishPublishedGuardian(
sha256: createHash("sha256").update(bytes).digest("hex"),
}));
decodeProviderGuardianPublished(
await within(publishedPayload, 1_000, "guardian PUBLISHED"),
await within(publishedPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian PUBLISHED"),
nonce,
{ dev: ready.sealedDev, ino: ready.sealedIno },
);
+47
View File
@@ -200,6 +200,53 @@ describe("bounded body reader", () => {
});
});
it("abandons a pending bounded read when the operation is aborted", async () => {
const controller = new AbortController();
const reader = {
read: vi.fn(() => new Promise<never>(() => {})),
cancel: vi.fn().mockRejectedValue(new Error("cancel ignored")),
releaseLock: vi.fn(),
};
const response = {
headers: new Headers(),
body: { getReader: () => reader },
} as unknown as Response;
const pending = readBoundedBytes(response, 8, controller.signal);
controller.abort();
await expect(pending).resolves.toEqual({
ok: false,
code: "RESPONSE_STREAM_FAILURE",
});
expect(reader.cancel).toHaveBeenCalledOnce();
expect(reader.releaseLock).toHaveBeenCalledOnce();
});
it("abandons a pending forbidden-body probe when the operation is aborted", async () => {
const controller = new AbortController();
const reader = {
read: vi.fn(() => new Promise<never>(() => {})),
cancel: vi.fn().mockRejectedValue(new Error("cancel ignored")),
releaseLock: vi.fn(),
};
const response = {
headers: new Headers(),
body: { getReader: () => reader },
} as unknown as Response;
const pending = probeForbiddenBody(response, controller.signal);
controller.abort();
await expect(pending).resolves.toEqual({
ok: false,
code: "RESPONSE_STREAM_FAILURE",
});
expect(reader.cancel).toHaveBeenCalledOnce();
expect(reader.releaseLock).toHaveBeenCalledOnce();
});
it("decodes valid JSON and distinguishes UTF-8 from JSON failures", () => {
expect(decodeJsonBytes(new TextEncoder().encode('{"ok":true}'))).toEqual({
ok: true,
+22 -16
View File
@@ -73,12 +73,17 @@ function labelledFileInput(): HTMLInputElement {
describe("browser file pickers", () => {
it("treats native input cancellation as a normal dismissed outcome", async () => {
const input = labelledFileInput();
Object.defineProperty(input, "showPicker", {
Object.defineProperty(input, "click", {
configurable: true,
value: vi.fn(() => {
queueMicrotask(() => input.dispatchEvent(new Event("cancel")));
}),
});
const baselineShowPicker = vi.fn();
Object.defineProperty(input, "showPicker", {
configurable: true,
value: baselineShowPicker,
});
const harness = createHarness();
const picker = new NativeInputFilePicker({
input,
@@ -90,6 +95,7 @@ describe("browser file pickers", () => {
ok: true,
value: { kind: "DISMISSED" },
});
expect(baselineShowPicker).not.toHaveBeenCalled();
});
it("resets the native input and supports same-file reselection", async () => {
@@ -102,12 +108,12 @@ describe("browser file pickers", () => {
configurable: true,
value: [selected],
});
const showPicker = vi.fn(() => {
const click = vi.fn(() => {
queueMicrotask(() => input.dispatchEvent(new Event("change")));
});
Object.defineProperty(input, "showPicker", {
Object.defineProperty(input, "click", {
configurable: true,
value: showPicker,
value: click,
});
let sequence = 0;
const { policies, vault } = createHarness(policyDefinition, {
@@ -130,14 +136,14 @@ describe("browser file pickers", () => {
ok: true,
value: { kind: "SELECTED" },
});
expect(showPicker).toHaveBeenCalledTimes(2);
expect(click).toHaveBeenCalledTimes(2);
expect(input.value).toBe("");
expect(vault.activeReferenceCount).toBe(2);
});
it("snapshots selection policy before awaiting picker events", async () => {
const input = labelledFileInput();
Object.defineProperty(input, "showPicker", {
Object.defineProperty(input, "click", {
configurable: true,
value: vi.fn(),
});
@@ -186,7 +192,7 @@ describe("browser file pickers", () => {
it("honors AbortSignal while a native dialog is pending", async () => {
const input = labelledFileInput();
Object.defineProperty(input, "showPicker", {
Object.defineProperty(input, "click", {
configurable: true,
value: vi.fn(),
});
@@ -215,7 +221,7 @@ describe("browser file pickers", () => {
let fallback: (() => void) | undefined;
let delay: number | undefined;
const clearTimeout = vi.fn();
Object.defineProperty(input, "showPicker", {
Object.defineProperty(input, "click", {
configurable: true,
value: vi.fn(() => {
window.dispatchEvent(new Event("focus"));
@@ -261,7 +267,7 @@ describe("browser file pickers", () => {
const input = labelledFileInput();
let fallback: (() => void) | undefined;
const clearTimeout = vi.fn();
Object.defineProperty(input, "showPicker", {
Object.defineProperty(input, "click", {
configurable: true,
value: vi.fn(() => {
window.dispatchEvent(new Event("focus"));
@@ -316,14 +322,14 @@ describe("browser file pickers", () => {
setTimeout: originalSetTimeout,
clearTimeout: originalClearTimeout,
};
const originalShowPicker = vi.fn(() => {
const originalClick = vi.fn(() => {
focus?.(new Event("focus"));
});
const replacedShowPicker = vi.fn();
Object.defineProperty(input, "showPicker", {
const replacedClick = vi.fn();
Object.defineProperty(input, "click", {
configurable: true,
writable: true,
value: originalShowPicker,
value: originalClick,
});
const harness = createHarness();
const picker = new NativeInputFilePicker({
@@ -338,7 +344,7 @@ describe("browser file pickers", () => {
focusFallbackGraceMs: 0,
});
input.showPicker = replacedShowPicker;
input.click = replacedClick;
windowHost.addEventListener = vi.fn();
windowHost.removeEventListener = vi.fn();
scheduler.setTimeout = vi.fn();
@@ -348,8 +354,8 @@ describe("browser file pickers", () => {
ok: true,
value: { kind: "DISMISSED" },
});
expect(originalShowPicker).toHaveBeenCalledOnce();
expect(replacedShowPicker).not.toHaveBeenCalled();
expect(originalClick).toHaveBeenCalledOnce();
expect(replacedClick).not.toHaveBeenCalled();
expect(originalWindowAdd).toHaveBeenCalledOnce();
expect(originalWindowRemove).toHaveBeenCalledOnce();
expect(originalSetTimeout).toHaveBeenCalledOnce();
+1 -1
View File
@@ -178,7 +178,7 @@ describe("browser file runtime hard limits and disposal", () => {
it("aborts a pending native picker and prevents event resurrection", async () => {
const input = fileInput();
Object.defineProperty(input, "showPicker", {
Object.defineProperty(input, "click", {
configurable: true,
value: vi.fn(),
});
+3 -3
View File
@@ -189,9 +189,9 @@ describe("CI gate contract", () => {
),
);
expect(contract.jobs).toHaveLength(9);
expect(contract.commands).toHaveLength(84);
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(96);
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23);
expect(contract.commands).toHaveLength(91);
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(103);
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(30);
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(88);
expect(contract.artifacts).toHaveLength(109);
expect(contract.stages).toHaveLength(5);
+18 -16
View File
@@ -16,7 +16,7 @@ import {
} from "../../src/features/installed-feature-contracts.ts";
const COMPILED = Object.freeze([
Object.freeze({ featureId: "reference-feature" }),
Object.freeze({ featureId: "alpha-feature" }),
Object.freeze({ featureId: "billing" }),
]);
@@ -26,7 +26,7 @@ describe("build-time product selection", () => {
expect(
selectCompiledProductFeatures(COMPILED, declared).map((f) => f.featureId),
String(declared),
).toEqual(["reference-feature", "billing"]);
).toEqual(["alpha-feature", "billing"]);
}
});
@@ -35,10 +35,10 @@ describe("build-time product selection", () => {
selectCompiledProductFeatures(COMPILED, "billing").map((f) => f.featureId),
).toEqual(["billing"]);
expect(
selectCompiledProductFeatures(COMPILED, " billing , reference-feature ").map(
selectCompiledProductFeatures(COMPILED, " billing , alpha-feature ").map(
(f) => f.featureId,
),
).toEqual(["reference-feature", "billing"]);
).toEqual(["alpha-feature", "billing"]);
});
it("selects nothing only when asked explicitly", () => {
@@ -79,13 +79,13 @@ describe("build-time product selection", () => {
describe("runtime product feature resolution", () => {
it("reports active, disabled and not-installed distinctly", () => {
const statuses = resolveProductFeatures(
["reference-feature", "billing"],
["reference-feature"],
{ "reference-feature": "DISABLED" },
["alpha-feature", "billing"],
["alpha-feature"],
{ "alpha-feature": "DISABLED" },
);
expect(statuses).toEqual([
{ featureId: "alpha-feature", state: "DISABLED_BY_CONFIG" },
{ featureId: "billing", state: "NOT_INSTALLED" },
{ featureId: "reference-feature", state: "DISABLED_BY_CONFIG" },
]);
expect(activeProductFeatureIds(statuses)).toEqual([]);
});
@@ -103,11 +103,11 @@ describe("runtime product feature resolution", () => {
// A shared runtime document may cover several builds, so a stale key is
// inert rather than fatal.
const statuses = resolveProductFeatures(
["reference-feature"],
["reference-feature"],
["alpha-feature"],
["alpha-feature"],
{ analytics: "DISABLED" },
);
expect(activeProductFeatureIds(statuses)).toEqual(["reference-feature"]);
expect(activeProductFeatureIds(statuses)).toEqual(["alpha-feature"]);
});
it("leaves an installed feature active without an override", () => {
@@ -140,15 +140,15 @@ describe("runtime config carries the switch", () => {
expect(
runtimeConfigV2ArtifactSchema.parse({
...base,
FEATURE_OVERRIDES: { "reference-feature": "DISABLED" },
FEATURE_OVERRIDES: { "alpha-feature": "DISABLED" },
}).FEATURE_OVERRIDES,
).toEqual({ "reference-feature": "DISABLED" });
).toEqual({ "alpha-feature": "DISABLED" });
// There is no "ENABLED": the vocabulary itself is what makes the rule
// unbreakable, not a check somewhere downstream.
expect(
runtimeConfigV2ArtifactSchema.safeParse({
...base,
FEATURE_OVERRIDES: { "reference-feature": "ENABLED" },
FEATURE_OVERRIDES: { "alpha-feature": "ENABLED" },
}).success,
).toBe(false);
});
@@ -212,11 +212,13 @@ describe("route ownership", () => {
}
});
it("owns exactly the routes the registry received from features", () => {
it("owns exactly routes that exist in the composed registry", () => {
const owned = Object.keys(ROUTE_FEATURE_OWNER);
expect(owned.length).toBeGreaterThan(0);
for (const routeId of owned) {
expect(Object.keys(ROUTE_REGISTRY), routeId).toContain(routeId);
}
expect(
owned.every((routeId) => ROUTE_FEATURE_OWNER[routeId] !== undefined),
).toBe(true);
});
});
+137
View File
@@ -0,0 +1,137 @@
import type {
PublicCacheAsset,
PublicCacheReleaseManifest,
} from "../../src/application/ports/browser-file-storage/cache-storage-ports.ts";
import {
createDefaultPublicCachePolicy,
type PublicCacheRuntimePolicy,
} from "../../src/adapters/cache-storage/public-cache-policy.ts";
import {
computePublicCacheManifestDigestHex,
type PublicCacheMutationLock,
} from "../../src/adapters/cache-storage/public-response-cache-adapter.ts";
export class MemoryCache {
readonly responses: Array<Readonly<{
request: Request;
response: Response;
}>> = [];
async match(request: RequestInfo | URL): Promise<Response | undefined> {
const url =
request instanceof Request ? request.url : new URL(String(request)).href;
const nativeRequest =
request instanceof Request ? request : new Request(url);
return this.responses
.find(
(entry) =>
entry.request.url === url &&
varyMatches(entry.request, nativeRequest, entry.response),
)
?.response.clone();
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
const url =
request instanceof Request ? request.url : new URL(String(request)).href;
const nativeRequest =
request instanceof Request ? request.clone() : new Request(url);
const existing = this.responses.findIndex(
(entry) =>
entry.request.url === url &&
varyMatches(entry.request, nativeRequest, response),
);
const entry = {
request: nativeRequest,
response: response.clone(),
};
if (existing >= 0) this.responses.splice(existing, 1, entry);
else this.responses.push(entry);
}
}
function varyMatches(
storedRequest: Request,
incomingRequest: Request,
response: Response,
): boolean {
const vary = response.headers.get("vary");
if (!vary) return true;
return vary
.split(",")
.map((name) => name.trim().toLowerCase())
.every(
(name) =>
storedRequest.headers.get(name) ===
incomingRequest.headers.get(name),
);
}
export class MemoryCacheStorage {
readonly caches = new Map<string, MemoryCache>();
async open(name: string): Promise<Cache> {
let cache = this.caches.get(name);
if (!cache) {
cache = new MemoryCache();
this.caches.set(name, cache);
}
return cache as unknown as Cache;
}
async keys(): Promise<string[]> {
return [...this.caches.keys()];
}
async delete(name: string): Promise<boolean> {
return this.caches.delete(name);
}
}
export const immediateLock: PublicCacheMutationLock = {
async run(_signal, task) {
return await task();
},
};
export function deferred<Value>() {
let settle: ((value: Value) => void) | undefined;
const promise = new Promise<Value>((resolve) => {
settle = resolve;
});
return Object.freeze({
promise,
resolve(value: Value): void {
settle?.(value);
},
});
}
export async function digestHex(bytes: Uint8Array): Promise<string> {
const digest = await globalThis.crypto.subtle.digest(
"SHA-256",
Uint8Array.from(bytes),
);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
export async function manifestFor(
releaseRegistryId: string,
assets: readonly PublicCacheAsset[],
policy: PublicCacheRuntimePolicy = createDefaultPublicCachePolicy(
"https://assets.example.test",
),
): Promise<PublicCacheReleaseManifest> {
return {
releaseRegistryId,
assets,
manifestDigestHex: await computePublicCacheManifestDigestHex(
globalThis.crypto,
releaseRegistryId,
assets,
policy,
),
};
}
@@ -0,0 +1,207 @@
import { describe, expect, it } from "vitest";
import type { PublicCacheAsset } from "../../src/application/ports/browser-file-storage/cache-storage-ports.ts";
import { createDefaultPublicCachePolicy } from "../../src/adapters/cache-storage/public-cache-policy.ts";
import { createPublicResponseCacheAdapter } from "../../src/adapters/cache-storage/public-response-cache-adapter.ts";
import {
MemoryCacheStorage,
digestHex,
immediateLock,
manifestFor,
} from "./public-response-cache-fixture.ts";
/**
* STO-RR-04 / STO-RR-05. A release cache that is currently serving traffic is
* the last thing a repair may destroy. A transient marker read failure is not
* evidence of damage, and a repair that has not yet fetched anything has not
* yet earned the right to delete what still works.
*/
describe("public response cache repair is failure-atomic", () => {
async function stagedRelease(releaseRegistryId: string) {
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const firstBytes = new Uint8Array([1, 1, 1, 1]);
const secondBytes = new Uint8Array([2, 2, 2, 2]);
const assets: readonly PublicCacheAsset[] = [
{
absoluteUrl: "https://assets.example.test/first.js",
expectedByteLength: firstBytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(firstBytes),
},
},
{
absoluteUrl: "https://assets.example.test/second.js",
expectedByteLength: secondBytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(secondBytes),
},
},
];
const bodies = new Map<string, Uint8Array>([
[assets[0]!.absoluteUrl, firstBytes],
[assets[1]!.absoluteUrl, secondBytes],
]);
const cacheStorage = new MemoryCacheStorage();
const fetchLog: string[] = [];
let failFrom: string | null = null;
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async (request: Request) => {
fetchLog.push(request.url);
if (failFrom !== null && request.url === failFrom) {
throw new TypeError("network is down");
}
const body = bodies.get(request.url);
if (!body) throw new TypeError(`unknown asset ${request.url}`);
return new Response(Uint8Array.from(body), {
headers: {
"cache-control": "public",
"content-type": "application/javascript",
},
});
},
});
const manifest = await manifestFor(releaseRegistryId, assets, policy);
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
ok: true,
});
expect(
await adapter.admin.activateRelease(
manifest.releaseRegistryId,
manifest.manifestDigestHex,
),
).toMatchObject({ ok: true });
const cacheName = [...cacheStorage.caches.keys()].find((name) =>
name.includes(releaseRegistryId),
);
if (!cacheName) throw new Error("staged cache missing");
return {
adapter,
assets,
cacheName,
cacheStorage,
fetchLog,
manifest,
setFailure(url: string | null) {
failFrom = url;
},
};
}
it("does not delete an active candidate when the marker read fails transiently", async () => {
const release = await stagedRelease("transient-marker");
const cache = release.cacheStorage.caches.get(release.cacheName)!;
const realMatch = cache.match.bind(cache);
let markerReads = 0;
const assetUrls = new Set(release.assets.map((asset) => asset.absoluteUrl));
cache.match = async (request: RequestInfo | URL) => {
const url =
request instanceof Request ? request.url : String(request);
if (!assetUrls.has(url)) {
markerReads += 1;
throw new DOMException("Storage is busy", "InvalidStateError");
}
return await realMatch(request);
};
const restaged = await release.adapter.admin.stageRelease(release.manifest);
expect(markerReads).toBeGreaterThan(0);
expect(restaged.ok).toBe(false);
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
cache.match = realMatch;
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[0]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
});
it("keeps every healthy asset when one repair fetch fails", async () => {
const release = await stagedRelease("partial-repair");
const cache = release.cacheStorage.caches.get(release.cacheName)!;
// Corrupt only the first asset's stored bytes.
const corrupted = cache.responses.findIndex(
(entry) => entry.request.url === release.assets[0]!.absoluteUrl,
);
expect(corrupted).toBeGreaterThanOrEqual(0);
cache.responses.splice(corrupted, 1);
release.setFailure(release.assets[0]!.absoluteUrl);
const restaged = await release.adapter.admin.stageRelease(release.manifest);
expect(restaged.ok).toBe(false);
// The cache still exists and the healthy asset is still served.
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[1]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
});
it("still removes a candidate this call created when staging fails", async () => {
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([7, 7, 7, 7]);
const asset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/fresh.js",
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const cacheStorage = new MemoryCacheStorage();
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async () => {
throw new TypeError("network is down");
},
});
const manifest = await manifestFor("fresh-release", [asset], policy);
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
ok: false,
});
expect(await cacheStorage.keys()).toEqual([]);
});
it("repairs an evicted asset in place and keeps the release usable", async () => {
const release = await stagedRelease("in-place-repair");
const cache = release.cacheStorage.caches.get(release.cacheName)!;
const evicted = cache.responses.findIndex(
(entry) => entry.request.url === release.assets[1]!.absoluteUrl,
);
cache.responses.splice(evicted, 1);
expect(
await release.adapter.admin.stageRelease(release.manifest),
).toMatchObject({ ok: true });
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[1]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[0]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
});
});
+7 -321
View File
@@ -10,135 +10,17 @@ import {
type PublicCacheRuntimePolicy,
} from "../../src/adapters/cache-storage/public-cache-policy.ts";
import {
computePublicCacheManifestDigestHex,
createPublicResponseCacheAdapter,
type PublicCacheMutationLock,
} from "../../src/adapters/cache-storage/public-response-cache-adapter.ts";
class MemoryCache {
readonly responses: Array<Readonly<{
request: Request;
response: Response;
}>> = [];
async match(request: RequestInfo | URL): Promise<Response | undefined> {
const url =
request instanceof Request ? request.url : new URL(String(request)).href;
const nativeRequest =
request instanceof Request ? request : new Request(url);
return this.responses
.find(
(entry) =>
entry.request.url === url &&
varyMatches(entry.request, nativeRequest, entry.response),
)
?.response.clone();
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
const url =
request instanceof Request ? request.url : new URL(String(request)).href;
const nativeRequest =
request instanceof Request ? request.clone() : new Request(url);
const existing = this.responses.findIndex(
(entry) =>
entry.request.url === url &&
varyMatches(entry.request, nativeRequest, response),
);
const entry = {
request: nativeRequest,
response: response.clone(),
};
if (existing >= 0) this.responses.splice(existing, 1, entry);
else this.responses.push(entry);
}
}
function varyMatches(
storedRequest: Request,
incomingRequest: Request,
response: Response,
): boolean {
const vary = response.headers.get("vary");
if (!vary) return true;
return vary
.split(",")
.map((name) => name.trim().toLowerCase())
.every(
(name) =>
storedRequest.headers.get(name) ===
incomingRequest.headers.get(name),
);
}
class MemoryCacheStorage {
readonly caches = new Map<string, MemoryCache>();
async open(name: string): Promise<Cache> {
let cache = this.caches.get(name);
if (!cache) {
cache = new MemoryCache();
this.caches.set(name, cache);
}
return cache as unknown as Cache;
}
async keys(): Promise<string[]> {
return [...this.caches.keys()];
}
async delete(name: string): Promise<boolean> {
return this.caches.delete(name);
}
}
const immediateLock: PublicCacheMutationLock = {
async run(_signal, task) {
return await task();
},
};
function deferred<Value>() {
let settle: ((value: Value) => void) | undefined;
const promise = new Promise<Value>((resolve) => {
settle = resolve;
});
return Object.freeze({
promise,
resolve(value: Value): void {
settle?.(value);
},
});
}
async function digestHex(bytes: Uint8Array): Promise<string> {
const digest = await globalThis.crypto.subtle.digest(
"SHA-256",
Uint8Array.from(bytes),
);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
async function manifestFor(
releaseRegistryId: string,
assets: readonly PublicCacheAsset[],
policy: PublicCacheRuntimePolicy = createDefaultPublicCachePolicy(
"https://assets.example.test",
),
): Promise<PublicCacheReleaseManifest> {
return {
releaseRegistryId,
assets,
manifestDigestHex: await computePublicCacheManifestDigestHex(
globalThis.crypto,
releaseRegistryId,
assets,
policy,
),
};
}
import {
MemoryCacheStorage,
deferred,
digestHex,
immediateLock,
manifestFor,
} from "./public-response-cache-fixture.ts";
describe("public response Cache Storage adapter", () => {
it("rejects a policy that enables variants but strips Vary", () => {
@@ -1513,199 +1395,3 @@ describe("public response Cache Storage adapter", () => {
expect(await cacheStorage.keys()).toEqual([]);
});
});
/**
* STO-RR-04 / STO-RR-05. A release cache that is currently serving traffic is
* the last thing a repair may destroy. A transient marker read failure is not
* evidence of damage, and a repair that has not yet fetched anything has not
* yet earned the right to delete what still works.
*/
describe("public response cache repair is failure-atomic", () => {
async function stagedRelease(releaseRegistryId: string) {
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const firstBytes = new Uint8Array([1, 1, 1, 1]);
const secondBytes = new Uint8Array([2, 2, 2, 2]);
const assets: readonly PublicCacheAsset[] = [
{
absoluteUrl: "https://assets.example.test/first.js",
expectedByteLength: firstBytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(firstBytes),
},
},
{
absoluteUrl: "https://assets.example.test/second.js",
expectedByteLength: secondBytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(secondBytes),
},
},
];
const bodies = new Map<string, Uint8Array>([
[assets[0]!.absoluteUrl, firstBytes],
[assets[1]!.absoluteUrl, secondBytes],
]);
const cacheStorage = new MemoryCacheStorage();
const fetchLog: string[] = [];
let failFrom: string | null = null;
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async (request: Request) => {
fetchLog.push(request.url);
if (failFrom !== null && request.url === failFrom) {
throw new TypeError("network is down");
}
const body = bodies.get(request.url);
if (!body) throw new TypeError(`unknown asset ${request.url}`);
return new Response(Uint8Array.from(body), {
headers: {
"cache-control": "public",
"content-type": "application/javascript",
},
});
},
});
const manifest = await manifestFor(releaseRegistryId, assets, policy);
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
ok: true,
});
expect(
await adapter.admin.activateRelease(
manifest.releaseRegistryId,
manifest.manifestDigestHex,
),
).toMatchObject({ ok: true });
const cacheName = [...cacheStorage.caches.keys()].find((name) =>
name.includes(releaseRegistryId),
);
if (!cacheName) throw new Error("staged cache missing");
return {
adapter,
assets,
cacheName,
cacheStorage,
fetchLog,
manifest,
setFailure(url: string | null) {
failFrom = url;
},
};
}
it("does not delete an active candidate when the marker read fails transiently", async () => {
const release = await stagedRelease("transient-marker");
const cache = release.cacheStorage.caches.get(release.cacheName)!;
const realMatch = cache.match.bind(cache);
let markerReads = 0;
const assetUrls = new Set(release.assets.map((asset) => asset.absoluteUrl));
cache.match = async (request: RequestInfo | URL) => {
const url =
request instanceof Request ? request.url : String(request);
if (!assetUrls.has(url)) {
markerReads += 1;
throw new DOMException("Storage is busy", "InvalidStateError");
}
return await realMatch(request);
};
const restaged = await release.adapter.admin.stageRelease(release.manifest);
expect(markerReads).toBeGreaterThan(0);
expect(restaged.ok).toBe(false);
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
cache.match = realMatch;
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[0]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
});
it("keeps every healthy asset when one repair fetch fails", async () => {
const release = await stagedRelease("partial-repair");
const cache = release.cacheStorage.caches.get(release.cacheName)!;
// Corrupt only the first asset's stored bytes.
const corrupted = cache.responses.findIndex(
(entry) => entry.request.url === release.assets[0]!.absoluteUrl,
);
expect(corrupted).toBeGreaterThanOrEqual(0);
cache.responses.splice(corrupted, 1);
release.setFailure(release.assets[0]!.absoluteUrl);
const restaged = await release.adapter.admin.stageRelease(release.manifest);
expect(restaged.ok).toBe(false);
// The cache still exists and the healthy asset is still served.
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[1]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
});
it("still removes a candidate this call created when staging fails", async () => {
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([7, 7, 7, 7]);
const asset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/fresh.js",
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const cacheStorage = new MemoryCacheStorage();
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async () => {
throw new TypeError("network is down");
},
});
const manifest = await manifestFor("fresh-release", [asset], policy);
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
ok: false,
});
expect(await cacheStorage.keys()).toEqual([]);
});
it("repairs an evicted asset in place and keeps the release usable", async () => {
const release = await stagedRelease("in-place-repair");
const cache = release.cacheStorage.caches.get(release.cacheName)!;
const evicted = cache.responses.findIndex(
(entry) => entry.request.url === release.assets[1]!.absoluteUrl,
);
cache.responses.splice(evicted, 1);
expect(
await release.adapter.admin.stageRelease(release.manifest),
).toMatchObject({ ok: true });
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[1]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[0]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
});
});
@@ -0,0 +1,341 @@
import { vi } from "vitest";
import type {
ResumableUploadCheckpoint,
ResumableUploadCheckpointStore,
ResumableUploadControlPlane,
ResumableUploadSource,
UploadPartExecutor,
UploadPartReceipt,
UploadProviderResult,
UploadSession,
} from "../../src/application/ports/browser-transfer/resumable-upload.ts";
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../src/application/ports/browser-transfer/resumable-upload.ts";
import type {
BrowserDataResult,
} from "../../src/application/ports/browser-file-storage/shared.ts";
import {
browserDataFailure,
browserDataSuccess,
} from "../../src/adapters/browser-file-storage/result.ts";
import { resolveResumableUploadRuntimePolicy } from "../../src/adapters/browser-transfer/resumable-upload/runtime-policy.ts";
import type {
UploadCancellationChannel,
UploadCancellationListener,
} from "../../src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts";
import type { UploadMutationLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
export type TestCapability = Readonly<{ id: string }>;
export const activeSignal = new AbortController().signal;
export const noContentionLock: UploadMutationLock = Object.freeze({
async run<Value>(
_uploadKey: string,
_signal: AbortSignal,
task: () => Promise<Value>,
): Promise<Value> {
return await task();
},
});
export function createSerialMutationLock(): UploadMutationLock {
let tail = Promise.resolve();
return Object.freeze({
run<Value>(
_uploadKey: string,
signal: AbortSignal,
task: () => Promise<Value>,
): Promise<Value> {
const result = tail.then(async () => {
if (signal.aborted) {
throw new DOMException(
"The operation was aborted.",
"AbortError",
);
}
return await task();
});
tail = result.then(
() => undefined,
() => undefined,
);
return result;
},
});
}
export function createMemoryCancellationPair(): readonly [
UploadCancellationChannel,
UploadCancellationChannel,
] {
const listeners = [
new Set<UploadCancellationListener>(),
new Set<UploadCancellationListener>(),
] as const;
const channels = listeners.map((ownListeners, ownIndex) => {
let closed = false;
return Object.freeze({
publish(uploadKey: string) {
if (closed) return false;
for (const [index, peerListeners] of listeners.entries()) {
if (index === ownIndex) continue;
for (const listener of [...peerListeners]) {
listener(uploadKey);
}
}
return true;
},
subscribe(listener: UploadCancellationListener) {
if (closed) throw new TypeError("closed");
ownListeners.add(listener);
return () => ownListeners.delete(listener);
},
close() {
closed = true;
ownListeners.clear();
},
});
});
return channels as unknown as readonly [
UploadCancellationChannel,
UploadCancellationChannel,
];
}
export class MemoryCheckpointStore implements ResumableUploadCheckpointStore {
readonly rows = new Map<string, ResumableUploadCheckpoint>();
closed = false;
async read(
uploadKey: string,
): Promise<BrowserDataResult<ResumableUploadCheckpoint | null>> {
return browserDataSuccess(
structuredClone(this.rows.get(uploadKey) ?? null),
);
}
async compareAndSwap(
input: Parameters<
ResumableUploadCheckpointStore["compareAndSwap"]
>[0],
): Promise<BrowserDataResult<ResumableUploadCheckpoint>> {
const current = this.rows.get(input.checkpoint.uploadKey);
if (
(input.expectedRevision === null && current) ||
(input.expectedRevision !== null &&
current?.revision !== input.expectedRevision)
) {
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
recovery: "RECONCILE",
});
}
const snapshot = structuredClone(input.checkpoint);
this.rows.set(snapshot.uploadKey, snapshot);
return browserDataSuccess(snapshot);
}
async remove(
input: Parameters<ResumableUploadCheckpointStore["remove"]>[0],
): Promise<BrowserDataResult<void>> {
const current = this.rows.get(input.uploadKey);
if (current?.revision !== input.expectedRevision) {
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
recovery: "RECONCILE",
});
}
this.rows.delete(input.uploadKey);
return browserDataSuccess(undefined);
}
close(): void {
this.closed = true;
}
}
export function rangeSource(bytes: Uint8Array): ResumableUploadSource {
return Object.freeze({
kind: "RANGE_READER" as const,
reader: Object.freeze({
byteLength: bytes.byteLength,
async readRange(input: Readonly<{
offset: number;
length: number;
signal: AbortSignal;
}>) {
if (input.signal.aborted) {
return browserDataFailure("ABORTED", "FILE_READ");
}
return browserDataSuccess(
bytes.slice(input.offset, input.offset + input.length),
);
},
}),
});
}
export function byteStreamSource(bytes: Uint8Array): ResumableUploadSource {
return Object.freeze({
kind: "FILE_BYTE_SOURCE" as const,
bytes: Object.freeze({
byteLength: bytes.byteLength,
async *stream(signal: AbortSignal) {
if (signal.aborted) {
yield browserDataFailure("ABORTED", "FILE_READ");
return;
}
yield browserDataSuccess(bytes.slice(0, 3));
yield browserDataSuccess(bytes.slice(3));
},
}),
});
}
export function runtimePolicy(
overrides: Partial<
Parameters<typeof resolveResumableUploadRuntimePolicy>[0]
> = {},
) {
return {
partSizeBytes: 4,
maxFileBytes: 100,
maxPartCount: 25,
maxConcurrency: 3,
maxInFlightBytes: 48,
partBufferCopyFactor: 4,
maxSourceChunkBytes: 8,
maxRetries: 2,
retryBaseDelayMs: 1,
retryMaxDelayMs: 10,
maxRetryAfterMs: 100,
capabilityRefreshSkewMs: 5,
maxSessionLifetimeMs: 10_000,
providerAttemptTimeoutMs: 100,
...overrides,
};
}
export type ControlHarness = Readonly<{
control: ResumableUploadControlPlane<TestCapability>;
accepted: Map<number, UploadPartReceipt>;
issued: ReturnType<typeof vi.fn>;
completedParts: UploadPartReceipt[][];
getSession(): UploadSession | null;
}>;
export function createControlHarness(options: Readonly<{
now?: number;
serverMaxConcurrency?: number;
sessionId?: (createIndex: number) => string;
statusParts?: (
session: UploadSession,
accepted: Map<number, UploadPartReceipt>,
) => readonly UploadPartReceipt[];
issueCapability?: (
input: Parameters<
ResumableUploadControlPlane<TestCapability>["issuePartCapability"]
>[0],
callIndex: number,
) => UploadProviderResult<Readonly<{
capability: TestCapability;
uploadBindingSha256: string;
expiresAtEpochMs: number;
}>>;
}> = {}): ControlHarness {
const now = options.now ?? 1_000;
const accepted = new Map<number, UploadPartReceipt>();
const completedParts: UploadPartReceipt[][] = [];
let session: UploadSession | null = null;
let createCount = 0;
let issueCount = 0;
const issued = vi.fn();
const control: ResumableUploadControlPlane<TestCapability> = {
async createSession(input) {
createCount += 1;
session = Object.freeze({
protocol: RESUMABLE_UPLOAD_PROTOCOL,
sessionId:
options.sessionId?.(createCount) ?? "session_01",
requestBindingSha256: input.requestBindingSha256,
fingerprint: input.fingerprint,
partSizeBytes: input.requestedPartSizeBytes,
partCount: input.fingerprint.partCount,
maxConcurrency: options.serverMaxConcurrency ?? 2,
expiresAtEpochMs: now + 5_000,
});
return browserDataSuccess(session);
},
async getStatus() {
if (!session) {
return browserDataFailure("NOT_FOUND", "UPLOAD_RECONCILE");
}
const parts =
options.statusParts?.(session, accepted) ??
[...accepted.values()].sort(
(left, right) => left.partNumber - right.partNumber,
);
return browserDataSuccess({
state: "ACTIVE",
session,
acceptedParts: parts,
});
},
async issuePartCapability(input) {
issueCount += 1;
issued(input);
return (
options.issueCapability?.(input, issueCount) ??
browserDataSuccess({
capability: Object.freeze({ id: `cap-${issueCount}` }),
uploadBindingSha256: input.uploadBindingSha256,
expiresAtEpochMs: now + 4_000,
})
);
},
async complete(input) {
completedParts.push([...input.orderedParts]);
return browserDataSuccess({
state: "QUARANTINED",
protocol: RESUMABLE_UPLOAD_PROTOCOL,
sessionId: input.sessionId,
requestBindingSha256: input.requestBindingSha256,
fingerprint: input.fingerprint,
resourceId: "resource_01",
});
},
async abort() {
return browserDataSuccess({ state: "ABORTED" });
},
};
return {
control,
accepted,
issued,
completedParts,
getSession: () => session,
};
}
export function executorFor(
harness: ControlHarness,
options: Readonly<{
delay?: () => Promise<void>;
onActive?: (active: number) => void;
}> = {},
): UploadPartExecutor<TestCapability> {
let active = 0;
return {
async uploadPart(input) {
active += 1;
options.onActive?.(active);
await options.delay?.();
active -= 1;
const receipt = Object.freeze({
...input.part,
receiptToken: `etag-part-${input.part.partNumber}`,
});
harness.accepted.set(input.part.partNumber, receipt);
return browserDataSuccess(receipt);
},
};
}
@@ -0,0 +1,188 @@
import { describe, expect, it, vi } from "vitest";
import { browserDataFailure } from "../../src/adapters/browser-file-storage/result.ts";
import { createResumableUploadRuntime } from "../../src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts";
import {
activeSignal,
byteStreamSource,
createControlHarness,
executorFor,
MemoryCheckpointStore,
noContentionLock,
runtimePolicy,
} from "./resumable-upload-runtime-fixture.ts";
/**
* TR-RR-06. A non-cooperative mutation lock or provider must not make teardown
* unbounded: `dispose()` bounds its drain and reports honestly when the runtime
* is still CLOSING, and an abort is admitted physical work it cannot step over.
*/
describe("TR-RR-06 bounded resumable teardown", () => {
it("reports an unproved drain instead of waiting forever", async () => {
const checkpoints = new MemoryCheckpointStore();
const harness = createControlHarness();
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness, { delay: async () => {} }),
checkpoints,
// A lock that never grants: dispose must still be bounded.
mutationLock: Object.freeze({
async run<Value>(): Promise<Value> {
return await new Promise<never>(() => {});
},
}),
crypto,
policy: runtimePolicy({ cleanupDeadlineMs: 20 }),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
void runtime.upload({
uploadKey: "upload_key_hung",
purpose: "attachment",
mediaType: "application/octet-stream",
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
signal: activeSignal,
});
await Promise.resolve();
await Promise.resolve();
const disposed = await runtime.dispose();
expect(disposed.ok).toBe(false);
// Still CLOSING: physical work the caller must not treat as finished.
expect(runtime.lifecycle()).toBe("CLOSING");
expect(checkpoints.closed).toBe(false);
});
it("closes once every admitted operation settles", async () => {
const checkpoints = new MemoryCheckpointStore();
const harness = createControlHarness();
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness, { delay: async () => {} }),
checkpoints,
mutationLock: noContentionLock,
crypto,
policy: runtimePolicy({ cleanupDeadlineMs: 200 }),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
const disposed = await runtime.dispose();
expect(disposed).toMatchObject({ ok: true });
expect(runtime.lifecycle()).toBe("CLOSED");
expect(checkpoints.closed).toBe(true);
});
});
/**
* TR-04. `dispose()` proved quiescence from the wrapper registry only. A
* provider that ignored its attempt deadline let the wrapper settle first and
* leave the set empty, so teardown reported a drained runtime and closed the
* checkpoint store while the provider was still running.
*/
describe("TR-04 teardown waits for raw provider work, not only its wrapper", () => {
it("refuses to report a drained runtime while a provider is still running", async () => {
const harness = createControlHarness();
const checkpoints = new MemoryCheckpointStore();
const closeStore = vi.spyOn(checkpoints, "close");
let releaseProvider: (() => void) | undefined;
// Ignores the attempt signal entirely and outlives its own deadline.
harness.control.createSession = () =>
new Promise((resolve) => {
releaseProvider = () =>
resolve(
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
retryable: true,
recovery: "RESUME",
}),
);
});
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness),
checkpoints,
mutationLock: noContentionLock,
crypto,
policy: runtimePolicy({
providerAttemptTimeoutMs: 5,
cleanupDeadlineMs: 25,
maxRetries: 0,
}),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
const uploading = runtime.upload({
uploadKey: "upload_key_raw",
purpose: "attachment",
mediaType: "application/octet-stream",
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
signal: activeSignal,
});
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
// The wrapper has already given up on the attempt.
await uploading;
const disposed = await runtime.dispose();
expect(disposed.ok).toBe(false);
if (!disposed.ok) {
expect(disposed.error.code).toBe("UNAVAILABLE");
expect(disposed.error.recovery).toBe("RESUME");
}
// The store stays open while something could still write a checkpoint.
expect(closeStore).not.toHaveBeenCalled();
releaseProvider?.();
});
it("reports a drained runtime once the raw provider settles", async () => {
const harness = createControlHarness();
const checkpoints = new MemoryCheckpointStore();
let releaseProvider: (() => void) | undefined;
harness.control.createSession = () =>
new Promise((resolve) => {
releaseProvider = () =>
resolve(
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
retryable: true,
recovery: "RESUME",
}),
);
});
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness),
checkpoints,
mutationLock: noContentionLock,
crypto,
policy: runtimePolicy({
providerAttemptTimeoutMs: 5,
cleanupDeadlineMs: 1_000,
maxRetries: 0,
}),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
const uploading = runtime.upload({
uploadKey: "upload_key_raw_2",
purpose: "attachment",
mediaType: "application/octet-stream",
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
signal: activeSignal,
});
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
await uploading;
const disposing = runtime.dispose();
releaseProvider?.();
await expect(disposing).resolves.toMatchObject({ ok: true });
expect(runtime.lifecycle()).toBe("CLOSED");
});
});
+13 -487
View File
@@ -32,319 +32,19 @@ import type {
} from "../../src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts";
import type { UploadMutationLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
type TestCapability = Readonly<{ id: string }>;
const activeSignal = new AbortController().signal;
const noContentionLock: UploadMutationLock = Object.freeze({
async run<Value>(
_uploadKey: string,
_signal: AbortSignal,
task: () => Promise<Value>,
): Promise<Value> {
return await task();
},
});
function createSerialMutationLock(): UploadMutationLock {
let tail = Promise.resolve();
return Object.freeze({
run<Value>(
_uploadKey: string,
signal: AbortSignal,
task: () => Promise<Value>,
): Promise<Value> {
const result = tail.then(async () => {
if (signal.aborted) {
throw new DOMException(
"The operation was aborted.",
"AbortError",
);
}
return await task();
});
tail = result.then(
() => undefined,
() => undefined,
);
return result;
},
});
}
function createMemoryCancellationPair(): readonly [
UploadCancellationChannel,
UploadCancellationChannel,
] {
const listeners = [
new Set<UploadCancellationListener>(),
new Set<UploadCancellationListener>(),
] as const;
const channels = listeners.map((ownListeners, ownIndex) => {
let closed = false;
return Object.freeze({
publish(uploadKey: string) {
if (closed) return false;
for (const [index, peerListeners] of listeners.entries()) {
if (index === ownIndex) continue;
for (const listener of [...peerListeners]) {
listener(uploadKey);
}
}
return true;
},
subscribe(listener: UploadCancellationListener) {
if (closed) throw new TypeError("closed");
ownListeners.add(listener);
return () => ownListeners.delete(listener);
},
close() {
closed = true;
ownListeners.clear();
},
});
});
return channels as unknown as readonly [
UploadCancellationChannel,
UploadCancellationChannel,
];
}
class MemoryCheckpointStore implements ResumableUploadCheckpointStore {
readonly rows = new Map<string, ResumableUploadCheckpoint>();
closed = false;
async read(
uploadKey: string,
): Promise<BrowserDataResult<ResumableUploadCheckpoint | null>> {
return browserDataSuccess(
structuredClone(this.rows.get(uploadKey) ?? null),
);
}
async compareAndSwap(
input: Parameters<
ResumableUploadCheckpointStore["compareAndSwap"]
>[0],
): Promise<BrowserDataResult<ResumableUploadCheckpoint>> {
const current = this.rows.get(input.checkpoint.uploadKey);
if (
(input.expectedRevision === null && current) ||
(input.expectedRevision !== null &&
current?.revision !== input.expectedRevision)
) {
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
recovery: "RECONCILE",
});
}
const snapshot = structuredClone(input.checkpoint);
this.rows.set(snapshot.uploadKey, snapshot);
return browserDataSuccess(snapshot);
}
async remove(
input: Parameters<ResumableUploadCheckpointStore["remove"]>[0],
): Promise<BrowserDataResult<void>> {
const current = this.rows.get(input.uploadKey);
if (current?.revision !== input.expectedRevision) {
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
recovery: "RECONCILE",
});
}
this.rows.delete(input.uploadKey);
return browserDataSuccess(undefined);
}
close(): void {
this.closed = true;
}
}
function rangeSource(bytes: Uint8Array): ResumableUploadSource {
return Object.freeze({
kind: "RANGE_READER" as const,
reader: Object.freeze({
byteLength: bytes.byteLength,
async readRange(input: Readonly<{
offset: number;
length: number;
signal: AbortSignal;
}>) {
if (input.signal.aborted) {
return browserDataFailure("ABORTED", "FILE_READ");
}
return browserDataSuccess(
bytes.slice(input.offset, input.offset + input.length),
);
},
}),
});
}
function byteStreamSource(bytes: Uint8Array): ResumableUploadSource {
return Object.freeze({
kind: "FILE_BYTE_SOURCE" as const,
bytes: Object.freeze({
byteLength: bytes.byteLength,
async *stream(signal: AbortSignal) {
if (signal.aborted) {
yield browserDataFailure("ABORTED", "FILE_READ");
return;
}
yield browserDataSuccess(bytes.slice(0, 3));
yield browserDataSuccess(bytes.slice(3));
},
}),
});
}
function runtimePolicy(
overrides: Partial<
Parameters<typeof resolveResumableUploadRuntimePolicy>[0]
> = {},
) {
return {
partSizeBytes: 4,
maxFileBytes: 100,
maxPartCount: 25,
maxConcurrency: 3,
maxInFlightBytes: 48,
partBufferCopyFactor: 4,
maxSourceChunkBytes: 8,
maxRetries: 2,
retryBaseDelayMs: 1,
retryMaxDelayMs: 10,
maxRetryAfterMs: 100,
capabilityRefreshSkewMs: 5,
maxSessionLifetimeMs: 10_000,
providerAttemptTimeoutMs: 100,
...overrides,
};
}
type ControlHarness = Readonly<{
control: ResumableUploadControlPlane<TestCapability>;
accepted: Map<number, UploadPartReceipt>;
issued: ReturnType<typeof vi.fn>;
completedParts: UploadPartReceipt[][];
getSession(): UploadSession | null;
}>;
function createControlHarness(options: Readonly<{
now?: number;
serverMaxConcurrency?: number;
sessionId?: (createIndex: number) => string;
statusParts?: (
session: UploadSession,
accepted: Map<number, UploadPartReceipt>,
) => readonly UploadPartReceipt[];
issueCapability?: (
input: Parameters<
ResumableUploadControlPlane<TestCapability>["issuePartCapability"]
>[0],
callIndex: number,
) => UploadProviderResult<Readonly<{
capability: TestCapability;
uploadBindingSha256: string;
expiresAtEpochMs: number;
}>>;
}> = {}): ControlHarness {
const now = options.now ?? 1_000;
const accepted = new Map<number, UploadPartReceipt>();
const completedParts: UploadPartReceipt[][] = [];
let session: UploadSession | null = null;
let createCount = 0;
let issueCount = 0;
const issued = vi.fn();
const control: ResumableUploadControlPlane<TestCapability> = {
async createSession(input) {
createCount += 1;
session = Object.freeze({
protocol: RESUMABLE_UPLOAD_PROTOCOL,
sessionId:
options.sessionId?.(createCount) ?? "session_01",
requestBindingSha256: input.requestBindingSha256,
fingerprint: input.fingerprint,
partSizeBytes: input.requestedPartSizeBytes,
partCount: input.fingerprint.partCount,
maxConcurrency: options.serverMaxConcurrency ?? 2,
expiresAtEpochMs: now + 5_000,
});
return browserDataSuccess(session);
},
async getStatus() {
if (!session) {
return browserDataFailure("NOT_FOUND", "UPLOAD_RECONCILE");
}
const parts =
options.statusParts?.(session, accepted) ??
[...accepted.values()].sort(
(left, right) => left.partNumber - right.partNumber,
);
return browserDataSuccess({
state: "ACTIVE",
session,
acceptedParts: parts,
});
},
async issuePartCapability(input) {
issueCount += 1;
issued(input);
return (
options.issueCapability?.(input, issueCount) ??
browserDataSuccess({
capability: Object.freeze({ id: `cap-${issueCount}` }),
uploadBindingSha256: input.uploadBindingSha256,
expiresAtEpochMs: now + 4_000,
})
);
},
async complete(input) {
completedParts.push([...input.orderedParts]);
return browserDataSuccess({
state: "QUARANTINED",
protocol: RESUMABLE_UPLOAD_PROTOCOL,
sessionId: input.sessionId,
requestBindingSha256: input.requestBindingSha256,
fingerprint: input.fingerprint,
resourceId: "resource_01",
});
},
async abort() {
return browserDataSuccess({ state: "ABORTED" });
},
};
return {
control,
accepted,
issued,
completedParts,
getSession: () => session,
};
}
function executorFor(
harness: ControlHarness,
options: Readonly<{
delay?: () => Promise<void>;
onActive?: (active: number) => void;
}> = {},
): UploadPartExecutor<TestCapability> {
let active = 0;
return {
async uploadPart(input) {
active += 1;
options.onActive?.(active);
await options.delay?.();
active -= 1;
const receipt = Object.freeze({
...input.part,
receiptToken: `etag-part-${input.part.partNumber}`,
});
harness.accepted.set(input.part.partNumber, receipt);
return browserDataSuccess(receipt);
},
};
}
import {
activeSignal,
byteStreamSource,
createControlHarness,
createMemoryCancellationPair,
createSerialMutationLock,
executorFor,
MemoryCheckpointStore,
noContentionLock,
rangeSource,
runtimePolicy,
type TestCapability,
} from "./resumable-upload-runtime-fixture.ts";
describe("production resumable upload runtime", () => {
it("disposes through one drain that proves quiescence", async () => {
@@ -1280,177 +980,3 @@ describe("production resumable upload runtime", () => {
}
});
});
/**
* TR-RR-06. A non-cooperative mutation lock or provider must not make teardown
* unbounded: `dispose()` bounds its drain and reports honestly when the runtime
* is still CLOSING, and an abort is admitted physical work it cannot step over.
*/
describe("TR-RR-06 bounded resumable teardown", () => {
it("reports an unproved drain instead of waiting forever", async () => {
const checkpoints = new MemoryCheckpointStore();
const harness = createControlHarness();
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness, { delay: async () => {} }),
checkpoints,
// A lock that never grants: dispose must still be bounded.
mutationLock: Object.freeze({
async run<Value>(): Promise<Value> {
return await new Promise<never>(() => {});
},
}),
crypto,
policy: runtimePolicy({ cleanupDeadlineMs: 20 }),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
void runtime.upload({
uploadKey: "upload_key_hung",
purpose: "attachment",
mediaType: "application/octet-stream",
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
signal: activeSignal,
});
await Promise.resolve();
await Promise.resolve();
const disposed = await runtime.dispose();
expect(disposed.ok).toBe(false);
// Still CLOSING: physical work the caller must not treat as finished.
expect(runtime.lifecycle()).toBe("CLOSING");
expect(checkpoints.closed).toBe(false);
});
it("closes once every admitted operation settles", async () => {
const checkpoints = new MemoryCheckpointStore();
const harness = createControlHarness();
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness, { delay: async () => {} }),
checkpoints,
mutationLock: noContentionLock,
crypto,
policy: runtimePolicy({ cleanupDeadlineMs: 200 }),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
const disposed = await runtime.dispose();
expect(disposed).toMatchObject({ ok: true });
expect(runtime.lifecycle()).toBe("CLOSED");
expect(checkpoints.closed).toBe(true);
});
});
/**
* TR-04. `dispose()` proved quiescence from the wrapper registry only. A
* provider that ignored its attempt deadline let the wrapper settle first and
* leave the set empty, so teardown reported a drained runtime and closed the
* checkpoint store while the provider was still running.
*/
describe("TR-04 teardown waits for raw provider work, not only its wrapper", () => {
it("refuses to report a drained runtime while a provider is still running", async () => {
const harness = createControlHarness();
const checkpoints = new MemoryCheckpointStore();
const closeStore = vi.spyOn(checkpoints, "close");
let releaseProvider: (() => void) | undefined;
// Ignores the attempt signal entirely and outlives its own deadline.
harness.control.createSession = () =>
new Promise((resolve) => {
releaseProvider = () =>
resolve(
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
retryable: true,
recovery: "RESUME",
}),
);
});
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness),
checkpoints,
mutationLock: noContentionLock,
crypto,
policy: runtimePolicy({
providerAttemptTimeoutMs: 5,
cleanupDeadlineMs: 25,
maxRetries: 0,
}),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
const uploading = runtime.upload({
uploadKey: "upload_key_raw",
purpose: "attachment",
mediaType: "application/octet-stream",
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
signal: activeSignal,
});
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
// The wrapper has already given up on the attempt.
await uploading;
const disposed = await runtime.dispose();
expect(disposed.ok).toBe(false);
if (!disposed.ok) {
expect(disposed.error.code).toBe("UNAVAILABLE");
expect(disposed.error.recovery).toBe("RESUME");
}
// The store stays open while something could still write a checkpoint.
expect(closeStore).not.toHaveBeenCalled();
releaseProvider?.();
});
it("reports a drained runtime once the raw provider settles", async () => {
const harness = createControlHarness();
const checkpoints = new MemoryCheckpointStore();
let releaseProvider: (() => void) | undefined;
harness.control.createSession = () =>
new Promise((resolve) => {
releaseProvider = () =>
resolve(
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
retryable: true,
recovery: "RESUME",
}),
);
});
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness),
checkpoints,
mutationLock: noContentionLock,
crypto,
policy: runtimePolicy({
providerAttemptTimeoutMs: 5,
cleanupDeadlineMs: 1_000,
maxRetries: 0,
}),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
const uploading = runtime.upload({
uploadKey: "upload_key_raw_2",
purpose: "attachment",
mediaType: "application/octet-stream",
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
signal: activeSignal,
});
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
await uploading;
const disposing = runtime.dispose();
releaseProvider?.();
await expect(disposing).resolves.toMatchObject({ ok: true });
expect(runtime.lifecycle()).toBe("CLOSED");
});
});
+541
View File
@@ -0,0 +1,541 @@
import {
createHash,
generateKeyPairSync,
sign,
type KeyObject,
} from "node:crypto";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts";
import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts";
import {
providerEvidenceSignaturePayload,
trustPolicySha256,
} from "../../scripts/lib/provider-evidence.ts";
import {
LOCAL_EVIDENCE_ASSESSMENT_PATH,
distSha256,
type ReleaseCandidateManifest,
} from "../../scripts/lib/release-candidate.ts";
import { supplyChainDigest } from "../../scripts/lib/supply-chain.ts";
export const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
export const digest = (value: string): string =>
createHash("sha256").update(value).digest("hex");
export const digestBytes = (value: Buffer): string =>
createHash("sha256").update(value).digest("hex");
export function passingAssessment(): any {
return {
schemaVersion: 1 as const,
artifactType: "local-evidence-assessment" as const,
generatedAt: "2026-08-02T00:00:00.000Z",
status: "PASS" as const,
verifier: {
id: "clean-architecture-frontend-template/local-evidence-verifier",
version: "1",
sourceSha256: digest("verifier source"),
},
source: {
revision: "a".repeat(40),
sourceSetSha256: digest("source set"),
},
candidate: {
distSha256: digest("dist"),
lockfileSha256: digest("lockfile"),
sbomSha256: digest("sbom"),
},
secretScan: {
policySha256: digest("secret policy"),
sarifSha256: digest("secret sarif"),
scanInputSha256: digest("secret scan input"),
},
policyInputs: [
{
path: "config/security/dependency-policy.json",
bytes: 3,
sha256: digest("{}\n"),
},
],
evidenceInputs: [
{ path: "pnpm-lock.yaml", bytes: 9, sha256: digest("lockfile\n") },
],
checks: {
release: "PASS" as const,
supplyChain: "PASS" as const,
dependencyPolicy: "PASS" as const,
licensePolicy: "PASS" as const,
vulnerabilityPolicy: "PASS" as const,
secretScan: "PASS" as const,
},
failures: [] as string[],
};
}
export function providerExpectedContext() {
return {
run: { id: "run-42", attempt: 1 },
source: { revision: "b".repeat(40), sourceSetSha256: digest("provider source") },
candidate: {
archiveSha256: digest("archive"),
bundleSha256: digest("bundle"),
distSha256: digest("provider dist"),
lockfileSha256: digest("provider lockfile"),
},
secretScanAttestation: {
status: "PASS" as const,
localEvidenceAssessmentSha256: digest("provider assessment"),
sourceSetSha256: digest("provider source"),
policySha256: digest("provider secret policy"),
sarifSha256: digest("provider secret sarif"),
scanInputSha256: digest("provider secret input"),
},
} as const;
}
export function privatePromotionFiles() {
return PROMOTED_FILE_NAMES.map((name) => {
const bytes = Buffer.from(`${name}\n`);
return { name, bytes, sha256: digestBytes(bytes) };
});
}
export function syntheticSignedPromotionBundle() {
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const archiveBytes = Buffer.from("synthetic signed replay archive\n");
const run = { id: "signed-run", attempt: 1 } as const;
const source = {
revision: "a".repeat(40),
sourceSetSha256: digest("synthetic-source-set"),
} as const;
const candidate = {
archiveSha256: digestBytes(archiveBytes),
bundleSha256: digest("synthetic-bundle"),
distSha256: digest("synthetic-dist"),
lockfileSha256: digest("synthetic-lock"),
} as const;
const vulnerability = signedProviderV2(
{
schemaVersion: 2,
evidenceType: "vulnerability-report",
provider: "synthetic-vulnerability-provider",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { ...run, invocationNonce: "1".repeat(64) },
source,
candidate,
secretScanAttestation: {
status: "PASS",
localEvidenceAssessmentSha256: digest("synthetic-assessment"),
sourceSetSha256: source.sourceSetSha256,
policySha256: digest("synthetic-policy"),
sarifSha256: digest("synthetic-sarif"),
scanInputSha256: digest("synthetic-scan-input"),
},
findings: [],
},
"synthetic-vulnerability",
vulnerabilityKeys.publicKey,
vulnerabilityKeys.privateKey,
);
const provenance = signedProviderV2(
{
schemaVersion: 2,
evidenceType: "provenance-attestation",
provider: "synthetic-provenance-provider",
signer: "synthetic-signer",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { ...run, invocationNonce: "2".repeat(64) },
source,
candidate,
subject: { name: "dist", digest: { sha256: candidate.distSha256 } },
},
"synthetic-provenance",
provenanceKeys.publicKey,
provenanceKeys.privateKey,
);
const vulnerabilityBytes = Buffer.from(`${JSON.stringify(vulnerability)}\n`);
const provenanceBytes = Buffer.from(`${JSON.stringify(provenance)}\n`);
const vulnerabilityTrust = trust("synthetic-vulnerability", vulnerabilityKeys.publicKey);
const provenanceTrust = trust("synthetic-provenance", provenanceKeys.publicKey);
const providerEvidence = {
vulnerabilityReportSha256: digestBytes(vulnerabilityBytes),
provenanceAttestationSha256: digestBytes(provenanceBytes),
vulnerabilityInvocationNonce: "1".repeat(64),
provenanceInvocationNonce: "2".repeat(64),
vulnerabilityKeyId: vulnerabilityTrust.keyId,
vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint,
provenanceKeyId: provenanceTrust.keyId,
provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint,
secretScanAttestation: vulnerability.secretScanAttestation,
};
const common = {
schemaVersion: 3,
verifiedAt: "2026-08-02T01:00:00.000Z",
status: "PASS",
verifier: {
id: "clean-architecture-frontend-template/promotion-verifier",
version: "3",
},
run,
source,
candidate,
providerEvidence,
trustPolicySha256: trustPolicySha256({ vulnerabilityTrust, provenanceTrust }),
failures: [],
};
const providerBytes = Buffer.from(
`${JSON.stringify({
...common,
artifactType: "provider-verification",
vulnerabilityStatus: "PASS",
provenanceAttestationStatus: "PASS",
}, null, 2)}\n`,
);
const promotionBytes = Buffer.from(
`${JSON.stringify({
...common,
artifactType: "promotion-verification",
localEvidenceStatus: "PASS",
localEvidenceAssessmentSha256: digest("synthetic-assessment"),
providerVerificationSha256: digestBytes(providerBytes),
}, null, 2)}\n`,
);
return {
files: {
"release-candidate.tar.gz": archiveBytes,
"vulnerability-report.json": vulnerabilityBytes,
"provenance-attestation.json": provenanceBytes,
"provider-verification.json": providerBytes,
"promotion-verification.json": promotionBytes,
},
verification: {
vulnerabilityTrust,
provenanceTrust,
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
expected: {
run,
sourceRevision: source.revision,
sourceSetSha256: source.sourceSetSha256,
archiveSha256: candidate.archiveSha256,
bundleSha256: candidate.bundleSha256,
distSha256: candidate.distSha256,
lockfileSha256: candidate.lockfileSha256,
},
},
vulnerabilityPem: vulnerabilityKeys.publicKey.export({ type: "spki", format: "pem" }),
provenancePem: provenanceKeys.publicKey.export({ type: "spki", format: "pem" }),
};
}
export function fingerprint(publicKey: KeyObject): string {
return `sha256:${createHash("sha256")
.update(publicKey.export({ type: "spki", format: "der" }))
.digest("hex")}`;
}
export function trust(keyId: string, publicKey: KeyObject) {
return { keyId, publicKey, publicKeyFingerprint: fingerprint(publicKey) };
}
export function signedProviderV2(
unsigned: Record<string, unknown>,
keyId: string,
publicKey: KeyObject,
privateKey: KeyObject,
fingerprintOverride?: string,
): Record<string, any> {
const { signature: existingSignature, ...payload } = unsigned;
const value = {
...payload,
signature: {
algorithm: "Ed25519" as const,
keyId,
publicKeyFingerprint:
fingerprintOverride ??
(existingSignature && typeof existingSignature === "object" &&
"publicKeyFingerprint" in existingSignature
? String(existingSignature.publicKeyFingerprint)
: fingerprint(publicKey)),
value: "",
},
};
value.signature.value = sign(
null,
providerEvidenceSignaturePayload(value),
privateKey,
).toString("base64");
return value;
}
export function providerUnsigned(
kind: "vulnerability" | "provenance",
expected: ReturnType<typeof providerExpectedContext>,
): Record<string, any> {
const common = {
source: expected.source,
candidate: expected.candidate,
schemaVersion: 2,
evidenceType:
kind === "vulnerability"
? "vulnerability-report"
: "provenance-attestation",
provider: `fixture-${kind}`,
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: {
...expected.run,
invocationNonce: kind === "vulnerability" ? "1".repeat(64) : "2".repeat(64),
},
};
return kind === "vulnerability"
? {
...common,
secretScanAttestation: expected.secretScanAttestation,
findings: [],
}
: {
...common,
signer: "fixture-workload",
subject: {
name: "dist",
digest: { sha256: expected.candidate.distSha256 },
},
};
}
export async function createArchivedAssessmentFixture(): Promise<{
root: string;
manifest: ReleaseCandidateManifest;
assessmentSha256: string;
}> {
const root = await mkdtemp(path.join(tmpdir(), "archived-assessment-"));
const sourceRevision = "a".repeat(40);
const sourceSetSha256 = digest("source set");
const releaseManifestBytes = Buffer.from(
`${JSON.stringify({
schemaVersion: 1,
appVersion: "1.0.0",
buildId: "build-1",
commitSha: sourceRevision,
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: digest("vite manifest"),
releaseId: "release-1",
builtAt: "2026-08-02T00:00:00.000Z",
routeChunks: { home: "assets/home.js" },
})}\n`,
);
const distInputs = [
{ path: "dist/app.js", bytes: Buffer.byteLength("app\n"), sha256: digest("app\n"), gzipBytes: 0 },
{
path: "dist/release-manifest.json",
bytes: releaseManifestBytes.byteLength,
sha256: digestBytes(releaseManifestBytes),
gzipBytes: 0,
},
];
const candidateDist = distSha256(distInputs);
const sbomBytes = Buffer.from(
`${JSON.stringify({
bomFormat: "CycloneDX",
specVersion: "1.6",
serialNumber: "urn:uuid:00000000-0000-4000-8000-000000000001",
version: 1,
metadata: {
component: { type: "application", name: "fixture", version: "1.0.0" },
properties: [],
},
components: [],
dependencies: [],
})}\n`,
);
const sbomSha256 = digestBytes(sbomBytes);
const lockfileBytes = Buffer.from("lockfile\n");
const lockfileDigest = digestBytes(lockfileBytes);
const buildManifest = {
schemaVersion: 1,
buildId: "build-1",
commitSha: sourceRevision,
releaseId: "release-1",
moduleInventoryHash: digest("module inventory"),
generatedAt: "2026-08-02T00:00:00.000Z",
buildContext: {
nodeVersion: "v24.0.0",
packageManagerVersion: "11.0.0",
runnerImage: "linux-x64",
sourceDateEpoch: "1785638400",
},
outputs: {
directory: "dist",
viteManifest: "dist/.vite/manifest.json",
moduleInventory: "artifacts/quality/vite-module-inventory.json",
routeChunks: { home: "assets/home.js" },
runtimeConfigSchema: "dist/runtime-config.schema.json",
},
};
const provenance = {
_type: "https://in-toto.io/Statement/v1",
subject: [{ name: "dist", digest: { sha256: candidateDist } }],
predicateType: "https://slsa.dev/provenance/v1",
predicate: {
buildDefinition: {
buildType: "https://vite.dev/build/v1",
externalParameters: {},
internalParameters: {},
resolvedDependencies: [
{ uri: "pnpm-lock.yaml", digest: { sha256: lockfileDigest } },
],
},
runDetails: {
builder: { id: "fixture-builder" },
metadata: { invocationId: "LOCAL_UNSIGNED" },
},
materials: { lockfileSha256: lockfileDigest, sourceSetSha256, sbomSha256 },
},
};
const supplyVerification = {
schemaVersion: 1,
localStatus: "PASS",
promotionStatus: "FAIL_UNVERIFIED",
lockfileSha256: lockfileDigest,
sourceSetSha256,
distSha256: candidateDist,
sbomSha256,
dependencyDiff: { added: [], removed: [], changed: [], upgrades: [] },
highRiskReview: [],
vulnerabilityStatus: "FAIL_UNVERIFIED",
provenanceAttestationStatus: "FAIL_UNVERIFIED",
failures: [],
};
const members = new Map<string, Buffer>([
["dist/app.js", Buffer.from("app\n")],
["dist/release-manifest.json", releaseManifestBytes],
["pnpm-lock.yaml", lockfileBytes],
["artifacts/release/build-manifest.json", Buffer.from(`${JSON.stringify(buildManifest)}\n`)],
["artifacts/release/provenance.json", Buffer.from(`${JSON.stringify(provenance)}\n`)],
[
"artifacts/security/supply-chain-verification.json",
Buffer.from(`${JSON.stringify(supplyVerification)}\n`),
],
["artifacts/release/sbom.cdx.json", sbomBytes],
]);
const evidenceInputs = [...members.entries()]
.map(([memberPath, bytes]) => ({
path: memberPath,
bytes: bytes.byteLength,
sha256: digestBytes(bytes),
}))
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
const policyPaths = [
"config/security/dependency-baseline.approval.json",
"config/security/dependency-baseline.json",
"config/security/dependency-change-evidence.json",
"config/security/dependency-policy.json",
"config/security/secret-scan-policy.json",
"config/security/vulnerability-exceptions.json",
"config/security/vulnerability-policy.json",
"schemas/artifacts/build-manifest.schema.json",
"schemas/artifacts/dependency-inventory.schema.json",
"schemas/artifacts/supply-chain-verification.schema.json",
"scripts/contracts/release-artifacts.ts",
"scripts/create-release-candidate.ts",
"scripts/generate-supply-chain.ts",
"scripts/lib/build-manifest-outputs.ts",
"scripts/lib/json-schema.ts",
"scripts/lib/local-policy-evidence.ts",
"scripts/lib/local-release-evidence.ts",
"scripts/lib/release-candidate.ts",
"scripts/lib/release-input-evidence.ts",
"scripts/lib/release-runtime-coherence.ts",
"scripts/lib/repository-file-inventory.ts",
"scripts/lib/secret-scan-evaluator.ts",
"scripts/lib/secret-scan-policy.ts",
"scripts/lib/secret-scan.ts",
"scripts/lib/supply-chain.ts",
"scripts/lib/validated-json-artifact.ts",
"src/contracts/release-artifacts.ts",
"src/features/installed-contract-contributions.ts",
"src/features/installed-feature-contracts.ts",
];
const sbomRow = evidenceInputs.find(
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
)!;
const policyInputs = policyPaths.map((policyPath) => ({
path: policyPath,
bytes: 2,
sha256: digest(`policy:${policyPath}`),
}));
const verifierPaths = new Set([
"scripts/contracts/release-artifacts.ts",
"scripts/create-release-candidate.ts",
"scripts/generate-supply-chain.ts",
"scripts/lib/build-manifest-outputs.ts",
"scripts/lib/json-schema.ts",
"scripts/lib/local-policy-evidence.ts",
"scripts/lib/local-release-evidence.ts",
"scripts/lib/release-candidate.ts",
"scripts/lib/release-input-evidence.ts",
"scripts/lib/release-runtime-coherence.ts",
"scripts/lib/repository-file-inventory.ts",
"scripts/lib/secret-scan-evaluator.ts",
"scripts/lib/secret-scan-policy.ts",
"scripts/lib/secret-scan.ts",
"scripts/lib/supply-chain.ts",
"scripts/lib/validated-json-artifact.ts",
"src/contracts/release-artifacts.ts",
"src/features/installed-contract-contributions.ts",
"src/features/installed-feature-contracts.ts",
]);
const assessment = localEvidenceAssessmentArtifactSchema.parse({
...passingAssessment(),
verifier: {
id: "clean-architecture-frontend-template/local-evidence-verifier",
version: "1",
sourceSha256: supplyChainDigest(
policyInputs.filter(({ path: policyPath }) => verifierPaths.has(policyPath)),
),
},
source: { revision: sourceRevision, sourceSetSha256 },
candidate: {
distSha256: candidateDist,
lockfileSha256: evidenceInputs.find(({ path: memberPath }) => memberPath === "pnpm-lock.yaml")!
.sha256,
sbomSha256: sbomRow.sha256,
},
policyInputs,
evidenceInputs,
});
const assessmentBytes = Buffer.from(`${JSON.stringify(assessment)}\n`);
members.set(LOCAL_EVIDENCE_ASSESSMENT_PATH, assessmentBytes);
for (const [memberPath, bytes] of members) {
await mkdir(path.dirname(path.join(root, memberPath)), { recursive: true });
await writeFile(path.join(root, memberPath), bytes);
}
const files = [...members.entries()]
.map(([memberPath, bytes]) => ({
path: memberPath,
bytes: bytes.byteLength,
sha256: digestBytes(bytes),
}))
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
const manifest: ReleaseCandidateManifest = {
schemaVersion: 1,
distSha256: assessment.candidate.distSha256,
lockfileSha256: assessment.candidate.lockfileSha256,
bundleSha256: supplyChainDigest(files),
files,
};
await mkdir(path.join(root, "artifacts/release"), { recursive: true });
await writeFile(
path.join(root, "artifacts/release/release-candidate.json"),
`${JSON.stringify(manifest)}\n`,
);
return { root, manifest, assessmentSha256: digestBytes(assessmentBytes) };
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,370 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
chmod,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rename,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts";
import { publishPrivatePromotionStaging } from "../../scripts/lib/promotion-stager.ts";
import { verifyExactPromotionBundle } from "../../scripts/lib/exact-promotion-bundle.ts";
import {
PROCESS_HEAVY_TIMEOUT_MS,
privatePromotionFiles,
syntheticSignedPromotionBundle,
} from "./security-followup-fixture.ts";
describe("security private promotion staging contracts", () => {
it("forces exact private staging modes in an isolated child with umask 077", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-umask-"));
try {
const stagerUrl = pathToFileURL(
path.join(process.cwd(), "scripts/lib/promotion-stager.ts"),
).href;
const contractsUrl = pathToFileURL(
path.join(process.cwd(), "scripts/contracts/promotion-artifacts.ts"),
).href;
const childPath = path.join(root, "umask-child.mjs");
const resultPath = path.join(root, "result.json");
await writeFile(resultPath, "{}\n", { mode: 0o600 });
await writeFile(
childPath,
[
`import { lstat, writeFile } from "node:fs/promises";`,
`import path from "node:path";`,
`import { createHash } from "node:crypto";`,
`import { cleanupFinalizedPromotion, publishPrivatePromotionStaging } from ${JSON.stringify(stagerUrl)};`,
`import { PROMOTED_FILE_NAMES } from ${JSON.stringify(contractsUrl)};`,
`process.umask(Number.parseInt(process.argv[2], 8));`,
`const runnerTempRoot = process.argv[3];`,
`const files = PROMOTED_FILE_NAMES.map((name) => { const bytes = Buffer.from(name); return { name, bytes, sha256: createHash("sha256").update(bytes).digest("hex") }; });`,
`const finalized = await publishPrivatePromotionStaging(runnerTempRoot, { id: "umask", attempt: 1 }, files, () => Buffer.alloc(16, 1));`,
`const directoryMode = (await lstat(finalized.stagingRoot)).mode & 0o777;`,
`const fileModes = await Promise.all(PROMOTED_FILE_NAMES.map(async (name) => (await lstat(path.join(finalized.stagingRoot, name))).mode & 0o777));`,
`await cleanupFinalizedPromotion({ runnerTempRoot, stagingRoot: finalized.stagingRoot, cleanupToken: finalized.cleanupToken, runnerTempIdentity: finalized.runnerTempIdentity, stagingIdentity: finalized.stagingIdentity });`,
`await writeFile(process.argv[4], JSON.stringify({ directoryMode, fileModes }));`,
].join("\n"),
);
const child = spawnSync(process.execPath, [childPath, "077", root, resultPath], {
cwd: root,
encoding: "utf8",
timeout: 30_000,
});
expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0);
expect(JSON.parse(await readFile(resultPath, "utf8"))).toEqual({
directoryMode: 0o700,
fileModes: [0o400, 0o400, 0o400, 0o400, 0o400],
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects a staged file unlinked and recreated after its original write", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-recreate-"));
const files = privatePromotionFiles();
const token = `promotion-seal-1-${"11".repeat(16)}`;
try {
await expect(
publishPrivatePromotionStaging(
root,
{ id: "seal", attempt: 1 },
files,
() => Buffer.alloc(16, 0x11),
undefined,
async (name) => {
if (name !== PROMOTED_FILE_NAMES.at(-1)) return;
const first = path.join(root, token, PROMOTED_FILE_NAMES[0]);
await rm(first);
await writeFile(first, "replacement bytes\n", { mode: 0o400 });
},
),
).rejects.toThrow(/staged.*digest|inode|seal/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects staged mode drift before returning the upload root", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-mode-"));
const files = privatePromotionFiles();
const token = `promotion-seal-1-${"12".repeat(16)}`;
try {
await expect(
publishPrivatePromotionStaging(
root,
{ id: "seal", attempt: 1 },
files,
() => Buffer.alloc(16, 0x12),
undefined,
async (name) => {
if (name === PROMOTED_FILE_NAMES.at(-1)) {
await chmod(path.join(root, token, PROMOTED_FILE_NAMES[0]), 0o600);
}
},
),
).rejects.toThrow(/mode.*0400|staged.*mode|seal/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects staging leaf replacement between mkdir and descriptor open", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-replace-"));
const files = privatePromotionFiles();
const token = `promotion-preopen-1-${"13".repeat(16)}`;
const displaced = path.join(root, `${token}-displaced`);
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
try {
await expect(
publishPrivatePromotionStaging(
root,
{ id: "preopen", attempt: 1 },
files,
() => Buffer.alloc(16, 0x13),
undefined,
undefined,
undefined,
async (stagingRoot) => {
await rename(stagingRoot, displaced);
await mkdir(stagingRoot, { mode: 0o700 });
await writeFile(replacementCanary, "external replacement canary\n");
},
),
).rejects.toThrow(/staging leaf.*changed|mkdir.*open|identity/u);
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
"external replacement canary\n",
);
await expect(readdir(displaced)).resolves.toEqual([]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("does not scan a crowded parent to recover an unverified pre-open leaf", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-bounded-"));
const files = privatePromotionFiles();
const token = `promotion-preopen-bound-1-${"14".repeat(16)}`;
const displaced = path.join(root, `${token}-displaced`);
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
try {
for (let offset = 0; offset < 4_097; offset += 128) {
await Promise.all(
Array.from(
{ length: Math.min(128, 4_097 - offset) },
(_, index) =>
mkdir(
path.join(
root,
`noise-${String(offset + index).padStart(4, "0")}`,
),
),
),
);
}
let failure: unknown;
try {
await publishPrivatePromotionStaging(
root,
{ id: "preopen-bound", attempt: 1 },
files,
() => Buffer.alloc(16, 0x14),
undefined,
undefined,
undefined,
async (stagingRoot) => {
await rename(stagingRoot, displaced);
await mkdir(stagingRoot, { mode: 0o700 });
await writeFile(replacementCanary, "external replacement canary\n");
},
);
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(Error);
expect(failure).not.toBeInstanceOf(AggregateError);
expect((failure as Error).message).toMatch(/staging leaf.*identity/i);
await expect(readdir(displaced)).resolves.toEqual([]);
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
"external replacement canary\n",
);
} finally {
await rm(root, { recursive: true, force: true });
}
}, 20_000);
it("leaves a non-empty moved original untouched after pre-open mismatch", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-nonempty-"));
const files = privatePromotionFiles();
const token = `promotion-preopen-nonempty-1-${"15".repeat(16)}`;
const displaced = path.join(root, `${token}-displaced`);
const ownedResidual = path.join(displaced, "owned-residual");
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
try {
let failure: unknown;
try {
await publishPrivatePromotionStaging(
root,
{ id: "preopen-nonempty", attempt: 1 },
files,
() => Buffer.alloc(16, 0x15),
undefined,
undefined,
undefined,
async (stagingRoot) => {
await rename(stagingRoot, displaced);
await writeFile(ownedResidual, "owned residual\n");
await mkdir(stagingRoot, { mode: 0o700 });
await writeFile(replacementCanary, "external replacement canary\n");
},
);
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(Error);
expect(failure).not.toBeInstanceOf(AggregateError);
expect((failure as Error).message).toMatch(/staging leaf.*identity/i);
await expect(readFile(ownedResidual, "utf8")).resolves.toBe(
"owned residual\n",
);
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
"external replacement canary\n",
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("does not search outside the parent for a moved unverified original", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-missing-"));
const outside = await mkdtemp(path.join(tmpdir(), "promotion-preopen-moved-"));
const files = privatePromotionFiles();
const token = `promotion-preopen-missing-1-${"16".repeat(16)}`;
const displaced = path.join(outside, token);
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
try {
let failure: unknown;
try {
await publishPrivatePromotionStaging(
root,
{ id: "preopen-missing", attempt: 1 },
files,
() => Buffer.alloc(16, 0x16),
undefined,
undefined,
undefined,
async (stagingRoot) => {
await rename(stagingRoot, displaced);
await mkdir(stagingRoot, { mode: 0o700 });
await writeFile(replacementCanary, "external replacement canary\n");
},
);
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(Error);
expect(failure).not.toBeInstanceOf(AggregateError);
expect((failure as Error).message).toMatch(/staging leaf.*identity/i);
await expect(lstat(displaced)).resolves.toEqual(
expect.objectContaining({ dev: expect.any(Number), ino: expect.any(Number) }),
);
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
"external replacement canary\n",
);
} finally {
await rm(root, { recursive: true, force: true });
await rm(outside, { recursive: true, force: true });
}
});
it("rejects a fresh signed exact-five bundle replayed under a different expected run", async () => {
const fixture = syntheticSignedPromotionBundle();
await expect(
verifyExactPromotionBundle(fixture.files, {
...fixture.verification,
expected: {
...fixture.verification.expected,
run: { id: "different-run", attempt: 1 },
},
}),
).rejects.toThrow(/external expected run.*mismatch|expected promotion run/u);
});
it("requires every external expected identity variable at the exact promotion CLI", async () => {
const fixture = syntheticSignedPromotionBundle();
const root = await mkdtemp(path.join(tmpdir(), "promotion-replay-cli-"));
const bundleRoot = path.join(root, "bundle");
try {
await mkdir(bundleRoot);
for (const [name, bytes] of Object.entries(fixture.files)) {
await writeFile(path.join(bundleRoot, name), bytes);
}
await writeFile(path.join(root, "vulnerability.pem"), fixture.vulnerabilityPem);
await writeFile(path.join(root, "provenance.pem"), fixture.provenancePem);
const cliPath = path.join(process.cwd(), "scripts/verify-exact-promotion-bundle.ts");
const baseEnvironment: NodeJS.ProcessEnv = {
...process.env,
PROMOTION_BUNDLE_ROOT: bundleRoot,
VULNERABILITY_PUBLIC_KEY_PATH: "vulnerability.pem",
VULNERABILITY_KEY_ID: "synthetic-vulnerability",
PROVENANCE_PUBLIC_KEY_PATH: "provenance.pem",
PROVENANCE_KEY_ID: "synthetic-provenance",
EXPECTED_PROMOTION_RUN_ID: fixture.verification.expected.run.id,
EXPECTED_PROMOTION_RUN_ATTEMPT: String(
fixture.verification.expected.run.attempt,
),
EXPECTED_PROMOTION_SOURCE_REVISION:
fixture.verification.expected.sourceRevision,
EXPECTED_PROMOTION_ARCHIVE_SHA256:
fixture.verification.expected.archiveSha256,
};
const requiredExpected = [
"EXPECTED_PROMOTION_RUN_ID",
"EXPECTED_PROMOTION_RUN_ATTEMPT",
"EXPECTED_PROMOTION_SOURCE_REVISION",
"EXPECTED_PROMOTION_ARCHIVE_SHA256",
] as const;
for (const missing of requiredExpected) {
const environment = { ...baseEnvironment };
delete environment[missing];
const result = spawnSync(process.execPath, [cliPath], {
cwd: root,
encoding: "utf8",
env: environment,
});
expect(result.status, missing).not.toBe(0);
expect(result.stderr, missing).toContain(
`exact promotion verification environment is missing ${missing}`,
);
}
for (const [name, value, diagnostic] of [
["EXPECTED_PROMOTION_RUN_ID", "different-run", /external expected run.*mismatch/u],
["EXPECTED_PROMOTION_RUN_ATTEMPT", "2", /external expected run.*mismatch/u],
["EXPECTED_PROMOTION_SOURCE_REVISION", "f".repeat(40), /external expected source revision.*mismatch/u],
["EXPECTED_PROMOTION_ARCHIVE_SHA256", "0".repeat(64), /external expected archive digest.*mismatch/u],
] as const) {
const result = spawnSync(process.execPath, [cliPath], {
cwd: root,
encoding: "utf8",
env: { ...baseEnvironment, [name]: value },
});
expect(result.status, name).not.toBe(0);
expect(result.stderr, name).toMatch(diagnostic);
}
} finally {
await rm(root, { recursive: true, force: true });
}
}, PROCESS_HEAVY_TIMEOUT_MS);
});
@@ -0,0 +1,708 @@
import { generateKeyPairSync } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { EventEmitter } from "node:events";
import { describe, expect, it } from "vitest";
import {
evaluatePromotionEvidence,
providerPublicKeyFingerprint,
validateProviderEvidence,
} from "../../scripts/lib/provider-evidence.ts";
import { readProviderTrust } from "../../scripts/lib/provider-trust.ts";
import { superviseProviderEvidence } from "../../scripts/lib/provider-supervisor.ts";
import { runProviderProcess } from "../../scripts/lib/provider-process-runner.ts";
import { runStageVerifiedPromotionCli } from "../../scripts/lib/stage-verified-promotion-cli.ts";
import type { ReleaseCandidateManifest } from "../../scripts/lib/release-candidate.ts";
import {
digest,
providerExpectedContext,
providerUnsigned,
signedProviderV2,
trust,
} from "./security-followup-fixture.ts";
describe("security provider evidence contracts", () => {
it("accepts signed provider v2 evidence only for the exact run, source, archive, and nonce", () => {
const now = Date.parse("2026-08-02T01:00:00.000Z");
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const expected = providerExpectedContext();
const vulnerability = signedProviderV2(
{
source: expected.source,
candidate: expected.candidate,
schemaVersion: 2,
evidenceType: "vulnerability-report",
provider: "fixture-vulnerability",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { ...expected.run, invocationNonce: "1".repeat(64) },
secretScanAttestation: expected.secretScanAttestation,
findings: [],
},
"vulnerability-key",
vulnerabilityKeys.publicKey,
vulnerabilityKeys.privateKey,
);
const provenance = signedProviderV2(
{
source: expected.source,
candidate: expected.candidate,
schemaVersion: 2,
evidenceType: "provenance-attestation",
provider: "fixture-provenance",
signer: "fixture-workload",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { ...expected.run, invocationNonce: "2".repeat(64) },
subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } },
},
"provenance-key",
provenanceKeys.publicKey,
provenanceKeys.privateKey,
);
const result = evaluatePromotionEvidence({
expected: {
...expected,
vulnerabilityInvocationNonce: "1".repeat(64),
provenanceInvocationNonce: "2".repeat(64),
},
localStatus: "PASS",
vulnerabilityReport: vulnerability,
provenanceAttestation: provenance,
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
nowEpochMs: () => now,
});
expect(result).toEqual({
status: "PASS",
vulnerabilityStatus: "PASS",
provenanceAttestationStatus: "PASS",
failures: [],
});
const replayed = evaluatePromotionEvidence({
expected: {
...expected,
run: { id: expected.run.id, attempt: 2 },
vulnerabilityInvocationNonce: "1".repeat(64),
provenanceInvocationNonce: "2".repeat(64),
},
localStatus: "PASS",
vulnerabilityReport: vulnerability,
provenanceAttestation: provenance,
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
nowEpochMs: () => now,
});
expect(replayed.status).toBe("FAIL_UNVERIFIED");
expect(replayed.failures).toEqual(
expect.arrayContaining([
"vulnerability report run identity mismatch",
"provenance attestation run identity mismatch",
]),
);
});
it("rejects a signed vulnerability PASS when the captured SARIF attestation differs", () => {
const keys = generateKeyPairSync("ed25519");
const expected = providerExpectedContext();
const secretScanAttestation = {
status: "PASS" as const,
localEvidenceAssessmentSha256: digest("assessment"),
sourceSetSha256: expected.source.sourceSetSha256,
policySha256: digest("secret policy"),
sarifSha256: digest("real sarif"),
scanInputSha256: digest("scan input"),
};
const report = signedProviderV2(
{
schemaVersion: 2,
evidenceType: "vulnerability-report",
provider: "fixture-vulnerability",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { ...expected.run, invocationNonce: "1".repeat(64) },
source: expected.source,
candidate: expected.candidate,
secretScanAttestation,
findings: [],
},
"vulnerability-key",
keys.publicKey,
keys.privateKey,
);
const validated = validateProviderEvidence({
kind: "vulnerability",
value: report,
expected: {
...expected,
vulnerabilityInvocationNonce: "1".repeat(64),
provenanceInvocationNonce: "2".repeat(64),
secretScanAttestation: {
...secretScanAttestation,
sarifSha256: digest("forged empty sarif"),
},
},
trust: trust("vulnerability-key", keys.publicKey),
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
});
expect(validated.status).toBe("FAIL_UNVERIFIED");
expect(validated.failures).toContain(
"vulnerability report secret scan attestation mismatch",
);
const forged = structuredClone(report);
forged.secretScanAttestation.sarifSha256 = digest("forged empty sarif");
const forgedValidation = validateProviderEvidence({
kind: "vulnerability",
value: forged,
expected: {
...expected,
vulnerabilityInvocationNonce: "1".repeat(64),
provenanceInvocationNonce: "2".repeat(64),
secretScanAttestation: forged.secretScanAttestation,
},
trust: trust("vulnerability-key", keys.publicKey),
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
});
expect(forgedValidation.failures).toContain(
"vulnerability report signature verification failed",
);
const provenanceKeys = generateKeyPairSync("ed25519");
const provenance = signedProviderV2(
{
schemaVersion: 2,
evidenceType: "provenance-attestation",
provider: "fixture-provenance",
signer: "fixture-workload",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { ...expected.run, invocationNonce: "2".repeat(64) },
source: expected.source,
candidate: expected.candidate,
subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } },
},
"provenance-key",
provenanceKeys.publicKey,
provenanceKeys.privateKey,
);
const evaluated = evaluatePromotionEvidence({
expected: {
...expected,
vulnerabilityInvocationNonce: "1".repeat(64),
provenanceInvocationNonce: "2".repeat(64),
secretScanAttestation: {
...secretScanAttestation,
sarifSha256: digest("forged empty sarif"),
},
},
localStatus: "PASS",
vulnerabilityReport: report,
provenanceAttestation: provenance,
vulnerabilityTrust: trust("vulnerability-key", keys.publicKey),
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
});
expect(evaluated.vulnerabilityStatus).toBe("FAIL_UNVERIFIED");
expect(evaluated.failures).toContain(
"vulnerability report secret scan attestation mismatch",
);
});
it.each(["vulnerability", "provenance"] as const)(
"rejects correctly re-signed %s v2 context/time/replay drift",
(kind) => {
const now = Date.parse("2026-08-02T01:00:00.000Z");
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const expected = providerExpectedContext();
const baseVulnerability = providerUnsigned("vulnerability", expected);
const baseProvenance = providerUnsigned("provenance", expected);
const validVulnerability = signedProviderV2(
baseVulnerability,
"vulnerability-key",
vulnerabilityKeys.publicKey,
vulnerabilityKeys.privateKey,
);
const validProvenance = signedProviderV2(
baseProvenance,
"provenance-key",
provenanceKeys.publicKey,
provenanceKeys.privateKey,
);
const rawCases: Array<readonly [
string,
(value: Record<string, any>) => Record<string, any>,
RegExp,
]> = [
["schema v1", (value) => ({ ...value, schemaVersion: 1 }), /missing or invalid/u],
[
"evidence type",
(value) => ({
...value,
evidenceType:
kind === "vulnerability"
? "provenance-attestation"
: "vulnerability-report",
}),
/missing or invalid/u,
],
...(["archiveSha256", "bundleSha256", "distSha256", "lockfileSha256"] as const).map(
(field) => [
`candidate ${field}`,
(value: Record<string, any>) => ({
...value,
candidate: { ...value.candidate, [field]: "f".repeat(64) },
...(kind === "provenance" && field === "distSha256"
? {
subject: {
name: "dist",
digest: { sha256: "f".repeat(64) },
},
}
: {}),
}),
/candidate identity|subject dist/u,
] as const,
),
[
"different archive with same dist and lockfile",
(value) => ({
...value,
candidate: { ...value.candidate, archiveSha256: "e".repeat(64) },
}),
/candidate identity/u,
],
[
"source revision",
(value) => ({ ...value, source: { ...value.source, revision: "c".repeat(40) } }),
/source identity/u,
],
[
"source set",
(value) => ({ ...value, source: { ...value.source, sourceSetSha256: "c".repeat(64) } }),
/source identity/u,
],
[
"run id",
(value) => ({ ...value, run: { ...value.run, id: "other-run" } }),
/run identity/u,
],
[
"run attempt replay",
(value) => ({ ...value, run: { ...value.run, attempt: 2 } }),
/run identity/u,
],
[
"different nonce",
(value) => ({ ...value, run: { ...value.run, invocationNonce: "3".repeat(64) } }),
/invocation nonce/u,
],
[
"missing nonce",
(value) => {
const run = { ...value.run };
delete run.invocationNonce;
return { ...value, run };
},
/missing or invalid/u,
],
[
"uppercase nonce",
(value) => ({ ...value, run: { ...value.run, invocationNonce: "A".repeat(64) } }),
/missing or invalid/u,
],
[
"short nonce",
(value) => ({ ...value, run: { ...value.run, invocationNonce: "1".repeat(62) } }),
/missing or invalid/u,
],
[
"issued future boundary",
(value) => ({ ...value, issuedAt: "2026-08-02T01:05:00.001Z" }),
/future skew/u,
],
[
"expiry equality",
(value) => ({ ...value, expiresAt: "2026-08-02T01:00:00.000Z" }),
/expired/u,
],
[
"expiry past",
(value) => ({ ...value, expiresAt: "2026-08-02T00:59:59.999Z" }),
/expired/u,
],
[
"zero lifetime",
(value) => ({
...value,
issuedAt: "2026-08-02T01:01:00.000Z",
expiresAt: "2026-08-02T01:01:00.000Z",
}),
/not positive/u,
],
[
"negative lifetime",
(value) => ({
...value,
issuedAt: "2026-08-02T01:02:00.000Z",
expiresAt: "2026-08-02T01:01:59.999Z",
}),
/not positive/u,
],
[
"lifetime above two hours",
(value) => ({
...value,
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T03:00:00.001Z",
}),
/exceeds two hours/u,
],
[
"wrong fingerprint",
(value) => ({
...value,
signature: {
...value.signature,
publicKeyFingerprint: `sha256:${"d".repeat(64)}`,
},
}),
/trust identity/u,
],
];
const cases = rawCases.map(([name, mutate, failure]) => ({
name,
mutate,
failure,
}));
for (const testCase of cases) {
const base = kind === "vulnerability" ? baseVulnerability : baseProvenance;
const mutated = testCase.mutate(structuredClone(base));
const resigned = signedProviderV2(
mutated,
kind === "vulnerability" ? "vulnerability-key" : "provenance-key",
kind === "vulnerability" ? vulnerabilityKeys.publicKey : provenanceKeys.publicKey,
kind === "vulnerability" ? vulnerabilityKeys.privateKey : provenanceKeys.privateKey,
"signature" in mutated && mutated.signature?.publicKeyFingerprint
? mutated.signature.publicKeyFingerprint
: undefined,
);
const result = evaluatePromotionEvidence({
expected: {
...expected,
vulnerabilityInvocationNonce: "1".repeat(64),
provenanceInvocationNonce: "2".repeat(64),
},
localStatus: "PASS",
vulnerabilityReport:
kind === "vulnerability" ? resigned : validVulnerability,
provenanceAttestation:
kind === "provenance" ? resigned : validProvenance,
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
nowEpochMs: () => now,
});
expect(result.status, testCase.name).toBe("FAIL_UNVERIFIED");
expect(result.failures.join("\n"), testCase.name).toMatch(testCase.failure);
}
},
);
it("canonicalizes provider fingerprints from DER SPKI across PEM wrapping and rejects Ed448", async () => {
const root = await mkdtemp(path.join(tmpdir(), "provider-fingerprint-"));
try {
const ed25519 = generateKeyPairSync("ed25519").publicKey;
const pem = ed25519.export({ type: "spki", format: "pem" }).toString();
const body = pem.replace(/-----[^-]+-----|\s/gu, "");
const wrapped = (width: number) =>
`-----BEGIN PUBLIC KEY-----\n${body.match(new RegExp(`.{1,${width}}`, "gu"))!.join("\n")}\n-----END PUBLIC KEY-----\n`;
await writeFile(path.join(root, "a.pem"), wrapped(64));
await writeFile(path.join(root, "b.pem"), wrapped(32));
const first = await readProviderTrust(root, "a.pem", "fixture-key");
const second = await readProviderTrust(root, "b.pem", "fixture-key");
expect(first?.publicKeyFingerprint).toBe(providerPublicKeyFingerprint(ed25519));
expect(second?.publicKeyFingerprint).toBe(first?.publicKeyFingerprint);
const ed448 = generateKeyPairSync("ed448").publicKey;
await writeFile(root + "/ed448.pem", ed448.export({ type: "spki", format: "pem" }));
await expect(readProviderTrust(root, "ed448.pem", "fixture-key")).resolves.toBeNull();
expect(() => providerPublicKeyFingerprint(ed448)).toThrow(/must be Ed25519/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("captures the downloaded archive pathname exactly once in the provider supervisor", async () => {
const keys = generateKeyPairSync("ed25519");
const expected = providerExpectedContext();
let captureCount = 0;
let receivedEnvironment: Readonly<Record<string, string>> | undefined;
const manifest: ReleaseCandidateManifest = {
schemaVersion: 1,
distSha256: expected.candidate.distSha256,
lockfileSha256: expected.candidate.lockfileSha256,
bundleSha256: expected.candidate.bundleSha256,
files: [{ path: "pnpm-lock.yaml", bytes: 1, sha256: expected.candidate.lockfileSha256 }],
};
const result = await superviseProviderEvidence(
{
kind: "vulnerability",
archivePath: "/downloads/candidate.tar.gz",
expectedArchiveSha256: expected.candidate.archiveSha256,
expectedRun: {
id: expected.run.id,
attempt: expected.run.attempt,
sourceRevision: expected.source.revision,
},
trust: trust("vulnerability-key", keys.publicKey),
executeProvider: async ({ environment }) => {
receivedEnvironment = environment;
},
captureReport: async () => Buffer.from("{}\n"),
},
{
captureArchive: async (input) => {
captureCount += 1;
expect(input).toEqual({
archivePath: "/downloads/candidate.tar.gz",
expectedSha256: expected.candidate.archiveSha256,
});
return {
bytes: Buffer.from("captured archive"),
archiveSha256: expected.candidate.archiveSha256,
};
},
withVerifiedCandidate: (async (input: any) =>
input.verify({ extractionRoot: "/captured/extraction", manifest })) as any,
verifyLocalEvidence: async () => ({
status: "PASS",
identity: {
sourceRevision: expected.source.revision,
sourceSetSha256: expected.source.sourceSetSha256,
assessmentSha256: digest("assessment"),
secretScan: {
policySha256: digest("provider secret policy"),
sarifSha256: digest("provider secret sarif"),
scanInputSha256: digest("provider secret input"),
},
},
failures: [],
}),
validateUpload: (async (input: any) => {
expect("archivePath" in input).toBe(false);
return { sealed: true };
}) as any,
randomBytes: () => Buffer.alloc(32, 0x11),
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
},
);
expect(captureCount).toBe(1);
expect(receivedEnvironment).toEqual(
expect.objectContaining({
PROVIDER_EVIDENCE_SCHEMA_VERSION: "2",
PROVIDER_INVOCATION_NONCE: "11".repeat(32),
PROVIDER_ISSUED_AT: "2026-08-02T01:00:00.000Z",
PROVIDER_EXPIRES_AT: "2026-08-02T02:00:00.000Z",
CI_RUN_ID: expected.run.id,
CI_RUN_ATTEMPT: "1",
SOURCE_REVISION: expected.source.revision,
CANDIDATE_ARCHIVE_SHA256: expected.candidate.archiveSha256,
}),
);
expect(result.evidence).toEqual({ sealed: true });
});
it("samples provider freshness after report capture instead of reusing issuance time", async () => {
const keys = generateKeyPairSync("ed25519");
const expected = providerExpectedContext();
const manifest: ReleaseCandidateManifest = {
schemaVersion: 1,
distSha256: expected.candidate.distSha256,
lockfileSha256: expected.candidate.lockfileSha256,
bundleSha256: expected.candidate.bundleSha256,
files: [{ path: "pnpm-lock.yaml", bytes: 1, sha256: expected.candidate.lockfileSha256 }],
};
const issuedSample = Date.parse("2026-08-02T01:00:00.000Z");
const validationSample = Date.parse("2026-08-02T02:00:00.001Z");
const samples = [issuedSample, validationSample];
let issuedAt = "";
await expect(
superviseProviderEvidence(
{
kind: "vulnerability",
archivePath: "/downloads/candidate.tar.gz",
expectedArchiveSha256: expected.candidate.archiveSha256,
expectedRun: {
id: expected.run.id,
attempt: expected.run.attempt,
sourceRevision: expected.source.revision,
},
trust: trust("vulnerability-key", keys.publicKey),
executeProvider: async ({ environment }) => {
issuedAt = environment.PROVIDER_ISSUED_AT!;
},
captureReport: async () => Buffer.from("{}\n"),
},
{
captureArchive: async () => ({
bytes: Buffer.from("captured archive"),
archiveSha256: expected.candidate.archiveSha256,
}),
withVerifiedCandidate: (async (input: any) =>
input.verify({ extractionRoot: "/captured/extraction", manifest })) as any,
verifyLocalEvidence: async () => ({
status: "PASS",
identity: {
sourceRevision: expected.source.revision,
sourceSetSha256: expected.source.sourceSetSha256,
assessmentSha256: digest("assessment"),
secretScan: {
policySha256: digest("provider secret policy"),
sarifSha256: digest("provider secret sarif"),
scanInputSha256: digest("provider secret input"),
},
},
failures: [],
}),
validateUpload: (async (input: any) => {
expect(input.nowEpochMs()).toBe(validationSample);
throw new Error("provider report expired during execution");
}) as any,
randomBytes: () => Buffer.alloc(32, 0x33),
nowEpochMs: () => samples.shift()!,
},
),
).rejects.toThrow(/expired during execution/u);
expect(issuedAt).toBe("2026-08-02T01:00:00.000Z");
});
it("kills a timed-out provider but settles only after the child closes", async () => {
const child = new EventEmitter() as EventEmitter & {
kill(signal: NodeJS.Signals): boolean;
};
let killedWith: NodeJS.Signals | undefined;
child.kill = (signal) => {
killedWith = signal;
return true;
};
let fireTimeout: (() => void) | undefined;
let settled = false;
const running = runProviderProcess(
{ executable: "/usr/bin/bwrap", arguments: [], environment: {}, timeoutMs: 1 },
{
spawnChild: () => child as any,
setTimer: (callback) => {
fireTimeout = callback;
return 1 as any;
},
clearTimer: () => undefined,
},
).finally(() => {
settled = true;
});
fireTimeout?.();
await Promise.resolve();
expect(killedWith).toBe("SIGKILL");
expect(settled).toBe(false);
child.emit("close", null, "SIGKILL");
await expect(running).rejects.toThrow(/timed out/u);
expect(settled).toBe(true);
});
it("captures process-group kill errors, attempts child fallback, and settles after close", async () => {
const child = new EventEmitter() as EventEmitter & {
pid: number;
kill(signal: NodeJS.Signals): boolean;
};
child.pid = 12_346;
let fallbackSignal: NodeJS.Signals | undefined;
child.kill = (signal) => {
fallbackSignal = signal;
return true;
};
let fireTimeout: (() => void) | undefined;
const running = runProviderProcess(
{ executable: "/usr/bin/bwrap", arguments: [], environment: {}, timeoutMs: 1 },
{
spawnChild: () => child as any,
setTimer: (callback) => {
fireTimeout = callback;
return 1 as any;
},
clearTimer: () => undefined,
killProcessGroup: () => {
throw Object.assign(new Error("group kill denied"), { code: "EPERM" });
},
},
);
expect(() => fireTimeout?.()).not.toThrow();
expect(fallbackSignal).toBe("SIGKILL");
child.emit("close", null, "SIGKILL");
await expect(running).rejects.toThrow(/timed out.*kill failed.*close/u);
});
it.each(["open failure", "partial write failure"])(
"cleans finalized staging from memory when GITHUB_OUTPUT has a %s",
async (failureKind) => {
const finalized = {
stagingRoot: "/runner/promotion-run-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
cleanupToken: "promotion-run-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
runnerTempIdentity: { dev: 10, ino: 20 },
stagingIdentity: { dev: 30, ino: 40 },
files: [],
} as const;
let cleanupInput: unknown;
let appendCalls = 0;
const environment = {
CANDIDATE_ARCHIVE_PATH: "candidate.tar.gz",
CANDIDATE_ARCHIVE_SHA256: "a".repeat(64),
VULNERABILITY_REPORT_PATH: "vulnerability.json",
PROVENANCE_ATTESTATION_PATH: "provenance.json",
VULNERABILITY_PUBLIC_KEY_PATH: "vulnerability.pem",
VULNERABILITY_KEY_ID: "vulnerability-key",
PROVENANCE_PUBLIC_KEY_PATH: "provenance.pem",
PROVENANCE_KEY_ID: "provenance-key",
CI_RUN_ID: "run",
CI_RUN_ATTEMPT: "1",
VITE_COMMIT_SHA: "b".repeat(40),
VULNERABILITY_INVOCATION_NONCE: "c".repeat(64),
PROVENANCE_INVOCATION_NONCE: "d".repeat(64),
RUNNER_TEMP: "/runner",
GITHUB_OUTPUT: "/runner/github-output",
};
await expect(
runStageVerifiedPromotionCli(environment, {
cwd: () => "/workspace",
finalize: async () => finalized as any,
appendOutput: async () => {
appendCalls += 1;
if (failureKind === "partial write failure") {
// The output sink accepted an unspecified prefix before rejecting.
}
throw new Error(failureKind);
},
cleanup: async (input) => {
cleanupInput = input;
},
writeStdout: () => undefined,
}),
).rejects.toThrow(new RegExp(failureKind, "u"));
expect(appendCalls).toBe(1);
expect(cleanupInput).toEqual({
runnerTempRoot: "/runner",
stagingRoot: finalized.stagingRoot,
cleanupToken: finalized.cleanupToken,
runnerTempIdentity: finalized.runnerTempIdentity,
stagingIdentity: finalized.stagingIdentity,
});
},
);
});
@@ -182,8 +182,8 @@ describe("selective Task 3 contract closure", () => {
it.skipIf(isReducedCiContractRun())("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
const canonical = await loadCiGateContract(process.cwd());
expect(canonical.gates).toHaveLength(27);
expect(canonical.commands).toHaveLength(84);
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(96);
expect(canonical.commands).toHaveLength(91);
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(103);
expect(canonical.artifacts).toHaveLength(109);
expect(canonical.stages).toHaveLength(5);
expect(canonical.retention.classes).toHaveLength(5);