From d2f797b996bed2b1a28063faa1f1c35f6331162b Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Sat, 30 May 2026 11:45:40 +0700 Subject: [PATCH 01/15] feat(constructive-blocks): add copy-in UI blocks skill Add the constructive-blocks skill so agents can discover the copy-in block workflow: SKILL.md, binding-doctrine and manifest-and-checks references, and the zero-dependency check-sdk.mjs preflight gate that proves the host's generated GraphQL SDK exports every operation a data block requires. List the skill in the CLAUDE.md and README.md catalogs. Co-Authored-By: Claude Opus 4.8 --- .agents/skills/constructive-blocks.zip | Bin 0 -> 19062 bytes .agents/skills/constructive-blocks/SKILL.md | 214 ++++++++++ .../references/binding-doctrine.md | 114 ++++++ .../references/manifest-and-checks.md | 111 ++++++ .../constructive-blocks/scripts/check-sdk.mjs | 370 ++++++++++++++++++ CLAUDE.md | 1 + README.md | 1 + 7 files changed, 811 insertions(+) create mode 100644 .agents/skills/constructive-blocks.zip create mode 100644 .agents/skills/constructive-blocks/SKILL.md create mode 100644 .agents/skills/constructive-blocks/references/binding-doctrine.md create mode 100644 .agents/skills/constructive-blocks/references/manifest-and-checks.md create mode 100755 .agents/skills/constructive-blocks/scripts/check-sdk.mjs diff --git a/.agents/skills/constructive-blocks.zip b/.agents/skills/constructive-blocks.zip new file mode 100644 index 0000000000000000000000000000000000000000..3949e27d85d94e4f285005ea51cefb867e6c56e4 GIT binary patch literal 19062 zcmaf)W3VVqm!*$w+x9)SZQHhO+qP}n<~_D;+w*>nnV9LG=!(dSsvq_2%82}tYp<2^ zQotZk0ROr$lDoA3?cqOeC;&JBMs~JN&WOZ;&UgN;Hm*OgWyWn$NxgJF(!^uivf5KcQboM$|xW z-0Y`=DVpds78T8AcOWXKUv6uqvTjjbrL*gIk-#)mb+g}hm&l=ZWuZA37aqYsP63H`NWd}#%>RJ;EpP)>fc zl2Js^OwXf-<=pt;IPF4D@!4M_a>p&rQM@JCPe1sO=fo*J<*bu#U*P3pLwWRa6a@-; z(5>(5yYGDhiaJd5wEe!mZpP=vW?PtTToD@`E2QM)D5-D;7PoKR`=r{c?s7)XB=L}7 zjXr4Gw(6u6a`L*eqjN}dcb=)0IY??f`Mui_MRAQU8&@HKDKV!_#)t||_*zD>ZE7Jj z21jJzg}jFq2ezvwXDo8a;PkN#OkaVob@Y~W90aFPihAug+9=V>G6q?u$xik|P42JP zsvgOBZf|XSIq-mi7R%NopMiYTX0ir zF*koDN-5p;j(m(KwrFm5`{c=6Xv%BQZ0*!_%04CYVRiXO&`I0yI3ASY47` z>rv?>H}UWEYzuVgD%@SEBMS!Hd2iM75GFoWv+NRFF6tnXC@!=8S$ni6Bh3tC!Q4J|Z|SBcH~ zAZc=FndNpSKcQ<&@i-A(@i$gpJ+xAgcGYvQGnSVvb1btIB7-bI$tsrc{L|h5-6N7f zcHEpDm0<83IKHucqNkuNJ-&4#rBhT#BaQZ2*`8z* z?T$5S1p!UX^k56nWl0lZ`{oxxowvHhv)$ByN-~dVHx-!~9}MsDBoe=Ty0gxk){Llp z#a|ZEF>%fucw9<-3Yd~9@MGwB3ZVTW5XbvH3pK0b%|D4(>ee=-S_|asPFAf_-O|BK zx&Q|28weEVa4Rh+-S?fD7!-` zAN0`xy2Xqe#1<@2o=bq`EBZZKHVS+GmGDWB?6}-gngpqCR9HgNa;J|IBnoNvoZ}PM zftg4P$XW_7!2oEYiiGv8+#cVjQpWPRU9Fb!<~RYSd`f*^hk1XhDN{YSxm0r~?vZ%i z)fV`IyzWDCBK=0}957>5DofcOPH;Xmu;ix)fHzwad4uaVNp*Xxtvq5KX@(TX4)CHE}_0{z!(IRe2TrpK`jDG2xug=8qvmN}r0bwnEe4b`>2=VyNoEUHwgQiUJ^J z?L1h57fNN;7ZZZq)?&)zit}7THTA`Hu}==D=zKHkHc0LyjRGPLA%Unx!{Ol1Md?dGmMx%D^32ifW2*7w93c@q`!O zQLZz3TNZPeiE02K{KD-4MLC=NiP?=PM=^O^f2Dj<2WW+1L==p=*mIC>9uxt(FQ`0F zr!oAq>(5!DYB4WW;uDw+%X^!QpOqbaa1QdGEn$=BR)M6D6Y-SYa5sva6MH^52f|3;-kUZ6wr~=R)Ge}BTmxcIOY~mw>cc_i_QZ+wyoD`G zprBrJYg5J}t|_^v5!~d7CE2FVljakvY{gGK0E9~58#vo{ww%`e3~Hh&1k*rJPVT8Q zfS2V|?WHBq-eQtMu_569`{~XYjPebW%Hmm7YFw(jky!4;xc@R-&D192&KlC9<)5{?k9&u&{WXuw2EFpgBz(aJ(7#&#rtKBmg*4vR0{w`#t6j-J0|g z>TSRtEzXn;shNLKW3E^(cojh49nE7rY>rpDuaI9P|~Y4?ge>a#9Z)PV!)7Q ze2lYoYq9_|pSLM_r2#ySKDS2nOvvBWV^AjYH1$q*__*ehm+j+yfLlSarg`^Hf?cyA zyoCKunc8|YqrVcGvE4*oJylV9J#IGq{9~_*Xgos1Swzl>yf_+zZSMJghrzugNf6Lk zf$m&~R%!{|MC0Na5@JUMK{Pm6npn6D2E=4_;udVuWSPO<7LI>1K4Xhp#Zj71NQqXY zt{gV6Ek~y;^w2bLoqipZD(ZM?!xmqWOFn#L6+Yd|`19nq256on@Yqu(&+H8PHqt{S7G7ck8*3*A z=R;Yp#ZF=z|CyL_?uOA_Yfav@5vi<>e5hoLTJxW5o6ZyGwC{llrP5|<&F%53tWGoh(isohARLoK z{3W6MKD%;dzPB6?abc4}MV^?68DTg=-H$b8M9>T@Uf7%61a5a3T!v+N4!9~qBZ1w9 ztqj4Gl`7~>E{bafVLo6P9R9)E(#Wi>;XgY;&^eG_Fz8K8b@pmV$)R(E7GLm*mq`&XB0v7R)n$&cXYL-0E7h>t|yr)CX|X&$Qu-xzx}Ew%=WXmva&_ter`|U_PJ&tlSNvHEY0%IR9SW zA7gr{l&VJ35mkgG?!kW;T0nelcBm&`#_7rK4Hygb=9;XjKi_It1S6@xWdZj$cunX$ z$|oWi-VuXB(A!Tc6;Cw?I5L*c^3L392@Ke23mXw+VtYMH?f3r9{q<%O-?kQSc6UpU z+xPY5;f9{?=kD!d=6U;iCJVgcL(uOD44Eh)KeZipi)^mMBI2>3iCbm&AU%A8gNf!D zS2msOS@D*|yxeAr zy1rS_QlWrU($_FlSv@ls?Q5ci-$e^shc3js7lNnmaZcl3W6}C0113xzRf&ZZ(H4tL z&g@)!xtv_3GSP&tGfx|C&Q=16azcsh1J^J8Q2S;;O{W0-Aj1Ix&GCLy#4*0#vX;2i z6rco#-(qqHed^KQU)mD{Gw@k62o?qzj=y%MG;*nM?Xg1m&6e#dZ#)SaWI7-!)P@Eh zBXR5zK#Vp)<7Bqc*gA%{2q-jv^YU$ObS@v?9w9DNsw$e8`A*~85CF@X#n6fIbntM# z@1XP%>7Qi7&dzMZ+~xWqwRarbMvz0DIN~z2hM^67Gvzx2jqVE zrSN4`4-fK`Ezb?jCDZo0rYoII)bzKSY}I)2v=IshoSvbcIdZ-dTrVso=c58=_aaJ8 z8eAig_lRWzSXsFaVTq36QHgTCB3!#&IV4br#9+kR=5G&sfF=R*;$8YwS~qJ%JA(W8 zugTTv`PBVSg$l#+dw)_23as9cr5Bw~Bg6{G^QbI8R^-+CBVGk%_Ns?#_|eX+2yPF! zsS1qloMyj)Af6)J>{{LO1|4aRwi1BeM zKm!11VgUfK{LdoY#=zFX)Wpe|*1*=7*2vuCpH{hyp01iJpKPWRE+j@@cS?aeDA z80BXwie+w5$`7%jxvs9khj!>z80pB9*0oEH*Xe4IZEIXcmpp=3 zOQHQ6$5_^T*{sCtbz3tLLeE;6DwwI*%$|%+Lil{_q1yM_VV&Tv3Q^&UMa;(Zjo73ymzLF(uFk{Ml^5dI-MYamJ}r%Ah29Qjc9UG<0SWpJZqP8MI#43_84c_&iBb&iwd6#t zdE?tto1Z?iAnYb0;G0BU+oQDn0<7ny>T;e(0x@R1s8vm+;`oK4Z4X7UlV(E7L}}0v zcpAx|n+vVOMlFXW-hL^HF*3LC2YB~S*dqGu7|H@~sj@wT8G~1Q&k|T2!p=~0Dg6h+ z&!Rj)asVFM{#v?mli(nBXZ*4^qX!D`EKm@2w>p=W(@RX)$DWu)jm?6jPPydTvX{zm zyaP>d9>gD9IN94M%9JT*fhiaCz{7?w(~IW}jz5bb>7(Ykt(EVs8NJ6nyDpJ9lUz}M zIY3^t>a@MJHWgLA9?3GTxfbU!$&_@mrZp9 zL^xTeg4zm*8?}$hy!trn1_zto-nmVA&C)3lBk&sAO~oV1?O_&cJDY>8chx))AMjXw z0>lkF*wTR~5ci*Ck#rFoDUx4Tt~V`{se)=^Y&s*2z;Dwnqip&$o0`x?6-E;s;Pq5< zl9uP$iZ$q{l$o#-BNk(e#7X7I0l&#Sr(NbuTNP7!vXg3)#P};(u5s=#p6PJW|Lo5I z&trNQ7bil}D{nVzadQw1wFGj5d~1N%*&o4AkB$3<#GQwMuOa#J* z6r+aExc}6NxcR6pr0+Xjhdb<;bN(>ML`&R+hrhz0}(B(|eJwW&Sbo^$86 zfT3=PAg<;-*y31(R+NMC9|R)sQcF1PhXj z%ZZ#yxb~Lo^Bb#;FW<*v#k9#5s_*=SO&TmyWh3`|qtu3hal+XeGZyN}{bK^i&R@)sy<|ZR?jSV@3Awg5QTVI@}5m=m$XYAQCkz>NmL5{-J()C`t zW}F-vR)`l%A%=oW#2+0dpzQgSSrpcz;n!ivQ&E%>e9nH8j;DmA(gT+841)<_VYIck zA5T+f2c)NJV()LA33X!>ymgDH9oPBXX2|nYFh(+e>2gI`rpJqlZCdKV=*xX8(74S? z<>*D=AazX%tuxu=0QjH)fG}^*S;ee65TcrvyM7X@Kw<}XC;@3yFqHe_V6&7~A3h?a za(x!HhcSLjc)Qn;#;kyla<~uhTnTf>jbkKH^lG?n=m$Pn4{MDWX(+v66+Rap+$5XN zP_Q|}9<=lR#)v)@uXJl%v^x*S09nY=mHdrc@YZ#PL9z>G6xV)(hyZ-kqji&g6q5RE za3k&D*vhpDk*VLP<=1top!-*0vxju03L@Q@}r+lqTig;g^U z9^$H<4=}f`rK7-;KrJ};O{kRa5u)ws`0!+*nr8Shye2h-l{+yicS<+_f9J>e-sH$i zs}f{KXs&gS;~BYL*7y5*V|NPOa3QTRAYb_2Yu{<{?WiY6rt3m>7uY{l?!z2oztW27 z_LTCJ6+zg*CW*lI>qZV#bD;1i)@;rggUBvH98nGuQh-NETG%96e44lzpCLVdZ*lB* zn~YqGwQ-w9tBdcnfd)t9iNGwJ`?^O)Gn4hNqX>AzQOUe4r6fTh(HZJD@5BBzQV}P3 zZWk*Y7b?RX#BS<%{bq=*@-oP%V;TOw!Yy|s&>LA&7h%L}C^!MHdGHh9PPkw6d3>9= zVbhA414Lep4JhX&2VTbqzelX5sbvtJ746|7OpaF;>7rY6a|l?KcbEr3r4a2f z$AVUPOhDVm`5a8G5}o%NNqz5colJnN{&{{_n1N)@#<6yR= za@i<8`%9$Ext*`Mz^(jA;Eh(teT=zp+K;MA-8#Sli^Uv$^~a@Au2zeMLQKr_I$(+u z#<;+!H*hg%#!6rAbP!@61)?T4X(;85s<#uXTaTJ*j2Z`@b!T{o@B35F5T2bVTG3$G zJAdn)2UT1c*|TmkXaYEY$2l4JKmoFlXFF-a$g=gRc1>%9ds%miOOyit&&&vRU-~F< zB+?V)f{&;MW&#SWgKTm%Li=nSH9{o6Aeag`6<7F_q04Y}IAwiS;hCh6`rw$-g{??d zs8%j>3H9YtBoDTLY^;#KU3hK#(zU7CU_FH)mDn~N+%=hympdA~jngnCKgq9%6SLuI zwXv`CHz*UFR*erIQ;_8RGUGfi>kg~-8+A3p4Dc^op29av%J%44AQ(!eu;w42CqKM= zN=WVSoEORGR$c^BRC{@@^wv|pVbN&NoQNbBW*%wDQ)#p!M#=;MD7^4P!&kBeCes>D z9Z*d~b&ER7_Uvn&mwUd0eE2&9TRNEf1_uxr2#mE+)pa$jjJIgdQCOZ4!$AVg$z}Za z6F^Wn9g!9t;J6A=Z#?=r1l>XMc-x|n&vccqqNx&>Q|)WK1_G#x$xkl2yBw{@@5yjE zpVBz=mE9f?f8jlr|4WG$ ztxd;GanIgWrDiu}DSje>sjOmG$|=p5tL+@zJk>-iGN}bJab}Tdet*%R#1@pQ6ROn; z>q^I@Ds7V61hEYAiPzt4SZ4moL**E47Gk8cpV?De-{2~`Ef(MD`w(GECmnDG5Y9DZ~&F zictyH4n@Fx#Qrt|-EeEF$PARCh^F?U)fi!S%0#RuiM|RTJljOt`Z2`OyOz}1q3n1G{gZW@8Cn> zRuP!&glrN*U8L2Hul8saZE@0W1Z*yUL^O9c&&j=9&~BnMsy6|X^YUx{;YuRpqJES1 zD$JW$@B54GcTfrxDjGwUmocUA57Nqn2qqT*%oW=3GgYEofS8w5Hfd`bF7nS26>*PLs6^#T#H=nPZakn)K2K@)5O0 z(F)mzT+_0?L>n;4> zp%|8+5Zna6N`n!m9gJuO;Bog!)i(Ne!Lig9h3SKg#ygn;8k}9Y86)j=J)Kh#3|9d!Sv0*gp?`1X&v>O;|JgSTs)v_APR$<0tw>d6uMpslQ6}dLyH9Sr7CKJS;%4Cb?m(Vk~6*1+n-=sY-6t ztuwf7X;0A#umE?NsqX%{|ITK1dLUl>Aeucjc=^5N`_4jU-=-lZcye@&$L zZTJg;)A2(O>)I*ZB169i@Bo6ro!{{?VkrhOW7F!7iz~oH#`$_S6pXZWME3B-O2g1` z9pRioVk1zy9wb0E-gGI{pC7(QibM#>fQNrDt7Le@DQ@hkF@2}+r)SjkCP-6^`t9yM z9(PEYf+!{?R;Xf{nA+Zj{IXwo%#;!^gDlwMsxbuNEW~ti^z% z7YlL_(rx!HO%{a(%hZ*zW4-%D0ka3-QP3-7v){r=yK7Adh_o06w6UDAOs0yx%`pVD zh4p*fsD46e$Frru7hJL~zH6k$N*X)8OEa!aU^*8l-aE_tPA8`iyhtO_h#l#pj2peD z9Byrig3!#D*XQB0LCMGufq2dhjXRI;g)&A|tMy9QBEPDLlb4Loo!*0N*sT44m{hozHPH7+vxWuAOIj^Tv8S<1^o7}uHwlSSk*P`lCB>Q2AmIB z5y&EforzR&p}&wnxHxZ?3S}h;bP;qB(`@@1CKWTAc)Wx^4AjmI5<%MrTyt2$+ZtwN z_nyP^nU*F+JPwwPbZHumoiZqyurz&)XY+$QHLV78GX@1Ww?z@8iO8*lRpWsJ3tvAd&C4Rk&RuCYvzKpc!;j;5<^yC<++zyDCb` zS9LGIWsRyvii{3PFFP9DUtLTEs|^=7oE3y*lbr-5KzA@&fi{$we(v!n3WwK zC?b_ipO^2VOdwp*5F^`CijC2dat$G1&lc;R!=IMFMhz~nb4FE##AQlUlkykqEtj}z z@mOI&ckRr`Le`remr=Tl3ei(_t&q+wwrS!ktn)b*cUN>88v-q^BQfBT7Tayw1|_cL~gP^(2)Qd1QHd;>ZlG4N0vU#VOK}Rz@c7(4BR~fI&vZI6C$Y3@t8=g4n0E? zU|uNh+uyxeRIXi|9Lh-cBVZ&pz#DDezl5cRK309GoHA(7$}zsRI(~P|DzlnJt6ZHo?;)ZDOe z8(O}o_gofEG^F%w;QHynu#t= zUo^@kXe^wmk!Ts~&!*CUu>taZu;uHnJk)yGJ;`bVFy>F91rgT!W|rs;dm&I}F`E%f;Xc#k1wz>%75uO#KXqs{TO?D_+@1eoaa0L zV!8Udb~tszcMbbI6TCV*I%YdX*J41z5oK5R_~7o%Z;ohaAzws zm!HgdV)*?a{rnu|5*xZYk!PeH187rZrQP2b#rziWIWRR*k;Az}8q?-$&u|#OKk>Io z4*JlPZKnoS2%QHQy_xw07xHt;O0c)lviNh%P#W)Kn4u41nJ^r(YT0HP!a~n+zy}!Z zcf)}??NwA)8SNmQ=gQ1voU$Tl5@6Y^;}3~CutBlx0p=*&0I#elf}wcv2+QJ9z5VKv z^p6Qx_J{6pG6sKr+e!=`!kUvU7#Yh>K-C!tiHs*%-I`d!TPB4#VQBgUKk z&Z+B1FiI4jF{UMn!z9#LOOEFCf;`7NwfOQm9L&VC*F{n0U0ztcn3k2EQ^J3~rt+az zM-tkS^d}*a>budlgWpQ2pqFY^%CnGOmGAp)_pI#B?@w_B2QE{-sbWs0TYcFqe9A6u z(h`xibVBsHZ3h1G(PWZGY11->Vu4<)u13m|z?{2^5#d;MZ^PKol0j-iu71Owpc{x* z?4RaEI~B(s^VLT)v&uq5JSNQ783lKlWiNIic-Z+ei2xp+hFN)k`J4O7c@4cj%ub(R zR#v9WE;;bbK>ri=PWQS^o2df7=B`4y%Tdf9*9*)JA$&i!qm;*vRgGf+ZNVmwpUJ&< zR7SLJ?TfNAFv#Hz_{xBsP5SMzz19Y!VUL|e)@ql-7Rv=QPvBvF6wX|>V|AwQ=;rBA zkM{biDj*xnU7`Vd+5=68$OZhE&E2C6u<=J-dU(4nOL9PP{b!QdA+0rl-x8KFQp{pH zZi+r38y{~s4HAOPer<5uYeW-v{{k?Ty?r;6y8^dkbjj)O0$l?`q#ic>?KV#NdsE!T zH(bj5fleVWkgcNek0+~_RVJS+sfIfZk0b2THtz#1uDcbRz5AgY0dq-R3#=RLnkw%` zpT<~p+OoeTQpnF1)M)Xf2>vk>n?BF<0_&V?CxxYSgFpN(d9X4Dj~R;X4~-Yf)>Gq% zBWOC@meSZZmx5xE@W!Mcr4S3(^srh}ugdrccL^1}kwA4l?QdobZqpW=7wZoe%x}&N zH@_$PybY+w@AA2KT}j4` zMtJ*Wo zSFW~ZHr)r#qK5w>-?J^z+y3o@cB&efD7$-BY7b>jDpD&?d7|XB%>B#6WjaQ6)X4SS zB4*@LM#iFs5GU;-k%YcLzIsSjU?VqD!o9_TLe)OQfr1B=?&SO1!L2RVv#juL1~)b3 z-`qu{uO&Bg4KRWO(i^QxgiG^`=`)g2GvSTz6d= zU-IHMP8soDZ4667VmWx+^~iCN@g)9kqyqR)uS)GLJ2GR#(a8AP(iw=jwXLX8BnRt| ze`Ud4HM^4bbaO9)4gwRHPELa@cDow$k^FRRd8eX09!dBqS+*sdJisw&ti z4oW{we=Q{UuueD$0Yh2@v7U2~vcn;oQwt-j7K?fJVM4m~>QS|e)vL%sLreJ!2i(Y_xyX=5R*t%Z75jniCB? zTte1YR56D(!J=|uk@SZ-|7Q>zc0seH_IxkUzw+~9l~uQh<^lYCoF2JLDM9)EiFpq# zj5EHkjY59~J8>hRr^8$@2uLX|W^0km2lVUt6fS#o!}1yNE>+VaUa@6ZIu6)$k)Ejr znCy7*xWUMIhC4L=RTR8ZdAaBx6N7DMrPfiSQgRiGLy1TM2@2s@$#$RlCJ(iy209C5 z!9^#|_eB4suGo~;o!9`sODwcJQ6H{7dcBUhK+Z?|qZO1skra&(g>BKU0^JkfpR5u6 z0W~y5Y#lYC!QtS(yD&Lz^E8NOdHYhM7ek9J7F~uIDqgGNf$GL{#tf6f?iM3*J(>=G zL8F|UK98?-AMu1@IR{O95k(f{O6a$Th!s!eV7%+Sjj7EMlQ<#aJzb5jzS8H2x_oK{ zNXj{S$yhU^a0|xq;OkzwxVZ{ zvND#7pHjdmmGs+pr!m>z`WoI z?k5*wvJOk~@vWGtNWekkR9kys+0qBkrS3w*&e%~@_+HFiMWWnQ#tJK!BG1e%Ot`zT6o4;d>FJe(o z$Z13%hzHWHH1FOCqO&|i9}TiPGBuw|!rXod&YH!Ap9Bj9nq|nxZN}6`4kb}-y$eU? z3#`B^o=hTmOV9z69?@}0pIZ%ryq87QnQV{>r$-MPUyH(&VwC%pjrPkI69 zp>+-$4FEuc1>m19<$r$?LP<(OTKYdj3Y`B06#koo3n^OLj+>&Xy{Agflv*ZiPT+}4wy3e5=20NAh41rYqgWhs@K;yy@NO0gFB}R4IetyByp1+;;KjB^>e!x$BkMA}Wq%-4>Z*ZLSE|*eMbUq#myMFMsa> zWfvnl}4cdNEqYZAzRKxEm`OO2XtUkSsRhGP9<7 z>w-j!^`pn0*=RbYt7RHFB*G=~I~gX4%-LAoBgp6^WEg~-8Rluhf^8N0nE7h!E1&)9 zJ-N`$$XlHv}z(}`L;36eG<*u)zsyh zHFZZvAG1lUlrex#z?P_x>hkq>GnzHhJTx}uyt~IjruHzA{%CEV9@E;*?_vwTH~KG^ zN@OZu0=vk{`PEzgR*i+lM4(!G+U}@54c&q4);YRMawx{-lz;jSkJVAjlt$YICE|JV zHGajh!CbZ&8ejvf^Y*bds2zJ)!Y?V=pd)9Z=zem`=1<>B{31US&6K^_SwsSEO&g;b zJYQUy)-$KXx|G0!O`f|EZzbaN>F=rn+3fyq{d(K&5we2kT|?Cl=&KD|xUafj8QOC1 zL~=%oYx}PH8d~vc53J+ z_sG<%tOra&YZA~y1}XOB4$gYCFG3syB?SDQ_#es9b}9mJKr3DiRR#EZM7sCG!yfEI zFWs-3A@rBC$N48`3}8V(7tuWFH;%)Aja>*QQ1fDh9^R|!3t=}g>b)Rdgg1uLIvd9d zy~J~#oZtS@aIB7Gj8j7-JKjGmHcW|ICiOM3A!jI^I3NmxXa5~;omiy-nRvj1EB7wF zpd4MZ+TBGE06|8Gx=7$+(7v_6+Sr;AeW>JDMPQrnJMH)bqbBy_5*ooXM zh7-GnfrIur37O~8s_FrCy__3iGW^|JmM)SH7_j)P&wt)*DHOEE|aD-fDwnmemgmo$qN4=_ze$wf+nIt||Nxs;}@O zm{=cFPmvXEAd&yK4@W|>9VvEz4sWbc8M%!`%u%?Q5a(f|P6-b2s(7F*7L+o4OAUux<8egD$nvBk zq?dSb*#nMt2tZds^KH~tF0^li?;`|5FD<1SD7IqeHBA$M$RV5o8Rb+Q*QL_F z(c=diohsv%Ql&^|E~#;B%tD5=zVqRO$fwxq#@{Kq-{+A6!BS!EN2ceJ!W6ZbAo|&YzZe<(|f3&RAxFxf}_QdW6t@lr5<2({s%n?c959o)3vd#K}XJ zAr;}9jMos?37-zhs^KbU4z}ep{W%Bq$CT{2I6H0YrCdStCe8J3Z@L&(awi@;$TEkYDE#J zK{uxyC3;F^GkCie{H3>D`bW^R?H3r`Wy?o2%u7!}-V3vVa?PXna;~eq=v%?1YNp%& zfhZW9jBKvW#$4IVsBL5)sf1JjVXtXeFM_hN{JILYuWcoEV8X(KjqJKMDEepU4;^c&Vcyie=(6yfiV8N?BiY^&8i@i;b`G6PZsaU3ko z%NUf;jn#J~u~<0koZ1YH<;Eh5ct;YDz3T+STG!?;HO&Ko2sz}G5mk;tl@zGJt|>2W zyagBFHw3NcaK2eYCJdhwwxeF7Q|c7c15f+~K9v*bYxi+pla)ViBl9X@;t9jKeq+vP z>^fWq6wKHG&9xL}Jd-0c{)DphoM43IBhVqSfJrRkjadRumLc_ToL)k}Pr0z_9wGWX z8p;yuo)+dgnDcvjLDsJBB&^8w#)w@?I?N5{_2rIWSa_fwnlCfb6{vu30m(C-$hR}$ z#=(WlX4`5mAY0FHr^Vo~j!hQa$NI;2K0XsxO(IzbUl7yr-GN^v>|i(hdOZDge7t#h zUQ9D9yN&%@7q{Ef&e`#G(v^0bo5$DH&Cb;aDOOuvF8@P^Fjye9>$kkH>k+vHwusY! zwpYDmSg{DPFI-cDP;j}0fRm$*+~tt9SW~6I7Ra`kq|Ug-`g$42hctaWd4C>bn2;on z%OjLCxUNH@ZVD;Gf<+vMt55fJ-gN|$0i{p)h5k2eS~+=wu^>2RR)O^ayf4m5F$Rv^ zD*O=evcH)!-5rd;TLiFjc&rBcyxZIja*w%Tm3vA zeW!ig$g4~T8$|2~vNjumcRnOL2DceGPMjB6(nA)N2)rDGv%CfXqr5N@dPE24qPxP> zg2wvY-Dk*@eas`h+8ckY9ju#?BAK=O%^J{iq3rDu1dr*>>GVdTvdCf~|mEw~y(G>(1vonz7l(EB3>Sgw>YIrVKVsCM|fnY!bc@}S*RII=0 zMX}~EgZW>Dz{M~ZNg;bj2eEZq2m9sgmPVVb@uM>jhe8&Z_QiyX>GY8;k6P<+RZAAK z*&(^|i-)$KkQWSq}64N9cixDkoDhUPDExA`SdM=fR?R9rwcNPE0 zUp$95aZ?US{u7jXv1CG*DGSnfXLw^%I?>%ikh^KD+Nb}$=lj%)@douI-r2TUVhp%O zvCZyuC9lHu<8{&{si-$;hUNX9bk^#q%ZY_KQDVh36a^$)214zI z!Xkb><1-^2V({@5LccO{KY9ODd1m=LO#S=IZ;4>NEKC>~wyUlrh&prV*xchBx)vBa zJ>o$H%Siy79K^SUMJK2ZHh&_v45+r-OeNbStYoBnoak@o0Y94zZMGOv##Iay?lHr>09Dcs?j za@G1Iv&=;2)uBZR<^~8oLX%#Kd=2gj%{r=ft*Y4{@*8Jr7SEIZ7-orYREzip^+R6nrD~^gwKIvi`lOOx7Fe$&TVmzdn2~P(?SbH|L^y~g-wuS-MZz&! zI!>^sGx>Txi1KNtM}^}RN~B9({ua86rkO`mc)8@I`wJtT@x`*3^(;Phu#$BiD8+^q zsvvUMbmz*&s7Jn>9(SMTuM4au-@RnVxk(2i@{(rZS4y9}_D2P;dZXJg+mFthlZowt z+W_IBmfne*FWU*;F&-}$*Ip-m;sYTe`rM!Q_w$+OmpvI+-}9;u zWin9hpm;*>o248&@HLyoQ~Ox}{oz-{OC`$zt`+1}Atg@ApB^FwNhIrnSOc@CtiC5K z{NFpmro5d1m~T>VYT>e8L;|0C7*Z;to*j{?2@-$3C-@U*FckG#@SrkB*g%U66T$1h zAM7o*%3wEF?fjNxXwX~8zElt6bSKn9eTqaYsNJV?IbaSXX4t|6Peyyd1=nIO^T~j1 z&NIh{rPKHUf}R@wkbXGcPuz9y=tgn|Xu&3065h@vT3T1TNHa#dkJW;x-0%8O*IukQSaPs!tNS`Eu#` z+)?l-7n&s7$I^NoJKfxgwhbT5nBG{pa8Et57b8BkATtp5QX7mvn@l0+jP@uUb zrUij{Fh@~2)zmLWkX-+(kaLe`djI42TsDqsm~)~q$z$T7rmJO4k~@=2rC(wzbkefJ zhMIIb!a8!v!DM5uW45r)F_$*CbE4dansi)3V&KLe?2XwE5_|OSbH8v0kZrwi4m%$9>+vG6e z&NFj^Kqv<)dSYT3VOgBk8D(#r3BTSna^c6Tzo%x}JD*K(>us`5JX(3RETEpZ!z8b< zw*5OnWvv?T_9JfrC}?M|#ff8-t$oL<6cTC+F}#63NWEE>MvmhYioIprtdXF1^G5h@ znx%HJ1dN@bdOR;+H#!dw$HP%+6!R1$fJ)t^zO@zqE-t3+;q<)gb7*gg-$h5;{z%oN zo1FY1`4q}XXi?{GLes0vmq3e}(mw84cUDh7H&)vjbX6Fx5H!Yjo%5Hh3H5fAP&Le;E(kwRX)Gf&*jL1`FN6H_;(pCDBH_@OCVQ*gW-9Il0ax#jU7a@x zHqbii(@sqmzX9%#2O5;IOzFQkoV@m!bw$h}H);7Yl4I}mJa&w7_;%u%HU>3pdHPkk z`ME%kP+y2=?y?DGB=;eRY@gm9R=TNM+471mU;RJq6y2lVCIv{CWlVCePP*B1>iqUH zfdzdpoFEhAOg`Lk=DX5p8*G z0U~^(e36Eku+Fdg>jq)bsoA02sP6RC9<>jh1Wx1w!^kP7)UtJKlBug3toHM9Hr{^t z0~mI$D(aL|sMi7LmB2}N-nM5qv)WjPW=rO1p3~)qn27+B1Y+2CMO<(9dE|c5p6D&+ zQ41QF_i21LC<#ZqsC;smv7@TG#cQw5gJio#2Q$xR*7f0>Akw-US!2(CN%PDMrxFl9Z%N2U}TEKqSzU%&FY@wN*xEr`FMYygSSm{oLz-{rY}>>sKGA4Z1_ zT*_$OruU(+UaC^inO~BUYZY}Rb)GQ66k%mA+tc**v@qVJuMjY~$q*&$iWCzTr^^wR z-eXi8QCjP4laCE~lG(Boi!jMx3vXu>rpQXA;5$SH6QHQSZ{LfzBJCJLUVUh-HkZkl zU?2IP9jnlq+(}FoO~*2zxEd)1GZ%KLjZMlBMTsS7?~`^*o|q+8G}io}GGdx4?2x=& zV1vN3=YhJA_RD(tPk$}mxrqM5@QBF&r(m-5f|7VK!z53y@16`{^?HHDsy`wgq-OSD zpAEUuxz#{EY=)7Ww2MuC(!$M4D5{2U_Cz0R1`sK(XVRZ;I z#hWfv^Ph_~1u(&s6-JtLYj0ADWIiy>fA)_5NTjxdgTYk(2!{YV(03l=6mi!D1a=j| zRgpHi8FR6h5T_0z7L4m%e9LiI5kCa{ zZ57kucFAECl1%vCD_k~{XuI3E%Nd^S?xby($;(50W;UF77U3LAy*X#F>aM-A}i=*s8%Q9MAv-%&hkEVrwb*9P%?UfFGf%KsC~ zvkX~T!CM!yQ7F$pkmXz;03(|DWyrb_J^vvp#y&FuhDz*DSr#xsB}7tK!~7pZ@~k|IE{0#;u)ttl}8aD*M(Kv^oP>Calar X*8C+v&~p0$O3P`, wire `blocks-runtime`, alias `@/generated/*`, generate a missing SDK with `cnc codegen`, write or check a `.requires.json` manifest, run `check-sdk.mjs`, or author a new block against the generated React Query hooks. Enforces the SDK Binding Contract: a block imports generated hooks, never network code." +compatibility: Node.js 18+; host app on Next.js (App Router) + React 19 + @tanstack/react-query + a Constructive-generated SDK +allowed-tools: Bash, Read, Edit, Write, Glob, Grep +license: MIT +metadata: + author: constructive-io + version: "1.0.0" +--- + +# Constructive Blocks + +Constructive Blocks are **copy-in** React UI blocks — auth, account, membership, invite, and object flows — shipped through a shadcn registry (`@constructive/`). You install a block's source *into* the host app; it is then ordinary, editable app code. + +A block is not a generic component. It binds to **your app's own generated GraphQL SDK** and is correct by construction for *that* app's schema. This skill is the operator's playbook for installing, wiring, checking, and authoring blocks without violating that binding. + +## The doctrine in one sentence + +> A data block imports generated **React Query hooks** from `@/generated/` — the SDK the *host* produced from *its own* PostGraphile endpoints — and ships **no network code of its own**. + +`@constructive-io/data`, `@constructive-io/sdk`, ``, a hand-written `fetch`, or a hardcoded `src/graphql/...` path are all the **wrong** frame. The binding is the generated hook + a convention alias. The full law is [`references/binding-doctrine.md`](./references/binding-doctrine.md) (a condensation of the canonical SDK Binding Contract); it **wins** over any older blocks doc. + +## When to Apply + +Use this skill when: + +- **Installing a block**: "add the sign-in card", `npx shadcn add @constructive/auth-sign-in-card`, or wiring any `@constructive/*` block into an app. +- **Preflight / checking**: running `check-sdk.mjs`, diagnosing "block compiles against a missing operation", verifying a `.requires.json`. +- **Host wiring**: aliasing `@/generated/*`, mounting ``, adding a namespace to the runtime, generating a missing SDK with `cnc codegen`. +- **Authoring a block**: writing a new block that calls a generated hook, choosing its namespace, declaring its `requires.json`, adding the override seam. + +If the request is about generating the SDK itself (codegen flags, ORM/hook output shapes, search, pagination), defer to **`constructive-sdk-graphql`** — this skill *consumes* that SDK. + +## Host setup — three steps (once per app) + +A block compiles only if the host satisfies all three. `check-sdk.mjs` verifies steps 1–2; you do step 3 once. + +**1. Generate the SDK** for each namespace the app uses, into `src/generated/`: + +```bash +# By API name against the app database (auto-expands to multi-target): +cnc codegen --api-names auth,admin --react-query --orm -o src/generated +# …or per endpoint: +cnc codegen --endpoint https://auth./graphql --react-query --orm -o src/generated/auth +``` + +`--react-query` **and** `--orm` are both required — hooks wrap the ORM client and the runtime's `configure()` lives in the ORM layer. Generated files are stamped `DO NOT EDIT`; never hand-edit them, regenerate. + +**2. Alias `@/generated/*`** to the generated output in `tsconfig.json` (and the bundler if it doesn't read tsconfig paths): + +```jsonc +{ "compilerOptions": { "paths": { "@/generated/*": ["./src/generated/*"] } } } +``` + +**3. Mount `` once at the app root.** It is a `registryDependency` of every data block (installed automatically), so this is the only provider wiring a human writes: + +```tsx +// app/layout.tsx +import { BlocksRuntime } from '@/blocks/runtime/blocks-runtime'; +import { tokenManager } from '@/lib/auth'; + +export default function RootLayout({ children }) { + return ( + tokenManager.getAccessToken()}> + {children} + + ); +} +``` + +The runtime mounts **one** shared `QueryClient`, calls each namespace's generated `configure()` (reading `NEXT_PUBLIC__GRAPHQL_ENDPOINT`), and attaches `Authorization: Bearer ` via the host's `getToken`. A block **never** mounts a provider or calls `configure()`. + +## Installing a block + +```bash +# 1. Pull the block's source into the app (also installs its registry deps: +# blocks-runtime, foundation libs, primitives, cn). +npx shadcn add @constructive/auth-sign-in-card + +# 2. Preflight: prove the host SDK actually exports every op the block needs. +node path/to/skill/scripts/check-sdk.mjs auth-sign-in-card +``` + +Step 1 also writes the block's manifest to `.constructive/blocks/.requires.json`. **Always run step 2 after installing a data block** — it is the §9 enforcement gate. A green check means the block will compile against real operations; a red check names the exact missing op *before* you waste a build. + +Then render it: + +```tsx +import { SignInCard } from '@/blocks/auth/sign-in-card/sign-in-card'; + + router.push('/')} + forgotPasswordHref="/forgot" + signUpHref="/register" +/> +``` + +## The `requires.json` manifest + +Every **data block** ships a co-located, machine-readable manifest declaring exactly what the host SDK must expose. It lands at `.constructive/blocks/.requires.json` on install: + +```json +{ "namespace": "auth", "mutations": ["signIn"], "queries": [], "models": [] } +``` + +- `namespace` — the generated namespace the block imports from (`auth`, `admin`, `objects`, `public`, …). +- `mutations` / `queries` — **GraphQL operation names** (camelCase, post-inflection) the block calls. `signIn` (not `useSignInMutation`) — the manifest names the *operation*; the check derives the hook. +- `models` — table model accessors the block needs (only when it uses a `useQuery` list hook; see the Connection rule below). + +**Presentational blocks ship no manifest.** A cross-namespace block uses one shape consistently — see [`references/manifest-and-checks.md`](./references/manifest-and-checks.md) for the authoritative schema (single-object vs `requires: [...]` array) and rules. + +## `check-sdk.mjs` — the preflight gate + +Zero-dependency Node (≥18). Run from the host app root: + +```bash +node scripts/check-sdk.mjs # check every installed manifest +node scripts/check-sdk.mjs auth-sign-in-card # check one block (name or manifest path) +node scripts/check-sdk.mjs --project /path/app --json +``` + +It (1) verifies the `@/generated/*` alias exists in `tsconfig.json`, (2) resolves and checks the generated dir for each block's namespace, (3) asserts every manifest op maps to a real SDK export (`signIn` → `useSignInMutation`), and (4) advises whether `` is mounted. **Exit codes: `0`** satisfied · **`1`** a prerequisite is missing · **`2`** the check couldn't run (no tsconfig / bad manifest). + +On failure it prints the exact remediation: + +- **Alias or generated dir missing** → it prints the `cnc codegen --api-names --react-query --orm -o src/generated` to run, then re-check. +- **SDK present but an op is absent** → the backend likely hasn't deployed that procedure, or the SDK is stale. Regenerate and drift-check with `cnc codegen … --dry-run`. + +**This script never runs `cnc codegen` itself** — generation needs an endpoint and operator confirmation. It *detects*; you *remediate*. If the SDK is genuinely missing, confirm the endpoint/api-names with the operator, run `cnc codegen`, then re-run the check. + +## Extending the runtime with a new namespace + +`blocks-runtime.tsx` is the host's wiring point, not a leaf block — editing it is expected. To support a namespace beyond `auth`/`admin`, make exactly three matched edits: + +```tsx +import { configure as configureObjects } from '@/generated/objects'; // 1. import its configure() +export type BlocksNamespace = 'auth' | 'admin' | 'objects'; // 2. widen the union +const CONFIGURERS = { auth: configureAuth, admin: configureAdmin, objects: configureObjects }; +const ENDPOINTS = { + auth: process.env.NEXT_PUBLIC_AUTH_GRAPHQL_ENDPOINT, + admin: process.env.NEXT_PUBLIC_ADMIN_GRAPHQL_ENDPOINT, + objects: process.env.NEXT_PUBLIC_OBJECTS_GRAPHQL_ENDPOINT, // 3. add the literal env var +}; +``` + +The env var **must** be referenced literally (`process.env.NEXT_PUBLIC_OBJECTS_GRAPHQL_ENDPOINT`), never as `process.env[\`NEXT_PUBLIC_${ns}_...\`]` — Next.js only inlines literal references. + +## Generated hook anatomy + +Block authors call the **real generated names** and pass a `selection` — never guess a signature; verify it in the generated `.d.ts`. + +| Operation kind | Generated hook | Example | +|---|---|---| +| Custom operation | `useMutation` | `signIn` → `useSignInMutation` | +| Table read (list / one) | `useQuery` / `useQuery` | `useUsersQuery`, `useUserQuery` | +| Table write | `useCreate/Update/DeleteMutation` | `useCreateApiKeyMutation` | + +```tsx +const signIn = useSignInMutation({ + selection: { fields: { result: { select: { userId: true, mfaRequired: true } } } }, +}); +await signIn.mutateAsync({ email, password, rememberMe }); +``` + +**Connection rule (critical):** a model accessor + `useQuery` list hook exist **iff** the SDL has a `*Connection` type for that table. Tables exposed only as private-schema views get no list hook — only their explicit mutations. This is why sessions/api-keys are not listable (see gaps below). + +## Known SDK gaps (consequences, not bugs) + +| Capability | Status | Block handling | +|---|---|---| +| List active sessions | No Connection type (`user_sessions` is private) → no list hook | `auth-account-sessions-list` is **out of frontend scope** until an API exposes a sessions Connection. Only `revokeSession` exists. | +| List API keys | Same — `user_api_keys` is private | `auth-account-api-keys-list` likewise out of scope; `createApiKey`/`revokeApiKey` exist. | +| Passkeys / TOTP-enroll / magic-link / email-OTP / anonymous / context-switch / org transfer+delete | Procedures **not yet deployed** in any public schema | Blocks kept **backend-pending** with a "not buildable until proc ships" banner; their `requires.json` names the pending op so `check-sdk.mjs` fails clearly. | + +A block whose required op is absent **fails the check with a precise message** rather than compiling against a guess — that is the gap surfacing honestly, not a defect. + +## The override seam (portability) + +The default path is the generated hook. Every data block also accepts an `onSubmit` (mutations) / `adapter` (queries) prop that **fully replaces** the network call, so the block runs on a non-Constructive backend. The block keeps owning form state, validation, error mapping, and notifications either way: + +```tsx + myAuth.login(vars)} onSuccess={(r) => ...} /> +``` + +This is the one soft point in the binding; everything else is the canonical Constructive-stack path. + +## Authoring a new block — checklist + +A new block is contract-compliant only if all hold (full list in `references/binding-doctrine.md` §11): + +1. Data blocks import hooks from `@/generated/` — never a package name or hardcoded generated path. +2. No `fetch`, no GraphQL document strings, no `configure()`/`getClient()`, no `QueryClientProvider` in any block file. +3. Calls use the real generated hook names and pass a `selection`. +4. An `onSubmit`/`adapter` override prop is present and fully replaces the default hook. +5. Co-located `.requires.json` lists namespace + ops; presentational blocks ship none. +6. `blocks-runtime` is in the block's `registryDependencies`; the block mounts no provider. +7. The registry `docs` field summarizes the SDK/proc prerequisites for humans. +8. `grep` for `@constructive-io/data`, `useConstructiveClient`, ``, `tokenStorage` finds nothing. + +UI is built on `@constructive-io/ui` (consumed as an npm dependency — **never** vendored/copied) + the shared foundation libs/primitives (`auth-errors`, `auth-schemas`, `form-field`, `auth-error-alert`, `auth-loading-button`). Form state uses `@tanstack/react-form`. + +## Reference Guide + +| Reference | Topic | Consult when | +|---|---|---| +| [binding-doctrine.md](./references/binding-doctrine.md) | The canonical SDK binding law: namespaces, import convention, runtime, hook anatomy, override seam, compliance checklist | Authoring a block, reviewing one, or resolving any "how does a block reach the backend" question | +| [manifest-and-checks.md](./references/manifest-and-checks.md) | Authoritative `requires.json` schema (single + cross-namespace), op-name rules, `check-sdk.mjs` invocation/exit codes/remediation | Writing or validating a manifest, interpreting a check failure | + +## Cross-References + +- `constructive-sdk-graphql` — generating the SDK this skill consumes: `cnc codegen`, hook/ORM output shapes, selection/pagination/search. +- `constructive-frontend` — the `@constructive-io/ui` component library blocks are built on. +- `constructive-platform` — CNC CLI, server config, API/endpoint deployment (what determines which ops a namespace exposes). diff --git a/.agents/skills/constructive-blocks/references/binding-doctrine.md b/.agents/skills/constructive-blocks/references/binding-doctrine.md new file mode 100644 index 0000000..a28c976 --- /dev/null +++ b/.agents/skills/constructive-blocks/references/binding-doctrine.md @@ -0,0 +1,114 @@ +# Binding Doctrine + +Condensation of the canonical **SDK Binding Contract** for in-skill use. Where any older blocks doc disagrees about data fetching, hooks, clients, providers, or endpoints, this wins. It supersedes the `@constructive-io/data` hybrid, the `` model, and any pinned-SDK frame. + +## 0. The doctrine + +A block binds to the **per-application generated SDK** — the namespaced TypeScript client the *host app* produces with `@constructive-io/graphql-codegen` from *its own* PostGraphile endpoints — **not** to any pinned, hand-written, or pre-published SDK package. It imports generated **React Query hooks** from a convention path (`@/generated/`) the host has aliased to its generated output. The block ships no network code of its own. + +## 1. Why per-app, not pinned + +A Constructive app's GraphQL surface is **dynamic** — a function of which pgpm modules are deployed, the app's `api_schemas` config, and `database_settings` flags. Two apps almost never expose the same operations, types, or field sets. A block pinned to one frozen `.d.ts` is correct for exactly one app and silently wrong for every other (the prior build's failure mode: guessed op names, wrong arg wrappers, wrong payload shapes). Codegen against the host's *live* endpoints encodes the exact operation kind, input shape, payload wrapper, and field names — a block written against the generated signatures is correct by construction. + +## 2. Namespaces + +Codegen emits one SDK per registered API (a row in `services_public.apis`; its `api_schemas` list the PostgreSQL schemas it exposes; each is reachable at its own subdomain). The four standard namespaces: + +| Namespace | Subdomain | Schema set (current) | +|---|---|---| +| `auth` | `auth.` | `constructive_auth_public` + `users_public` + `user_identifiers_public` + `logging_public` | +| `admin` | `admin.` | `memberships_public` + `permissions_public` + `limits_public` + `invites_public` + `status_public` | +| `objects` | `objects.` | `object_store_public` + `object_tree_public` | +| `public` | `api.` | nearly all of the above combined | + +**Routing blocks to a namespace:** + +- Auth flows (sign-in, password, email/MFA, account, identity) → `auth`. +- Membership / invite / role / permission / limit / status → `admin`. (Invite *acceptance* mutations `submitAppInviteCode` / `submitOrgInviteCode` live in `invites_public`, reachable via `admin` or `public`.) +- File/object blocks → `objects`. +- A block needing ops from more than one schema set targets `public`, **or** imports from two namespaces. Prefer a single namespace per block; document any cross-namespace block in `requires.json` with multiple entries. The list is not closed — an app may register custom APIs. + +## 3. Import convention (locked v1) + +```tsx +'use client'; +import { useSignInMutation } from '@/generated/auth'; +import { useOrganizationMembersQuery } from '@/generated/admin'; +``` + +A block **never** imports from a versioned SDK package name, never hardcodes a path like `src/graphql/auth-sdk/api`, and never writes its own `fetch`, GraphQL document, or client bootstrap. + +> **Why a convention path, not an injected client?** Generated hooks are hard-bound to a module-level singleton (`getClient()`) — there is no `client` parameter on any hook. The only way a block and the host share one configured client is to import the *same generated module*. The `@/generated/` alias makes "the same module" a stable, app-agnostic name a block compiles against. + +## 4. The override seam (portability) + +The default path is the generated hook. Every block also accepts `onSubmit` (mutations) / `adapter` (queries) that **fully replaces** the network call, so the block stays usable on a non-Constructive backend. The block still owns form state, validation, error mapping, and notifications regardless. This is the one soft point in the binding; everything else here is the canonical path. + +## 5. Generated hook anatomy + +**Naming** (confirmed against real codegen output): + +- Custom operations → `useMutation` (e.g. `useSignInMutation`, `useRequireStepUpMutation`). The previous plan assumed `useSignIn`; the real name is `useSignInMutation`. +- Table reads → `useQuery` / `useQuery` (e.g. `useUsersQuery`, `useUserQuery`). +- Table writes → `useCreateMutation` / `useUpdateMutation` / `useDeleteMutation`. + +**React Query.** Every hook calls `useMutation`/`useQuery` and needs a `QueryClient` in the tree (the runtime supplies it). Each takes a `selection` field-picker plus standard React Query options: + +```tsx +const signIn = useSignInMutation({ + selection: { fields: { result: { select: { userId: true, mfaRequired: true } } } }, + onSuccess: (data) => { /* data.signIn... */ }, +}); +await signIn.mutateAsync({ email, password, rememberMe }); +``` + +**Per-namespace singleton.** Each SDK ships its own `configure(config)` / `getClient()` backed by a module-level instance. `configure()` must run **once per namespace** (auth and admin are separate singletons). There is **no** `client` prop on any hook. `OrmClientConfig = { endpoint?, headers?, fetch?, adapter?, realtime? }` — there is **no token-storage property**; auth is attached via `headers`/`fetch`/`adapter` (the runtime uses a `getToken`-driven adapter). + +**Model accessor exists iff a `*Connection` type exists.** Codegen infers a table model accessor (`.findMany()` + the `useQuery` hook) only when the SDL has a `*Connection` object type for that table. Tables exposed only as private-schema views get no accessor and no list hook — only their explicit mutations. + +**Op-shape branching** (how a block calls a hook): + +- scalar / Connection return → flat-arg, no `select`, raw return. +- object payload return → `{ input }` + `{ select }`, read `.result`. +- table CRUD → `{ where, data }` with a `*Patch` data type (gated on a valid PK). + +Always verify the real signature in the generated `.d.ts` / hook file — never guess. + +## 6. The runtime block: `blocks-runtime` + +One shipped registry item encapsulating host wiring so no human hand-writes provider boilerplate. It is a `registryDependency` of every data block and mounts, once at app root: + +1. **One** `` (one shared `QueryClient` for all namespaces — the "two QueryClients" fear was an *unmounted-provider* artifact, not a real defect). +2. **Per-namespace `configure()`** for each namespace present, reading `NEXT_PUBLIC__GRAPHQL_ENDPOINT` and attaching auth via a host `getToken` → `Authorization: Bearer ` adapter. + +```tsx + tokenManager.getAccessToken()}> + {children} + +``` + +A block **never** mounts a provider or calls `configure()`. Tests mount the runtime (or mock the generated hook module) — never react-query directly. + +## 7. Generating the SDK (`cnc codegen`) + +```bash +cnc codegen --endpoint https://auth./graphql --react-query --orm -o src/generated/auth +cnc codegen --api-names auth,admin,public,objects --react-query --orm -o src/generated +cnc codegen --schema-file ./schemas/auth.graphql --react-query --orm -o src/generated/auth +``` + +`--react-query` **and** `--orm` are both required. `--dry-run` previews without writing (used by the staleness check). Sources are mutually exclusive: `--endpoint` | `--schema-file` | `--schema-dir` | `--api-names`/`--schemas` | `--config`. Output is never hand-edited (`@generated … DO NOT EDIT`); regeneration is the only correct change. + +## 11. Compliance checklist + +A reviewer checking a block MUST confirm: + +1. **Generated-hook import** — data blocks import from `@/generated/`, never a package name or hardcoded generated path. +2. **No network code** — no `fetch`, no GraphQL document strings, no `configure()`/`getClient()`, no `QueryClientProvider` in any block file. +3. **Generated hook names** — calls use real generated names (`useMutation`, `useQuery`) and pass a `selection`. +4. **Override seam** — `onSubmit`/`adapter` present and fully replaces the default hook. +5. **`requires.json`** — every data block ships a co-located manifest; presentational blocks ship none. +6. **Runtime dependency** — data blocks list `blocks-runtime` in `registryDependencies`; none mount a provider. +7. **Docs prerequisite** — the registry `docs` field summarizes SDK/proc prerequisites for humans. +8. **Gap honesty** — blocks for known gaps carry the out-of-scope / backend-pending banner; their `requires.json` names the absent op. +9. **No pinned-SDK references** — `grep` for `@constructive-io/data`, `@constructive-io/react`, `useConstructiveClient`, ``, `tokenStorage` finds nothing in block source. diff --git a/.agents/skills/constructive-blocks/references/manifest-and-checks.md b/.agents/skills/constructive-blocks/references/manifest-and-checks.md new file mode 100644 index 0000000..756f634 --- /dev/null +++ b/.agents/skills/constructive-blocks/references/manifest-and-checks.md @@ -0,0 +1,111 @@ +# Manifest & Checks + +The authoritative `.requires.json` schema and the `check-sdk.mjs` preflight. Per the SDK Binding Contract §7, **this document is authoritative** for the manifest shape — where the contract leaves the cross-namespace form to "pick one and keep it consistent," the choice is locked here. + +## What ships a manifest + +Every **data block** (any block importing a generated hook) ships a co-located, machine-readable `.requires.json` as a registry `file`. On install it lands at: + +``` +.constructive/blocks/.requires.json +``` + +**Presentational blocks ship none** (no generated-hook import → nothing to verify). The registry item's `docs` field always carries a *human-readable* summary of the same prerequisites; the JSON manifest is the machine-checkable twin that `check-sdk.mjs` reads. + +## Schema — single namespace (canonical) + +A block that imports from one namespace ships a single top-level object: + +```json +{ + "namespace": "auth", + "mutations": ["signIn", "requireStepUp"], + "queries": ["currentUser"], + "models": [] +} +``` + +| Field | Meaning | +|---|---| +| `namespace` | The generated namespace the block imports from (`auth`, `admin`, `objects`, `public`, or a custom API). Exactly the `` in `@/generated/`. | +| `mutations` | GraphQL **operation names** the block calls — camelCase, post-inflection (`signIn`, not `SignIn`, not `useSignInMutation`). The check derives the hook name. | +| `queries` | GraphQL query operation names, same convention. | +| `models` | Table **model accessors** the block needs — populated **only** when the block uses a `useQuery` list hook. Subject to the Connection rule (below). | + +All four keys are present; unused ones are empty arrays. + +## Schema — cross-namespace (locked shape) + +A block that imports from more than one namespace uses a top-level **`requires` array**, one object per namespace: + +```json +{ + "requires": [ + { "namespace": "admin", "mutations": ["submitOrgInviteCode"], "queries": [], "models": [] }, + { "namespace": "auth", "mutations": [], "queries": ["currentUser"], "models": [] } + ] +} +``` + +This is the **one** cross-namespace shape — do not use a bare top-level array, and do not nest namespaces inside a single object. `check-sdk.mjs` normalizes a manifest as: `raw.requires` when present, else `[raw]` (the single-object form). Prefer a single namespace per block where possible (§2); reach for `requires[]` only when a block genuinely spans schema sets. + +## Operation-name → hook-name derivation + +The manifest names **operations**; the check derives the generated **hook** it expects to find exported by the SDK: + +| Manifest field | Entry | Expected SDK export | +|---|---|---| +| `mutations` | `signIn` | `useSignInMutation` | +| `queries` | `currentUser` | `useCurrentUserQuery` | +| `models` | `user` | a model file `models/user.*` (or an export named `user`) | + +So a manifest entry is satisfied when the operation's `use` identifier is a real export of the namespace's generated SDK. + +## The Connection rule (when `models` applies) + +A model accessor and its `useQuery` list hook exist **iff** the SDL has a `*Connection` object type for that table. Tables exposed only as private-schema views (no Connection type) get **no** accessor and **no** list hook — only their explicit mutations. + +Practical consequence: only list `models` a block actually reads via a list hook. Sessions and API keys (`user_sessions` / `user_api_keys`, in `constructive_auth_private`) have no Connection type, so they are **not listable** through any generated SDK — a manifest must not claim them as `models`. The blocks for those lists are out of frontend scope until an API exposes the Connection (see SKILL.md "Known SDK gaps"). + +## `check-sdk.mjs` + +Zero-dependency Node (≥18), bundled at `scripts/check-sdk.mjs`. Run from the host app root. + +```bash +node scripts/check-sdk.mjs # check every installed manifest +node scripts/check-sdk.mjs auth-sign-in-card # one block by name… +node scripts/check-sdk.mjs ./path/to.requires.json # …or by manifest path +node scripts/check-sdk.mjs --project /path/app # check a different project root +node scripts/check-sdk.mjs --json # machine-readable report on stdout +node scripts/check-sdk.mjs --help +``` + +### What it verifies + +1. The `@/generated/*` alias exists in the host `tsconfig.json` (follows one `extends` level; tolerant of JSONC comments + trailing commas). +2. The generated dir for each block's namespace exists, resolved **via the alias** (tries `@/generated/`, `@/generated//*`, then `@/generated/*` — never a hardcoded path). +3. Every manifest `mutation`/`query`/`model` maps to a real export of that SDK (it scans every SDK source file, so a leaf `export function useXMutation` is found regardless of barrel re-exports). +4. *(Advisory)* whether `` appears mounted somewhere in the host source. + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | Every prerequisite satisfied — or nothing to check (no manifests). | +| `1` | A prerequisite is missing (alias, generated dir, or an op/model export). | +| `2` | The check could not run — no `tsconfig.json`, bad args, or an unreadable/unparseable manifest. | + +### What it does NOT do + +It **never runs `cnc codegen`**. Drift detection (`--dry-run`) and generating a missing SDK need an endpoint and operator confirmation, so the script only *detects* and prints the exact command to run. The operator (or the agent following SKILL.md) performs the generation after confirming endpoint/api-names. + +## Reading a failure → remediation + +| Failure | What it means | Remediation | +|---|---|---| +| `✗ @/generated/* alias in tsconfig` | Host never aliased the generated output. | Add `"@/generated/*": ["./src/generated/*"]` to `tsconfig.json` paths, then re-check. | +| `namespace ✗ (unresolved …)` / dir missing | No SDK generated for that namespace. | The script prints `cnc codegen --api-names --react-query --orm -o src/generated`. Confirm endpoint/api-names with the operator, run it, re-check. | +| `✗ mutation → useMutation` (dir exists) | SDK is present but lacks that op — backend hasn't deployed the procedure, or the SDK is stale. | Regenerate; drift-check with `cnc codegen … --dry-run`. If the op is a known backend-pending gap, the block is not buildable until the proc ships. | +| `• not found` | Advisory only — not a hard failure. | Mount `` once at the app root (see SKILL.md host setup step 3). | + +A red op line is the binding working as designed: the block surfaces the exact missing operation *before* compiling against a guess. diff --git a/.agents/skills/constructive-blocks/scripts/check-sdk.mjs b/.agents/skills/constructive-blocks/scripts/check-sdk.mjs new file mode 100755 index 0000000..1abddad --- /dev/null +++ b/.agents/skills/constructive-blocks/scripts/check-sdk.mjs @@ -0,0 +1,370 @@ +#!/usr/bin/env node +/** + * check-sdk.mjs — preflight check for installing Constructive data blocks. + * + * Part of the `constructive-blocks` agent skill. Implements the enforcement + * described in the SDK Binding Contract §9: before a data block is considered + * installable, its declared prerequisites (a co-located `.requires.json`, + * installed to `.constructive/blocks/`) MUST be satisfied by the host app's + * generated SDK. A block whose required op is absent fails here — with a precise + * message — instead of compiling against a guess. + * + * Zero dependencies. Pure Node (>=18). Run from the host app's project root: + * + * node check-sdk.mjs # check every installed manifest + * node check-sdk.mjs auth-sign-in-card # check one block (name or path) + * node check-sdk.mjs --project /path/app # check a different project root + * node check-sdk.mjs --json # machine-readable report on stdout + * + * Exit codes: + * 0 every prerequisite satisfied (or nothing to check) + * 1 a prerequisite is missing (alias / generated dir / op export) + * 2 the check could not run (no tsconfig, bad args, unreadable manifest) + * + * What it verifies (per contract §9): + * 1. the `@/generated/*` alias exists in the host tsconfig + * 2. the generated dir for each block's namespace exists (resolved via alias) + * 3. every mutation/query/model in requires.json is an export of that SDK + * 5. (advisory) `@constructive/blocks-runtime` appears mounted somewhere + * + * Drift detection (§9.4) and generating a missing SDK (§9.6) require `cnc + * codegen` + an endpoint + operator confirmation, so they are NOT run here — + * on failure this script prints the exact `cnc codegen` command to run. The + * skill's SKILL.md drives that remediation. + */ + +import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; +import { join, resolve, dirname, basename, isAbsolute } from 'node:path'; + +const SKIP_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', 'coverage']); +const SRC_EXT = /\.(?:[cm]?tsx?|d\.ts)$/; + +// --------------------------------------------------------------------------- +// args +// --------------------------------------------------------------------------- +function parseArgs(argv) { + const opts = { project: process.cwd(), only: null, json: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--project' || a === '-p') opts.project = resolve(argv[++i] ?? '.'); + else if (a === '--json') opts.json = true; + else if (a === '--help' || a === '-h') opts.help = true; + else if (!a.startsWith('-')) opts.only = a; // block name or manifest path + } + return opts; +} + +// --------------------------------------------------------------------------- +// tsconfig: read compilerOptions.paths (+ baseUrl), following one `extends`. +// JSONC-tolerant (tsconfig allows comments + trailing commas). +// --------------------------------------------------------------------------- +function readJsonc(file) { + let txt = readFileSync(file, 'utf-8'); + txt = txt.replace(/\/\*[\s\S]*?\*\//g, ''); // block comments + txt = txt.replace(/(^|[^:])\/\/.*$/gm, '$1'); // line comments (not URLs) + txt = txt.replace(/,(\s*[}\]])/g, '$1'); // trailing commas + return JSON.parse(txt); +} + +function loadTsconfig(projectRoot) { + const path = join(projectRoot, 'tsconfig.json'); + if (!existsSync(path)) return null; + let cfg; + try { + cfg = readJsonc(path); + } catch (e) { + fail(2, `Could not parse ${path}: ${e.message}`); + } + let co = cfg.compilerOptions ?? {}; + let baseDir = projectRoot; + // One level of `extends`: pull paths/baseUrl from the base if absent here. + if (cfg.extends && (!co.paths || co.baseUrl === undefined)) { + try { + const extPath = isAbsolute(cfg.extends) ? cfg.extends : resolve(projectRoot, cfg.extends); + const resolved = existsSync(extPath) ? extPath : `${extPath}.json`; + if (existsSync(resolved)) { + const base = readJsonc(resolved); + const baseCo = base.compilerOptions ?? {}; + co = { ...baseCo, ...co, paths: co.paths ?? baseCo.paths }; + if (co.baseUrl === undefined && baseCo.baseUrl !== undefined) { + baseDir = dirname(resolved); + co.baseUrl = baseCo.baseUrl; + } + } + } catch { + /* best-effort */ + } + } + const baseUrl = co.baseUrl ? resolve(baseDir, co.baseUrl) : projectRoot; + return { paths: co.paths ?? {}, baseUrl }; +} + +// Resolve the on-disk dir an alias key maps to (first target), substituting `*`. +function resolveAlias(target, substitution, baseUrl) { + const filled = target.replace(/\*/g, substitution).replace(/\/$/, ''); + return resolve(baseUrl, filled); +} + +// Find the generated dir for a namespace via `@/generated/*`, `@/generated/`, +// or `@/generated//*`. Returns { dir, aliasKey } or null. +function resolveGeneratedDir(ns, paths, baseUrl) { + const candidates = [`@/generated/${ns}`, `@/generated/${ns}/*`, `@/generated/*`, `@/generated/*/`]; + for (const key of candidates) { + const targets = paths[key]; + if (!targets || !targets.length) continue; + const sub = key === `@/generated/*` || key === `@/generated/*/` ? ns : ''; + const dir = resolveAlias(targets[0], sub, baseUrl); + return { dir, aliasKey: key }; + } + return null; +} + +function hasGeneratedAlias(paths) { + return Object.keys(paths).some((k) => k.startsWith('@/generated/')); +} + +// --------------------------------------------------------------------------- +// SDK introspection: collect exported identifiers + model file names. +// We scan every source file (so leaf `export function useXMutation` is found +// regardless of how the barrels re-export) and parse two export forms: +// export (async)? (function|const|let|var|class|type|interface|enum) NAME +// export (type)? { A, B as C, type D } ← captures the EXPORTED name +// --------------------------------------------------------------------------- +function walk(dir, files = []) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return files; + } + for (const e of entries) { + if (e.isDirectory()) { + if (!SKIP_DIRS.has(e.name)) walk(join(dir, e.name), files); + } else if (SRC_EXT.test(e.name)) { + files.push(join(dir, e.name)); + } + } + return files; +} + +const DECL_RE = /export\s+(?:async\s+)?(?:function|const|let|var|class|type|interface|enum)\s+([A-Za-z0-9_$]+)/g; +const LIST_RE = /export\s+(?:type\s+)?\{([^}]*)\}/g; + +function collectSdk(sdkDir) { + const exports = new Set(); + const models = new Set(); + for (const file of walk(sdkDir)) { + let txt; + try { + txt = readFileSync(file, 'utf-8'); + } catch { + continue; + } + let m; + while ((m = DECL_RE.exec(txt))) exports.add(m[1]); + while ((m = LIST_RE.exec(txt))) { + for (let item of m[1].split(',')) { + item = item.trim().replace(/^type\s+/, ''); + if (!item) continue; + const as = item.split(/\s+as\s+/); + const name = (as[1] ?? as[0]).trim(); + if (/^[A-Za-z0-9_$]+$/.test(name)) exports.add(name); + } + } + // model accessor signal: a file living under a `models/` directory. + if (/(?:^|\/)models\//.test(file.replace(/\\/g, '/'))) { + models.add(basename(file).replace(SRC_EXT, '')); + } + } + return { exports, models }; +} + +// op name (camelCase GraphQL op) → expected generated hook identifier. +const pascal = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s); +const mutationHook = (op) => `use${pascal(op)}Mutation`; +const queryHook = (op) => `use${pascal(op)}Query`; + +// --------------------------------------------------------------------------- +// manifests: read .constructive/blocks/*.requires.json (or a named one). +// A manifest is either a single { namespace, mutations, queries, models } +// object, or { requires: [ {…}, … ] } for cross-namespace blocks. +// --------------------------------------------------------------------------- +function manifestDir(projectRoot) { + return join(projectRoot, '.constructive', 'blocks'); +} + +function findManifests(projectRoot, only) { + if (only) { + // explicit path, or a block name resolved under .constructive/blocks/ + const direct = isAbsolute(only) ? only : resolve(projectRoot, only); + if (existsSync(direct) && statSync(direct).isFile()) return [direct]; + const named = join(manifestDir(projectRoot), only.endsWith('.requires.json') ? only : `${only}.requires.json`); + if (existsSync(named)) return [named]; + fail(2, `No manifest found for "${only}" (looked for ${named}).`); + } + const dir = manifestDir(projectRoot); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((f) => f.endsWith('.requires.json')) + .map((f) => join(dir, f)) + .sort(); +} + +function normalizeRequirements(raw) { + const list = Array.isArray(raw?.requires) ? raw.requires : [raw]; + return list.map((r) => ({ + namespace: r.namespace, + mutations: r.mutations ?? [], + queries: r.queries ?? [], + models: r.models ?? [] + })); +} + +// --------------------------------------------------------------------------- +// advisory: is mounted anywhere in the host source? +// --------------------------------------------------------------------------- +function runtimeMounted(projectRoot) { + const src = join(projectRoot, 'src'); + const root = existsSync(src) ? src : projectRoot; + for (const file of walk(root)) { + try { + if (/]/.test(readFileSync(file, 'utf-8'))) return true; + } catch { + /* ignore */ + } + } + return false; +} + +// --------------------------------------------------------------------------- +// reporting +// --------------------------------------------------------------------------- +const C = process.stdout.isTTY + ? { red: (s) => `\x1b[31m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m` } + : { red: (s) => s, green: (s) => s, dim: (s) => s, bold: (s) => s }; + +function fail(code, msg) { + console.error(`${C.red('✗')} ${msg}`); + process.exit(code); +} + +const HELP = `check-sdk.mjs — verify the host SDK satisfies installed Constructive data blocks. + +Usage: + node check-sdk.mjs [block] [--project DIR] [--json] + + [block] a block name (auth-sign-in-card) or manifest path; omit to check all + --project DIR project root to check (default: cwd) + --json emit a machine-readable report + --help show this help`; + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + console.log(HELP); + process.exit(0); + } + + const ts = loadTsconfig(opts.project); + if (!ts) fail(2, `No tsconfig.json in ${opts.project}. Run from the host app root or pass --project.`); + + const manifests = findManifests(opts.project, opts.only); + if (!manifests.length) { + console.log(`${C.dim('•')} No data-block manifests in ${manifestDir(opts.project)} — nothing to check.`); + process.exit(0); + } + + const aliasOk = hasGeneratedAlias(ts.paths); + const sdkCache = new Map(); // ns -> { dir, sdk } | { dir:null } + const report = []; + let failed = false; + + for (const file of manifests) { + let raw; + try { + raw = JSON.parse(readFileSync(file, 'utf-8')); + } catch (e) { + fail(2, `Could not parse manifest ${file}: ${e.message}`); + } + const block = basename(file).replace(/\.requires\.json$/, ''); + const reqs = normalizeRequirements(raw); + const blockEntry = { block, namespaces: [] }; + + for (const req of reqs) { + const ns = req.namespace; + const nsEntry = { namespace: ns, aliasOk, generatedDir: null, ops: [] }; + + if (!sdkCache.has(ns)) { + const loc = aliasOk ? resolveGeneratedDir(ns, ts.paths, ts.baseUrl) : null; + if (loc && existsSync(loc.dir)) sdkCache.set(ns, { dir: loc.dir, sdk: collectSdk(loc.dir) }); + else sdkCache.set(ns, { dir: loc?.dir ?? null, sdk: null }); + } + const cached = sdkCache.get(ns); + nsEntry.generatedDir = cached.dir; + + const checkOp = (op, kind, expected, present) => { + const ok = !!cached.sdk && present; + if (!ok) failed = true; + nsEntry.ops.push({ op, kind, expects: expected, ok }); + }; + + for (const op of req.mutations) checkOp(op, 'mutation', mutationHook(op), cached.sdk?.exports.has(mutationHook(op))); + for (const op of req.queries) checkOp(op, 'query', queryHook(op), cached.sdk?.exports.has(queryHook(op))); + for (const mdl of req.models) + checkOp(mdl, 'model', `models/${mdl}`, cached.sdk?.models.has(mdl) || cached.sdk?.exports.has(mdl)); + + blockEntry.namespaces.push(nsEntry); + } + report.push(blockEntry); + } + + const runtimeOk = runtimeMounted(opts.project); + + if (opts.json) { + console.log(JSON.stringify({ project: opts.project, aliasOk, runtimeMounted: runtimeOk, blocks: report, ok: !failed }, null, 2)); + process.exit(failed ? 1 : 0); + } + + // human report + console.log(C.bold(`\nConstructive blocks — SDK preflight (${opts.project})\n`)); + console.log(`${aliasOk ? C.green('✓') : C.red('✗')} @/generated/* alias in tsconfig`); + const missingNs = new Set(); + for (const b of report) { + console.log(`\n${C.bold(b.block)}`); + for (const ns of b.namespaces) { + const dirOk = !!ns.generatedDir && existsSync(ns.generatedDir); + console.log( + ` namespace ${C.bold(ns.namespace)} ${dirOk ? C.green('✓') : C.red('✗')} ${C.dim(ns.generatedDir ?? '(unresolved — alias missing)')}` + ); + if (!dirOk) missingNs.add(ns.namespace); + for (const o of ns.ops) { + console.log(` ${o.ok ? C.green('✓') : C.red('✗')} ${o.kind} ${C.bold(o.op)} ${C.dim(`→ ${o.expects}`)}`); + } + } + } + console.log(`\n${runtimeOk ? C.green('✓') : C.dim('•')} ${runtimeOk ? 'mounted' : 'not found (mount it once at the app root — advisory)'}`); + + if (failed) { + console.log(C.red('\n✗ Unsatisfied prerequisites.')); + if (missingNs.size) { + const names = [...missingNs].join(','); + console.log( + `\nGenerate the missing SDK(s), then re-run this check:\n ${C.bold(`cnc codegen --api-names ${names} --react-query --orm -o src/generated`)}\n` + + C.dim(' (or per-endpoint: cnc codegen --endpoint https://./graphql --react-query --orm -o src/generated/)') + ); + } else { + console.log( + `\nThe SDK exists but is missing operations above — the host backend likely hasn't deployed them, or the SDK is stale. Re-generate and check drift:\n ${C.bold('cnc codegen --api-names --react-query --orm -o src/generated')}\n ${C.bold('cnc codegen … --dry-run')} ${C.dim('# drift check')}` + ); + } + process.exit(1); + } + + console.log(C.green('\n✓ All data-block prerequisites satisfied.')); + process.exit(0); +} + +main(); diff --git a/CLAUDE.md b/CLAUDE.md index 0bd7296..56af870 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ A collection of skills for AI coding agents working with Constructive tooling. S | **constructive-sdk-uploads** | File uploads — presigned URL flow, bucket provisioning, upload-client, deduplication | | **constructive-starter-kits** | Project scaffolding — `pgpm init` templates, Next.js app boilerplate, custom template authoring | | **constructive-frontend** | UI components (50+ on Base UI + Tailwind v4), CRUD Stack cards, dynamic `_meta` forms | +| **constructive-blocks** | Copy-in UI blocks (shadcn registry, `@constructive/`) that bind to the host's per-app generated GraphQL SDK — install/wire/author flow, `blocks-runtime`, `requires.json` manifests, bundled `check-sdk.mjs` preflight | | **constructive-testing** | All test frameworks — pgsql-test, drizzle-orm-test, supabase-test, Drizzle ORM patterns, pgsql-parser | | **constructive-sdk-events** | Events & achievements — EventTracker blueprint node, blueprint achievements[], invite virality, achievement credit rewards | | **constructive-jobs** | Background jobs — JobTrigger blueprint node, payload strategies, Knative worker pipeline, scheduled jobs | diff --git a/README.md b/README.md index 6feff9a..ba6d95f 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ npx skills add constructive-io/constructive-skills --skill constructive-safegres | Skill | Description | |-------|-------------| | `constructive-ui` | Build UIs with @constructive-io/ui — 50+ components, cva variants, Tailwind CSS v4 theming, forms, overlays, layout, animations, command palette, Stack navigation, and advanced inputs. Includes 18 reference files covering foundations, theming, registry, motion, forms, overlays, layout, sidebar, data display, advanced inputs, combobox, command palette, card patterns, Stack navigation, sheet stacking, and more. | +| `constructive-blocks` | Copy-in UI blocks distributed via a shadcn registry (`@constructive/`) that bind to the host app's per-application generated GraphQL SDK. Install/wire/author flow, `blocks-runtime`, `requires.json` manifests, and a bundled `check-sdk.mjs` preflight that proves the host SDK exports every operation a block needs. | | `constructive-crud-stack` | Build CRUD actions as Stack cards (iOS-style slide-in panels) for any Constructive CRM | | `constructive-meta-forms` | Dynamic CRUD forms using the \_meta GraphQL endpoint — zero static field configuration | | `constructive-deployment` | Deploy the Constructive platform locally and to production (Docker Compose, pgpm, CLI) | From 11748dea92cda20085b9c513620051107cbe320f Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Sat, 30 May 2026 11:45:58 +0700 Subject: [PATCH 02/15] fix(constructive-sdk): correct stale secureTableProvision, SDK import, and signUp shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real live-run blockers a builder hit, corrected against the generated SDK types and the working provisioning builder: - secureTableProvision input is the Blueprint shape (nodes/fields/grants/ policies discriminated by $type), not the flat nodeType/grantRoles/ policyType/policyData shape. fields[] is the one Record[] array member, so it assigns uncast (matching the live builder); nodes/ grants/policies stay cast as they are scalar object members. - SDK import is @constructive-io/sdk (the @constructive-db/sdk package does not exist). - signUp selects fields off `result` (a SignUpRecord), never top-level on SignUpPayload: the { ok, errors } form is replaced with { result { id } }, and the device example selects outDeviceToken under result and reads it via result.signUp.result (it was selected at the payload root, which does not compile; outDeviceToken is devices_module-gated, mirroring the sign-in example above it). Also reconcile constructive-starter-kits: document the SDK output path (src/graphql/schema-builder-sdk/api) vs the blocks @/generated/* convention so an agent following both is not contradicted, and add brief block/React test guidance (replace the data layer via BlocksRuntime override or mock the @/generated module — never the network — since generated hooks bind a module-level singleton). Co-Authored-By: Claude Opus 4.8 --- .agents/skills/constructive-sdk/SKILL.md | 64 ++++++++++++------- .../constructive-sdk/references/auth-flow.md | 12 ++-- .../references/provisioning.md | 6 +- .../references/nextjs-app.md | 58 +++++++++++++++++ 4 files changed, 111 insertions(+), 29 deletions(-) diff --git a/.agents/skills/constructive-sdk/SKILL.md b/.agents/skills/constructive-sdk/SKILL.md index d0aab44..c08a03a 100644 --- a/.agents/skills/constructive-sdk/SKILL.md +++ b/.agents/skills/constructive-sdk/SKILL.md @@ -29,12 +29,13 @@ Related skills: See `references/auth-flow.md` for full details (endpoints, JWT structure, bootstrap user). ```typescript -import { createClient as createAuthClient } from '@constructive-db/sdk/auth'; +import { createClient as createAuthClient } from '@constructive-io/sdk/auth'; const authDb = createAuthClient({ endpoint: 'http://auth.localhost:3000/graphql' }); -// Sign up -await authDb.mutation.signUp({ input: { email, password } }, { select: { ok: true, errors: true } }).execute(); +// Sign up — returns a SignUpPayload whose `result` is a SignUpRecord (id, userId, accessToken, …). +// There is NO `ok`/`errors` selection; select fields off `result`. +await authDb.mutation.signUp({ input: { email, password } }, { select: { result: { select: { id: true } } } }).execute(); // Sign in — returns accessToken (NOT jwtToken, see workarounds/known-issues SDK-002) const result = await authDb.mutation.signIn( @@ -53,7 +54,7 @@ See `references/provisioning.md` for end-to-end flow with per-DB auth. Always use `modules: ['all']` and `bootstrapUser: true`: ```typescript -import { createClient as createPublicClient } from '@constructive-db/sdk/public'; +import { createClient as createPublicClient } from '@constructive-io/sdk/public'; const publicDb = createPublicClient({ endpoint: 'http://api.localhost:3000/graphql', @@ -83,28 +84,43 @@ After provisioning, apply workarounds: `workarounds/fix-membership-defaults` and ### Create secure table + fields + grants + policy -```ts -const grant_privileges = [ - ['select', '*'], ['insert', '*'], ['update', '*'], ['delete', '*'], -] as unknown as Record; - -const policy_data: Record = { - entity_field: 'entity_id', - membership_type: 2, -}; +`SecureTableProvisionInput` is the **Blueprint shape**: four independent, optional arrays — +`nodes[]` (Data* field modules), `fields[]` (explicit columns), `grants[]` (per-role privilege +targeting), and `policies[]` (Authz* RLS policies). Each entry is discriminated by a `$type`. +This is NOT the flat `nodeType`/`grantRoles`/`grantPrivileges`/`policyType`/`policyData` shape — +that is stale and no longer matches the live platform. +```ts const res = await db.secureTableProvision.create({ data: { databaseId: '', // schemaId is optional -- defaults to the database's app_public schema tableName: 'projects', - nodeType: 'DataEntityMembership', useRls: true, - grantRoles: ['authenticated'], - grantPrivileges: grant_privileges, - policyType: 'AuthzEntityMembership', - policyPermissive: true, - policyData: policy_data, + // nodes[]: one entry per Data* field module (compose multiple in one call) + nodes: [ + { $type: 'DataEntityMembership' }, + { $type: 'DataTimestamps', data: { include_id: false } }, + ] as unknown as Record, + // fields[]: explicit columns (the generated type is Record[] — an + // array — so the literal assigns directly; no cast needed, unlike nodes/grants/policies) + fields: [ + { name: 'title', type: 'text', is_required: true }, + { name: 'body', type: 'text' }, + ], + // grants[]: each entry targets roles + privilege list ([privilege, columns] tuples) + grants: [ + { roles: ['authenticated'], privileges: [['select', '*'], ['insert', '*'], ['update', '*'], ['delete', '*']] }, + ] as unknown as Record, + // policies[]: one entry per Authz* policy, discriminated by $type + policies: [ + { + $type: 'AuthzEntityMembership', + permissive: true, + privileges: ['select', 'insert', 'update', 'delete'], + data: { entity_field: 'entity_id', membership_type: 2 }, + }, + ] as unknown as Record, }, select: { id: true, tableId: true, outFields: true, tableName: true }, }).execute(); @@ -114,7 +130,7 @@ const table_id = provision.tableId; ``` What success looks like: -- `provision.outFields` is populated with created field IDs when `nodeType` is set +- `provision.outFields` is populated with created field IDs when `nodes`/`fields` are supplied - `metaschema_public.table_grant` rows exist (for the requested privileges + roles) - `metaschema_public.policy` rows exist (for the chosen Safegres policy) - RLS is enabled on the table when `useRls=true` @@ -129,13 +145,17 @@ What success looks like: ### Add more fields by composing +Target the same `tableId` again with another `nodes[]` entry (each module is a `$type` with +optional `data`). Multiple modules can also be passed in a single `nodes[]` array. + ```ts await db.secureTableProvision.create({ data: { databaseId: '', tableId: table_id, - nodeType: 'DataTimestamps', - nodeData: { include_id: false }, + nodes: [ + { $type: 'DataTimestamps', data: { include_id: false } }, + ] as unknown as Record, }, select: { id: true, outFields: true }, }).execute(); diff --git a/.agents/skills/constructive-sdk/references/auth-flow.md b/.agents/skills/constructive-sdk/references/auth-flow.md index babef24..2f9f142 100644 --- a/.agents/skills/constructive-sdk/references/auth-flow.md +++ b/.agents/skills/constructive-sdk/references/auth-flow.md @@ -14,7 +14,7 @@ const authDb = createAuthClient({ endpoint: 'http://auth.localhost:3000/graphql' await authDb.mutation.signUp( { input: { email, password } }, - { select: { ok: true, errors: true } } + { select: { result: { select: { id: true } } } } ).execute(); ``` @@ -110,13 +110,17 @@ localStorage.setItem('device_token', r.outDeviceToken); ### Sign up (first device auto-approved) ```typescript +// `deviceToken` is a SignUpInput field; everything you read back is selected off +// `result` (a SignUpRecord) — there is no top-level field on SignUpPayload. +// `outDeviceToken` is only present when `devices_module` is installed (see §intro). const result = await authDb.mutation.signUp( { input: { email, password, deviceToken: '' } }, - { select: { outDeviceToken: true, accessToken: true } } + { select: { result: { select: { accessToken: true, outDeviceToken: true } } } } ).execute(); -// First device is auto-approved even when require_device_approval is on -localStorage.setItem('device_token', result.signUp.outDeviceToken); +// First device is auto-approved even when require_device_approval is on — +// persist the returned device token for future logins. +localStorage.setItem('device_token', result.signUp.result.outDeviceToken); ``` See [`constructive-platform/references/device-settings.md`](../../constructive-platform/references/device-settings.md) for the full composition matrix of device settings. diff --git a/.agents/skills/constructive-sdk/references/provisioning.md b/.agents/skills/constructive-sdk/references/provisioning.md index 3f52107..58d2c4a 100644 --- a/.agents/skills/constructive-sdk/references/provisioning.md +++ b/.agents/skills/constructive-sdk/references/provisioning.md @@ -3,8 +3,8 @@ ## Client Setup ```typescript -import { createClient as createAuthClient } from '@constructive-db/sdk/auth'; -import { createClient as createPublicClient } from '@constructive-db/sdk/public'; +import { createClient as createAuthClient } from '@constructive-io/sdk/auth'; +import { createClient as createPublicClient } from '@constructive-io/sdk/public'; const authDb = createAuthClient({ endpoint: 'http://auth.localhost:3000/graphql' }); const publicDb = createPublicClient({ endpoint: 'http://api.localhost:3000/graphql' }); @@ -13,7 +13,7 @@ const publicDb = createPublicClient({ endpoint: 'http://api.localhost:3000/graph ## Step 1: Sign Up + Sign In ```typescript -await authDb.mutation.signUp({ input: { email, password } }, { select: { ok: true, errors: true } }).execute(); +await authDb.mutation.signUp({ input: { email, password } }, { select: { result: { select: { id: true } } } }).execute(); const signIn = await authDb.mutation.signIn( { input: { email, password } }, diff --git a/.agents/skills/constructive-starter-kits/references/nextjs-app.md b/.agents/skills/constructive-starter-kits/references/nextjs-app.md index 6abf0d1..22b82f3 100644 --- a/.agents/skills/constructive-starter-kits/references/nextjs-app.md +++ b/.agents/skills/constructive-starter-kits/references/nextjs-app.md @@ -232,6 +232,26 @@ pnpm codegen Config in `graphql-codegen.config.ts` points to `http://api.localhost:3000/graphql` by default. +#### SDK output path vs the Blocks `@/generated/*` convention + +This boilerplate generates its SDK into `src/graphql/schema-builder-sdk/api` and imports it +via the `@sdk`/`src/graphql/...` paths. The **`constructive-blocks`** skill, by contrast, requires +data blocks to import generated hooks from a fixed convention path, `@/generated/` +(e.g. `@/generated/auth`, `@/generated/admin`), aliased in `tsconfig.json`. **These two conventions +are complementary, not contradictory** — the boilerplate's path is the app's own hand-written data +layer; the `@/generated/*` alias is the *stable name a copy-in block compiles against*. If you install +Constructive Blocks into this boilerplate, do **not** rename the existing SDK directory. Instead, satisfy +the block contract additively in one of two ways: + +- **Alias (preferred, no second codegen):** point `@/generated/*` at the SDK you already generate, e.g. + `"paths": { "@/generated/*": ["./src/graphql/schema-builder-sdk/*"] }` (must expose per-namespace + `auth`/`admin` entrypoints with `--react-query --orm` output). +- **Separate output:** run a second `cnc codegen --api-names auth,admin --react-query --orm -o src/generated` + so blocks resolve `@/generated/` and the app keeps using `src/graphql/...` for its own queries. + +Run the block preflight (`check-sdk.mjs` from the `constructive-blocks` skill) after either, and see that +skill for the full host-wiring contract (``, the `NEXT_PUBLIC__GRAPHQL_ENDPOINT` env vars). + ## Features - **Authentication** — Login, register, logout, password reset, email verification @@ -242,6 +262,44 @@ Config in `graphql-codegen.config.ts` points to `http://api.localhost:3000/graph - **App Shell** — Sidebar navigation, theme switching, responsive layout - **Permissions** — Role-based access control for org features +## Testing components and blocks + +There is no dedicated Constructive frontend-test skill; this is the minimum that keeps React component +and Block tests green. The hard constraint: generated SDK hooks (`useMutation`, `useQuery`) +are bound to a **module-level client singleton** — they make no network call you can intercept by passing +a prop, and there is no client argument. So a test must replace the data layer, not the network. + +Pick one of three approaches (in order of preference): + +1. **Use the block's override seam (no mocking).** Every data block accepts an `onSubmit` (mutations) / + `adapter` (queries) prop that *fully replaces* the generated hook. This is the cleanest unit test — + render the block, pass a fake resolver, assert on form state / success callback: + + ```tsx + render( ({ accessToken: 'tok' })} onSuccess={onSuccess} />); + await userEvent.click(screen.getByRole('button', { name: /sign in/i })); + expect(onSuccess).toHaveBeenCalled(); + ``` + +2. **Mock the `@/generated/` module.** When you must exercise the default hook path, mock the + generated module so the singleton is never touched (Jest example; the Vitest equivalent is `vi.mock`): + + ```tsx + jest.mock('@/generated/auth', () => ({ + useSignInMutation: () => ({ mutateAsync: jest.fn().mockResolvedValue({ signIn: { result: { accessToken: 'tok' } } }), isPending: false }), + configure: jest.fn(), + })); + ``` + +3. **Mount `` (integration).** For a test that needs the real hook + `QueryClient`, wrap + the tree in ` null}>` and point + `NEXT_PUBLIC_AUTH_GRAPHQL_ENDPOINT` at a mock server (e.g. MSW). Reserve this for a few integration + tests — it requires a fetch mock and is slower than (1)/(2). + +In all cases wrap rendered components that read React Query state in a `QueryClientProvider` (or +`BlocksRuntime`, which provides one). Never import a real generated module *and* leave it unmocked in a +unit test — it will try to read a `NEXT_PUBLIC_*` endpoint that isn't set and fail opaquely. + ## Troubleshooting - **GraphQL errors on startup**: Ensure the Constructive backend is running and the endpoint in `.env.local` is correct From 6559571bd9d8424140d3360ce37b94c1a7d0a87b Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Sat, 30 May 2026 12:45:40 +0700 Subject: [PATCH 03/15] fix(constructive-blocks): make check-sdk.mjs work on standard Next.js apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled preflight failed on every standard Next.js app, for two reasons: - JSONC comment stripping was a naive regex that also devoured `/* … */` sequences occurring INSIDE quoted strings. A tsconfig path glob like `"@/*": ["./src/*"]` contains a `/*`…`*/` span across string literals, so the regex corrupted valid JSON and `JSON.parse` threw ("Bad control character…"). Replace it with a single-pass, string-aware scanner (`stripJsonComments` + `stripTrailingCommas`) that only strips comments and trailing commas OUTSIDE string literals and honours `\"` / `\\` escapes. This also hardens the `extends` path, which routes through the same reader. - shadcn writes block manifests to `/src/.constructive/blocks` when the blocks registry target resolves under `src/` (the usual Next.js layout), but the script only scanned the project-root `.constructive`, so it silently found nothing and false-passed. Discover manifests under BOTH `/.constructive/blocks` and `/src/.constructive/blocks`, add a `--manifests-dir`/`-m` override that short-circuits discovery, and de-dupe by manifest file name (root wins) so the same block present in both locations is reported once while distinct blocks are never merged. Update SKILL.md and references/manifest-and-checks.md to document the `src/` manifest location, the dual-location scan, the `--manifests-dir` override, and the string-aware stripping. Regenerate the distribution zip so the packaged artifact carries the fixed script and docs. Exit-code contract is preserved (0 satisfied/nothing-to-check, 1 missing op, 2 cannot-run). Verified against a real Next.js tsconfig fixture and the full contract matrix. Co-Authored-By: Claude Opus 4.8 --- .agents/skills/constructive-blocks.zip | Bin 19062 -> 20652 bytes .agents/skills/constructive-blocks/SKILL.md | 4 +- .../references/manifest-and-checks.md | 7 +- .../constructive-blocks/scripts/check-sdk.mjs | 179 +++++++++++++++--- 4 files changed, 163 insertions(+), 27 deletions(-) diff --git a/.agents/skills/constructive-blocks.zip b/.agents/skills/constructive-blocks.zip index 3949e27d85d94e4f285005ea51cefb867e6c56e4..d13f0b0412736c036d24b6bfeeee7b26b5c0c529 100644 GIT binary patch delta 16408 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 diff --git a/.agents/skills/constructive-blocks/SKILL.md b/.agents/skills/constructive-blocks/SKILL.md index 4162d46..f7146cd 100644 --- a/.agents/skills/constructive-blocks/SKILL.md +++ b/.agents/skills/constructive-blocks/SKILL.md @@ -82,7 +82,7 @@ npx shadcn add @constructive/auth-sign-in-card node path/to/skill/scripts/check-sdk.mjs auth-sign-in-card ``` -Step 1 also writes the block's manifest to `.constructive/blocks/.requires.json`. **Always run step 2 after installing a data block** — it is the §9 enforcement gate. A green check means the block will compile against real operations; a red check names the exact missing op *before* you waste a build. +Step 1 also writes the block's manifest to `.constructive/blocks/.requires.json` — relative to wherever the blocks registry target lives, so on a standard Next.js `src/` layout it lands at **`src/.constructive/blocks/.requires.json`**. `check-sdk.mjs` auto-discovers both the project-root and `src/` locations (use `--manifests-dir DIR` for anything non-standard). **Always run step 2 after installing a data block** — it is the §9 enforcement gate. A green check means the block will compile against real operations; a red check names the exact missing op *before* you waste a build. Then render it: @@ -98,7 +98,7 @@ import { SignInCard } from '@/blocks/auth/sign-in-card/sign-in-card'; ## The `requires.json` manifest -Every **data block** ships a co-located, machine-readable manifest declaring exactly what the host SDK must expose. It lands at `.constructive/blocks/.requires.json` on install: +Every **data block** ships a co-located, machine-readable manifest declaring exactly what the host SDK must expose. It lands at `.constructive/blocks/.requires.json` on install — under `src/` when the blocks target lives there (`src/.constructive/blocks/.requires.json`), which is the usual Next.js layout: ```json { "namespace": "auth", "mutations": ["signIn"], "queries": [], "models": [] } diff --git a/.agents/skills/constructive-blocks/references/manifest-and-checks.md b/.agents/skills/constructive-blocks/references/manifest-and-checks.md index 756f634..2e6c4bc 100644 --- a/.agents/skills/constructive-blocks/references/manifest-and-checks.md +++ b/.agents/skills/constructive-blocks/references/manifest-and-checks.md @@ -10,6 +10,8 @@ Every **data block** (any block importing a generated hook) ships a co-located, .constructive/blocks/.requires.json ``` +This path is **relative to the blocks registry target**. shadcn resolves the target against the host's aliases, so on a standard Next.js `src/` layout the manifest actually lands at `src/.constructive/blocks/.requires.json`; only when the blocks target sits at the project root does it land at the root `.constructive/blocks/`. `check-sdk.mjs` scans **both** locations (and accepts `--manifests-dir` to override), so a manifest under `src/` is never silently missed. + **Presentational blocks ship none** (no generated-hook import → nothing to verify). The registry item's `docs` field always carries a *human-readable* summary of the same prerequisites; the JSON manifest is the machine-checkable twin that `check-sdk.mjs` reads. ## Schema — single namespace (canonical) @@ -76,13 +78,16 @@ node scripts/check-sdk.mjs # check every installed manifes node scripts/check-sdk.mjs auth-sign-in-card # one block by name… node scripts/check-sdk.mjs ./path/to.requires.json # …or by manifest path node scripts/check-sdk.mjs --project /path/app # check a different project root +node scripts/check-sdk.mjs --manifests-dir DIR # point at a non-standard manifests dir node scripts/check-sdk.mjs --json # machine-readable report on stdout node scripts/check-sdk.mjs --help ``` +Manifests are auto-discovered under **both** `/.constructive/blocks` and `/src/.constructive/blocks` (a block name passed as `[block]` is resolved against both, too). `--manifests-dir` overrides discovery with an explicit directory. + ### What it verifies -1. The `@/generated/*` alias exists in the host `tsconfig.json` (follows one `extends` level; tolerant of JSONC comments + trailing commas). +1. The `@/generated/*` alias exists in the host `tsconfig.json` (follows one `extends` level; tolerant of JSONC comments + trailing commas — comment/comma stripping is **string-aware**, so path globs like `"@/*": ["./src/*"]` are never mis-parsed as block comments). 2. The generated dir for each block's namespace exists, resolved **via the alias** (tries `@/generated/`, `@/generated//*`, then `@/generated/*` — never a hardcoded path). 3. Every manifest `mutation`/`query`/`model` maps to a real export of that SDK (it scans every SDK source file, so a leaf `export function useXMutation` is found regardless of barrel re-exports). 4. *(Advisory)* whether `` appears mounted somewhere in the host source. diff --git a/.agents/skills/constructive-blocks/scripts/check-sdk.mjs b/.agents/skills/constructive-blocks/scripts/check-sdk.mjs index 1abddad..786157b 100755 --- a/.agents/skills/constructive-blocks/scripts/check-sdk.mjs +++ b/.agents/skills/constructive-blocks/scripts/check-sdk.mjs @@ -43,10 +43,11 @@ const SRC_EXT = /\.(?:[cm]?tsx?|d\.ts)$/; // args // --------------------------------------------------------------------------- function parseArgs(argv) { - const opts = { project: process.cwd(), only: null, json: false }; + const opts = { project: process.cwd(), only: null, json: false, manifestsDir: null }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--project' || a === '-p') opts.project = resolve(argv[++i] ?? '.'); + else if (a === '--manifests-dir' || a === '-m') opts.manifestsDir = argv[++i] ?? null; else if (a === '--json') opts.json = true; else if (a === '--help' || a === '-h') opts.help = true; else if (!a.startsWith('-')) opts.only = a; // block name or manifest path @@ -57,12 +58,103 @@ function parseArgs(argv) { // --------------------------------------------------------------------------- // tsconfig: read compilerOptions.paths (+ baseUrl), following one `extends`. // JSONC-tolerant (tsconfig allows comments + trailing commas). +// +// Comment stripping is STRING-AWARE: a single-pass scanner that ignores `//` +// and `/* */` sequences occurring inside quoted strings. A naive regex would +// corrupt valid JSON like the path glob `"@/*": ["./src/*/index"]`, whose +// `/*` … `*/` substrings (spread across string literals) look like a block +// comment and get devoured. Escapes (`\"`, `\\`) inside strings are honoured. // --------------------------------------------------------------------------- +function stripJsonComments(txt) { + let out = ''; + let i = 0; + const n = txt.length; + let inStr = false; // inside a double-quoted string literal + while (i < n) { + const c = txt[i]; + const next = i + 1 < n ? txt[i + 1] : ''; + if (inStr) { + out += c; + if (c === '\\') { + // copy the escaped char verbatim (handles \" and \\) + if (i + 1 < n) out += txt[i + 1]; + i += 2; + continue; + } + if (c === '"') inStr = false; + i += 1; + continue; + } + if (c === '"') { + inStr = true; + out += c; + i += 1; + continue; + } + if (c === '/' && next === '/') { + // line comment: skip to (but keep) the newline + i += 2; + while (i < n && txt[i] !== '\n' && txt[i] !== '\r') i += 1; + continue; + } + if (c === '/' && next === '*') { + // block comment: skip through the closing */ + i += 2; + while (i < n && !(txt[i] === '*' && i + 1 < n && txt[i + 1] === '/')) i += 1; + i += 2; // consume the closing */ + continue; + } + out += c; + i += 1; + } + return out; +} + +// Strip trailing commas (`,]` / `,}`) that sit OUTSIDE string literals, so a +// comma inside a string value is never touched. Runs after comment stripping. +function stripTrailingCommas(txt) { + let out = ''; + let i = 0; + const n = txt.length; + let inStr = false; + while (i < n) { + const c = txt[i]; + if (inStr) { + out += c; + if (c === '\\') { + if (i + 1 < n) out += txt[i + 1]; + i += 2; + continue; + } + if (c === '"') inStr = false; + i += 1; + continue; + } + if (c === '"') { + inStr = true; + out += c; + i += 1; + continue; + } + if (c === ',') { + // look ahead past whitespace for a closing } or ] + let j = i + 1; + while (j < n && /\s/.test(txt[j])) j += 1; + if (j < n && (txt[j] === '}' || txt[j] === ']')) { + i += 1; // drop the comma + continue; + } + } + out += c; + i += 1; + } + return out; +} + function readJsonc(file) { let txt = readFileSync(file, 'utf-8'); - txt = txt.replace(/\/\*[\s\S]*?\*\//g, ''); // block comments - txt = txt.replace(/(^|[^:])\/\/.*$/gm, '$1'); // line comments (not URLs) - txt = txt.replace(/,(\s*[}\]])/g, '$1'); // trailing commas + txt = stripJsonComments(txt); // string-aware: comments only outside strings + txt = stripTrailingCommas(txt); // string-aware trailing-comma removal return JSON.parse(txt); } @@ -189,25 +281,58 @@ const queryHook = (op) => `use${pascal(op)}Query`; // A manifest is either a single { namespace, mutations, queries, models } // object, or { requires: [ {…}, … ] } for cross-namespace blocks. // --------------------------------------------------------------------------- -function manifestDir(projectRoot) { - return join(projectRoot, '.constructive', 'blocks'); +// Candidate manifest dirs, in priority order. shadcn writes block manifests to +// `/src/.constructive/blocks` whenever the blocks registry target sits +// under src/ (the common Next.js layout) — the project-root `.constructive` is +// only used when the target is at the root. We scan BOTH so manifests are never +// silently missed (which would false-pass the check). An explicit +// --manifests-dir override short-circuits discovery. +function manifestDirs(projectRoot, override) { + if (override) { + const dir = isAbsolute(override) ? override : resolve(projectRoot, override); + return [dir]; + } + return [join(projectRoot, '.constructive', 'blocks'), join(projectRoot, 'src', '.constructive', 'blocks')]; +} + +// Primary dir — used in messages (the location the operator should expect). +function manifestDir(projectRoot, override) { + return manifestDirs(projectRoot, override)[0]; } -function findManifests(projectRoot, only) { +function findManifests(projectRoot, only, override) { + const dirs = manifestDirs(projectRoot, override); if (only) { - // explicit path, or a block name resolved under .constructive/blocks/ + // explicit path, or a block name resolved under any candidate dir const direct = isAbsolute(only) ? only : resolve(projectRoot, only); if (existsSync(direct) && statSync(direct).isFile()) return [direct]; - const named = join(manifestDir(projectRoot), only.endsWith('.requires.json') ? only : `${only}.requires.json`); - if (existsSync(named)) return [named]; - fail(2, `No manifest found for "${only}" (looked for ${named}).`); + const fileName = only.endsWith('.requires.json') ? only : `${only}.requires.json`; + const tried = []; + for (const dir of dirs) { + const named = join(dir, fileName); + tried.push(named); + if (existsSync(named)) return [named]; + } + fail(2, `No manifest found for "${only}" (looked for ${tried.join(', ')}).`); } - const dir = manifestDir(projectRoot); - if (!existsSync(dir)) return []; - return readdirSync(dir) - .filter((f) => f.endsWith('.requires.json')) - .map((f) => join(dir, f)) - .sort(); + // De-dupe by manifest file name. Dirs are scanned in priority order (root + // before src/), so the first occurrence of a given `.requires.json` + // wins — covering both two candidate dirs that resolve to the same place AND + // the same block accidentally present in both locations (otherwise it would + // be reported twice). Distinct blocks keep distinct file names, so this never + // merges different manifests. + const seen = new Set(); + const found = []; + for (const dir of dirs) { + if (!existsSync(dir)) continue; + for (const f of readdirSync(dir)) { + if (!f.endsWith('.requires.json')) continue; + if (seen.has(f)) continue; + seen.add(f); + found.push(join(dir, f)); + } + } + return found.sort(); } function normalizeRequirements(raw) { @@ -251,12 +376,17 @@ function fail(code, msg) { const HELP = `check-sdk.mjs — verify the host SDK satisfies installed Constructive data blocks. Usage: - node check-sdk.mjs [block] [--project DIR] [--json] + node check-sdk.mjs [block] [--project DIR] [--manifests-dir DIR] [--json] + + [block] a block name (auth-sign-in-card) or manifest path; omit to check all + --project DIR project root to check (default: cwd) + --manifests-dir DIR explicit .constructive/blocks dir (overrides auto-discovery) + --json emit a machine-readable report + --help show this help - [block] a block name (auth-sign-in-card) or manifest path; omit to check all - --project DIR project root to check (default: cwd) - --json emit a machine-readable report - --help show this help`; +Manifests are auto-discovered under both /.constructive/blocks and +/src/.constructive/blocks (shadcn writes to the latter when the blocks +target lives under src/). Use --manifests-dir to point at a non-standard location.`; // --------------------------------------------------------------------------- // main @@ -271,9 +401,10 @@ function main() { const ts = loadTsconfig(opts.project); if (!ts) fail(2, `No tsconfig.json in ${opts.project}. Run from the host app root or pass --project.`); - const manifests = findManifests(opts.project, opts.only); + const manifests = findManifests(opts.project, opts.only, opts.manifestsDir); if (!manifests.length) { - console.log(`${C.dim('•')} No data-block manifests in ${manifestDir(opts.project)} — nothing to check.`); + const where = opts.manifestsDir ? manifestDir(opts.project, opts.manifestsDir) : manifestDirs(opts.project).join(' or '); + console.log(`${C.dim('•')} No data-block manifests in ${where} — nothing to check.`); process.exit(0); } From 71de93b7f2fc06403db2e440ef4a435e0cc43c06 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Sat, 30 May 2026 15:06:10 +0700 Subject: [PATCH 04/15] fix(skills): replace modules:['all'] with explicit module lists in provisioning docs 'all' is not a provisioning sentinel. metaschema_generators.provision_database_modules matches explicit module names via 58 `ANY(v_modules)` branches; there is no 'all' expansion in the SQL proc, trigger, SDK, or CLI. modules:['all'] therefore installs nothing, after which bootstrapUser fails (TARGET_USERS_NOT_FOUND), per-DB auth is empty, and app-public queries hit RLS denials. Replace every recommended ['all'] with explicit module lists across constructive-sdk (SKILL + references/provisioning.md), constructive-sdk-services, constructive-sdk-database, constructive-features, and constructive-platform (module-presets, blueprint-definition-format). The basic-auth default is the verified auth:email 12-module list; b2b/full point at their preset module lists. Also add the missing bootstrapUser:true to the constructive-sdk-services provisioning example so the recommended path actually creates the admin user. Correct the stale "via ['all'] sentinel" claims for devices_module (in b2b+full) and realtime_module (in no shipped preset). Names and lists verified against constructive/packages/node-type-registry/src/module-presets. Co-Authored-By: Claude Opus 4.8 --- .agents/skills/constructive-features/SKILL.md | 2 +- .../references/blueprint-definition-format.md | 4 +- .../references/module-presets.md | 6 +-- .../skills/constructive-sdk-database/SKILL.md | 2 +- .../skills/constructive-sdk-services/SKILL.md | 10 ++++- .agents/skills/constructive-sdk/SKILL.md | 14 +++++-- .../references/provisioning.md | 37 +++++++++++++++++-- 7 files changed, 60 insertions(+), 15 deletions(-) diff --git a/.agents/skills/constructive-features/SKILL.md b/.agents/skills/constructive-features/SKILL.md index 7dd11fc..2e5585b 100644 --- a/.agents/skills/constructive-features/SKILL.md +++ b/.agents/skills/constructive-features/SKILL.md @@ -229,7 +229,7 @@ When a feature is gated by a module, installing / omitting the module from a pre | `auth:passkey` | `auth:email` + WebAuthn | | `auth:hardened` | rate limits + SSO + passkeys + SMS + magic links | | `b2b` | `auth:hardened` + orgs + invites + permissions + levels + profiles + hierarchy | -| `full` | `['all']` — everything | +| `full` | every standard module (explicit list — there is no `['all']` sentinel) | See [`constructive/references/module-presets.md`](../constructive-platform/references/module-presets.md) for the full catalog, shapes, and ORM usage. diff --git a/.agents/skills/constructive-platform/references/blueprint-definition-format.md b/.agents/skills/constructive-platform/references/blueprint-definition-format.md index 3ef0e97..4771960 100644 --- a/.agents/skills/constructive-platform/references/blueprint-definition-format.md +++ b/.agents/skills/constructive-platform/references/blueprint-definition-format.md @@ -285,7 +285,7 @@ All 28 node types from the `node_type_registry`: | `LimitCounter` | Attaches increment/decrement triggers to track metered usage against configurable maximums. On INSERT the named limit is incremented; on DELETE it is decremented. | `limit_name` (required — must match a `limit_defaults` entry, e.g. `'projects'`, `'members'`), `scope` (default `'app'` — `'app'` for membership_type=1 or `'org'` for membership_type=2), `actor_field` (default `'owner_id'` — column-ref, field on target table holding the actor/entity ID), `events` (default `['INSERT','DELETE']` — which DML events to attach triggers for) | | `LimitFeatureFlag` | Gates a table behind a feature flag backed by cap tables. Attaches a BEFORE INSERT trigger that checks `resolve_cap(feature_name) > 0`. Features are modeled as caps with `max=0` (disabled) or `max=1` (enabled) in `limit_caps_defaults`. | `feature_name` (required — cap name, must match a `limit_caps_defaults` entry), `scope` (default `'app'` — `'app'` or `'org'`), `entity_field` (default `'entity_id'` — column-ref, used for org-scope only to resolve per-entity cap overrides) | -**Prerequisites:** Both require `limits_module` to be provisioned for the target scope. Enable via `modules:['all']` or the `b2b`/`full` presets, or via `has_limits: true` on entity types. +**Prerequisites:** Both require `limits_module` to be provisioned for the target scope. Add `'limits_module:app'` (and/or `'limits_module:org'`) to your explicit module list — it ships in the `auth:email`, `b2b`, and `full` preset lists — or set `has_limits: true` on entity types. (Do not use `modules:['all']`; it is not a sentinel and installs nothing.) **Example — limit projects per org:** ```json @@ -325,7 +325,7 @@ Seed `limit_caps_defaults` with `{ name: 'advanced_reporting', max: 1 }` to enab |-----------|---------|----------------| | `DataRealtime` | Creates a per-table subscriber table in `subscriptions_public` with RLS policies derived from source table SELECT policies. Attaches statement-level `emit_change()` triggers to track changes. Requires `realtime_module`. | `operations` (default `['INSERT', 'UPDATE', 'DELETE']` — which DML operations to track), `subscriber_table_name` (default `'{source_table}_subscriber'`) | -**Prerequisites:** Requires `realtime_module` to be provisioned. Enable via `modules:['all']` or the `full` preset, or add `'realtime_module'` to your module list. +**Prerequisites:** Requires `realtime_module` to be provisioned. Add `'realtime_module'` to your explicit module list. (No shipped preset includes it by default, and there is no `modules:['all']` sentinel — it installs nothing.) **Example — enable realtime on a messages table:** ```json diff --git a/.agents/skills/constructive-platform/references/module-presets.md b/.agents/skills/constructive-platform/references/module-presets.md index 82c69c1..e581245 100644 --- a/.agents/skills/constructive-platform/references/module-presets.md +++ b/.agents/skills/constructive-platform/references/module-presets.md @@ -36,7 +36,7 @@ interface ModulePreset { | `auth:passkey` | auth:email + WebAuthn | Phishing-resistant auth | | `auth:hardened` | rate limits + SSO + passkeys + SMS + magic links | Production consumer auth | | `b2b` | auth:hardened + orgs + invites + permissions + levels + profiles + hierarchy | Multi-tenant SaaS | -| `full` | `['all']` sentinel | Reference / demo DBs / greenfield | +| `full` | every standard module (explicit list) | Reference / demo DBs / greenfield | ## Usage @@ -77,7 +77,7 @@ Provisions shared infrastructure for realtime subscriptions: - **Partitioned `change_log` table** — durable, time-partitioned event stream for change tracking. Uses PostgreSQL native range partitioning with automatic partition lifecycle management (creation, rotation, cleanup) - **`emit_change()` trigger function** — called by statement-level triggers on source tables to record changes and emit NOTIFY signals -**Included in:** `full` preset (via `['all']` sentinel). Not included in other presets by default — add `'realtime_module'` to your module list to enable. +**Included in:** none of the shipped presets by default — add `'realtime_module'` to your explicit module list to enable. (There is no `['all']` sentinel; even `full` is an explicit list and does not currently include this module.) **Runtime toggle:** `database_settings.enable_realtime` and `api_settings.enable_realtime` control whether the server activates realtime processing. API setting takes precedence over database setting. @@ -91,7 +91,7 @@ Provisions device tracking, trusted device MFA bypass, and device approval gate: - **`auth_user_devices` table** — per-user device records (token hash, IP, user agent, trust/approval status) - **`approve_device` procedure** — validates email approval tokens for the device approval flow -**Included in:** `full` preset (via `['all']` sentinel). Not included in other presets by default — add `'devices_module'` to your module list to enable. +**Included in:** the `b2b` and `full` preset module lists. Not included in other presets by default — add `'devices_module'` to your explicit module list to enable. (There is no `['all']` sentinel.) **Settings toggles:** All features are off by default (`enable_device_tracking = true` enables passive tracking only). Enable `enable_trusted_devices` for MFA bypass, `require_device_approval` for email approval gate, `require_mfa_new_device` to force MFA on new devices. diff --git a/.agents/skills/constructive-sdk-database/SKILL.md b/.agents/skills/constructive-sdk-database/SKILL.md index 6688d62..9f83996 100644 --- a/.agents/skills/constructive-sdk-database/SKILL.md +++ b/.agents/skills/constructive-sdk-database/SKILL.md @@ -145,7 +145,7 @@ When provisioning, you can specify which modules to install: | `profiles_module` | User profiles | | `hierarchy_module` | Org hierarchy | -Use `['all']` to install all default modules. +Pass an explicit list of these module names — there is **no `['all']` sentinel** (it matches zero branches in `provision_database_modules` and installs nothing). For a basic auth app use the `auth:email` list; for everything use the `full` preset's list. See `constructive-sdk`'s `references/provisioning.md` and the `constructive-platform` `module-presets.md` catalog. ## Querying Databases diff --git a/.agents/skills/constructive-sdk-services/SKILL.md b/.agents/skills/constructive-sdk-services/SKILL.md index 229cf65..0043e3d 100644 --- a/.agents/skills/constructive-sdk-services/SKILL.md +++ b/.agents/skills/constructive-sdk-services/SKILL.md @@ -347,7 +347,15 @@ const result = await db.databaseProvisionModule.create({ ownerId: userId, subdomain: 'my-app', domain: 'constructive.io', - modules: ['all'], + // Explicit module list — never ['all'] (not a sentinel; installs nothing). + // auth:email preset; see constructive-sdk references/provisioning.md. + modules: [ + 'users_module', 'membership_types_module', + 'permissions_module:app', 'limits_module:app', 'levels_module:app', + 'memberships_module:app', 'sessions_module', 'user_state_module', + 'config_secrets_user_module', 'emails_module', 'rls_module', 'user_auth_module', + ], + bootstrapUser: true, // creates the admin user row; without it per-DB auth is empty options: { site: { title: 'My Application', diff --git a/.agents/skills/constructive-sdk/SKILL.md b/.agents/skills/constructive-sdk/SKILL.md index c08a03a..833d605 100644 --- a/.agents/skills/constructive-sdk/SKILL.md +++ b/.agents/skills/constructive-sdk/SKILL.md @@ -51,7 +51,7 @@ const { accessToken, userId } = result.signIn.result; See `references/provisioning.md` for end-to-end flow with per-DB auth. -Always use `modules: ['all']` and `bootstrapUser: true`: +Pass an **explicit module list** (never `['all']` — it is not a sentinel and silently installs nothing; see `references/provisioning.md`) and `bootstrapUser: true`. The `auth:email` list below is the verified default for a basic auth app: ```typescript import { createClient as createPublicClient } from '@constructive-io/sdk/public'; @@ -61,10 +61,18 @@ const publicDb = createPublicClient({ headers: { Authorization: `Bearer ${accessToken}` }, }); +// auth:email preset modules. Source: node-type-registry/src/module-presets/auth-email.ts +const modules = [ + 'users_module', 'membership_types_module', + 'permissions_module:app', 'limits_module:app', 'levels_module:app', + 'memberships_module:app', 'sessions_module', 'user_state_module', + 'config_secrets_user_module', 'emails_module', 'rls_module', 'user_auth_module', +]; + const result = await publicDb.databaseProvisionModule.create({ data: { databaseName: dbName, ownerId: userId, subdomain: dbName, domain: 'localhost', - modules: ['all'], bootstrapUser: true, + modules, bootstrapUser: true, }, select: { id: true, databaseId: true, databaseName: true, status: true } }).execute(); @@ -323,6 +331,6 @@ In almost all situations, prefer `secureTableProvision`. - `references/auth-flow.md` — auth endpoints, JWT, bootstrap user - `references/provisioning.md` — full provisioning flow with per-DB auth -- `constructive-db-built-in-schemas` — what `modules: ['all']` creates +- `constructive-db-built-in-schemas` — the schemas/tables the module set creates - `constructive-safegres` — Authz* type reference - `constructive-sdk-security` — RLS, grants, policies diff --git a/.agents/skills/constructive-sdk/references/provisioning.md b/.agents/skills/constructive-sdk/references/provisioning.md index 58d2c4a..b8c7cb2 100644 --- a/.agents/skills/constructive-sdk/references/provisioning.md +++ b/.agents/skills/constructive-sdk/references/provisioning.md @@ -24,18 +24,38 @@ const { accessToken, userId } = signIn.signIn.result; ## Step 2: Provision Database -Always use `modules: ['all']` and `bootstrapUser: true`: +Pass an **explicit module list** and `bootstrapUser: true`. **Never use `modules: ['all']`** — `'all'` is not a sentinel. `databaseProvisionModule` feeds `modules` straight into `metaschema_generators.provision_database_modules`, whose body is ~58 branches of `IF '' = ANY(v_modules) THEN ...` with **no `'all'` expansion** anywhere (not in the SQL, the trigger, the SDK, or the CLI). So `['all']` matches nothing, installs zero optional modules, and you get only the ~4 base schemas. The damage is silent: `bootstrapUser` fails with `TARGET_USERS_NOT_FOUND`, per-DB `signIn`/`signUp`/`currentUser` are empty, and every app-public query hits an RLS denial. + +For a basic auth app (email/password + app-level RLS, no orgs/SSO/MFA), use the `auth:email` module list — the verified default: ```typescript publicDb.setHeaders({ Authorization: `Bearer ${accessToken}` }); +// auth:email — verified default for a basic auth app. +// Source of truth: constructive/packages/node-type-registry/src/module-presets/auth-email.ts +// (or: getModulePreset('auth:email').modules from @constructive-io/node-type-registry) +const modules = [ + 'users_module', + 'membership_types_module', + 'permissions_module:app', + 'limits_module:app', + 'levels_module:app', + 'memberships_module:app', + 'sessions_module', + 'user_state_module', + 'config_secrets_user_module', + 'emails_module', + 'rls_module', + 'user_auth_module', +]; + const result = await publicDb.databaseProvisionModule.create({ data: { databaseName: dbName, ownerId: userId, subdomain: dbName, domain: 'localhost', - modules: ['all'], + modules, bootstrapUser: true, }, select: { id: true, databaseId: true, databaseName: true, status: true } @@ -44,6 +64,8 @@ const result = await publicDb.databaseProvisionModule.create({ const dbId = result.createDatabaseProvisionModule?.databaseProvisionModule?.databaseId; ``` +For a fuller app, swap in the `b2b` module list (orgs/teams/invites/permissions) or `full` (every standard module). Pull the exact array from the matching `module-presets/.ts` file. See the `constructive-platform` skill's `module-presets.md` for the full catalog. + ## Step 3: Apply Workarounds See `workarounds/fix-membership-defaults` and `workarounds/auto-verify-email`. @@ -76,7 +98,14 @@ await db.notes.create({ data: { content: 'Hello' }, select: { id: true } }).exec ## Module Reference +Always pass an explicit module list — the array is what `provision_database_modules` matches against. There is **no `['all']` sentinel**; passing it installs nothing (see Step 2). + | Modules | What it installs | |---|---| -| `['all']` | Everything — always use this for demos and real apps | -| `['uuid_module', 'users_module']` | Minimal — breaks app API auth | +| `auth:email` list (above) | Verified default — email/password auth + app-level RLS. Use for a basic auth app. | +| `b2b` list | `auth:email` + orgs/teams/invites/fine-grained permissions/levels/profiles/hierarchy. Multi-tenant SaaS. | +| `full` list | Every standard module (`b2b` + storage, billing/plans, notifications, ...). Reference/demo DBs. | +| `['users_module']` only | Minimal — breaks app API auth (no RLS/memberships/auth procedures). | +| `['all']` | **WRONG / anti-pattern** — not a sentinel; matches zero branches, installs nothing, silently breaks auth + RLS. | + +Source of truth for every list: `constructive/packages/node-type-registry/src/module-presets/.ts` (the `ModulePreset.modules` field), or `getModulePreset(name).modules` from `@constructive-io/node-type-registry`. From 77c0aa19f126c00382f3fc151b9478a26f68372e Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Mon, 1 Jun 2026 14:01:07 +0700 Subject: [PATCH 05/15] feat(constructive-blocks): flow-selection guide + flows drift guard SKILL.md 'Flow selection (start here)' decision-point reads the generated references/flows.json + flow-catalog.md (from the apps/blocks SoT). check-flows.mjs asserts sotHash sync (SoT/skill/harness) + referential integrity; exposed as pnpm check:flows. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/skills/constructive-blocks/SKILL.md | 29 + .../references/flow-catalog.md | 283 ++++++ .../constructive-blocks/references/flows.json | 906 ++++++++++++++++++ .../scripts/check-flows.mjs | 542 +++++++++++ package.json | 9 + 5 files changed, 1769 insertions(+) create mode 100644 .agents/skills/constructive-blocks/references/flow-catalog.md create mode 100644 .agents/skills/constructive-blocks/references/flows.json create mode 100755 .agents/skills/constructive-blocks/scripts/check-flows.mjs create mode 100644 package.json diff --git a/.agents/skills/constructive-blocks/SKILL.md b/.agents/skills/constructive-blocks/SKILL.md index f7146cd..ebaf26e 100644 --- a/.agents/skills/constructive-blocks/SKILL.md +++ b/.agents/skills/constructive-blocks/SKILL.md @@ -71,6 +71,34 @@ export default function RootLayout({ children }) { The runtime mounts **one** shared `QueryClient`, calls each namespace's generated `configure()` (reading `NEXT_PUBLIC__GRAPHQL_ENDPOINT`), and attaches `Authorization: Bearer ` via the host's `getToken`. A block **never** mounts a provider or calls `configure()`. +## Flow selection (start here) + +**Before** you install any block, pick the **flow(s)** the app needs. A *flow* is a backend-capability bundle — it answers *"which auth flow do you want?"* with the exact database **modules** to provision, the GraphQL **operations** that go live, and the **blocks** that wire the UI. Every catalogued flow is **GA** (DB-wired, GraphQL-exposed, blocks resolve). This is the catalog-first analogue of better-auth's plugins, and it is the cure for the `modules:['all']` over-provisioning trap. + +The catalog is two co-located files (both generated from one source of truth in apps/blocks — never hand-edit them): + +- **[`references/flows.json`](./references/flows.json)** — the machine-readable catalog: each flow's `backend.preset`, the resolved flat `backend.modules[]`, `backend.exposedOps[]`, and `blocks[]`. Read this to drive provisioning + install programmatically. +- **[`references/flow-catalog.md`](./references/flow-catalog.md)** — the human-readable index of the same data. + +### Decision procedure + +1. **Read the brief → list the capabilities** the app needs (e.g. "sign in, reset password, manage org members"). +2. **Map each capability to a flow id** in `references/flows.json` (e.g. `email-password`, `password-reset`, `org-members`). Pick the minimal set that covers the brief. +3. **Provision the UNION of the chosen flows' `backend.modules[]`** — the exact flat list, deduplicated across flows. Pass it to `databaseProvisionModule.create({ data: { modules } })`. **Never `modules:['all']`.** A flow's `modules[]` is authoritative; `preset` is only the smallest covering shipped preset (advisory). Org flows have no preset smaller than `b2b`. +4. **Install ONLY the chosen flows' `blocks[]`** — not the whole library. `npx shadcn@latest add …` for the union of the flows' blocks. +5. **Run `check-sdk.mjs`** (below) for each installed data block — it proves the host SDK actually exposes the ops the flow's blocks call, before you waste a build. + +```bash +# Example: brief needs sign-in + password reset. +# flows.json → email-password (preset auth:email) + password-reset (preset auth:email). +# Union of modules is the auth:email set (same preset) → provision that once, then: +npx shadcn@latest add auth-sign-in-card auth-sign-up-card auth-sign-out-button \ + auth-forgot-password-card auth-reset-password-card +node path/to/skill/scripts/check-sdk.mjs # gate every installed data block +``` + +If a needed capability is **not** in the catalog (magic-link, OTP, MFA enroll, passkey, anonymous, SSO/SCIM stubs, context-switch), its blocks exist in the library but are **not GA** — they ship a "backend-pending" banner and their `requires.json` names a not-yet-deployed op, so `check-sdk.mjs` fails clearly rather than letting you build against a guess. + ## Installing a block ```bash @@ -206,6 +234,7 @@ UI is built on `@constructive-io/ui` (consumed as an npm dependency — **never* |---|---|---| | [binding-doctrine.md](./references/binding-doctrine.md) | The canonical SDK binding law: namespaces, import convention, runtime, hook anatomy, override seam, compliance checklist | Authoring a block, reviewing one, or resolving any "how does a block reach the backend" question | | [manifest-and-checks.md](./references/manifest-and-checks.md) | Authoritative `requires.json` schema (single + cross-namespace), op-name rules, `check-sdk.mjs` invocation/exit codes/remediation | Writing or validating a manifest, interpreting a check failure | +| [flow-catalog.md](./references/flow-catalog.md) | The GA flow catalog (human-readable) — each flow's preset, resolved modules, exposed ops, and blocks. Machine twin: [`flows.json`](./references/flows.json) | Picking which flow(s) to install, deciding the modules to provision and the blocks to add (see "Flow selection") | ## Cross-References diff --git a/.agents/skills/constructive-blocks/references/flow-catalog.md b/.agents/skills/constructive-blocks/references/flow-catalog.md new file mode 100644 index 0000000..8130aed --- /dev/null +++ b/.agents/skills/constructive-blocks/references/flow-catalog.md @@ -0,0 +1,283 @@ + + +# Flow catalog + +Source of truth: `apps/blocks/scripts/flows-content.mjs`. sotHash: `200f0da2506f2df472e016b5467d2c2f10aec5cd7d77a96ddf0d85be261c6bbb`. + +Each flow is a backend-capability bundle: a preset to provision (resolved to a flat module list), the GraphQL operations it exposes, and the Blocks that wire the UI. GA-only. + +## Authentication + +### Email + password (`email-password`) + +The reference sign-in surface: register, sign in, sign out, and read the current user against email + password credentials. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `signUp`, `signIn`, `signOut`, `currentUser` +- **Blocks:** `auth-sign-in-card`, `auth-sign-up-card`, `auth-sign-out-button`, `auth-sign-in-page`, `auth-sign-up-page` + +Install: + +```bash +npx shadcn@latest add auth-sign-in-card auth-sign-up-card auth-sign-out-button auth-sign-in-page auth-sign-up-page +``` + +### Email verification (`email-verification`) + +Confirm a user owns their email: a verify-link landing page plus a resend banner for unverified accounts. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `verifyEmail`, `sendVerificationEmail` +- **Blocks:** `auth-verify-email-banner`, `auth-verify-email-page` + +Install: + +```bash +npx shadcn@latest add auth-verify-email-banner auth-verify-email-page +``` + +### Password reset (`password-reset`) + +Forgot-password request plus the emailed reset-token landing — enumeration-safe by construction. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `forgotPassword`, `resetPassword` +- **Blocks:** `auth-forgot-password-card`, `auth-forgot-password-page`, `auth-reset-password-card`, `auth-reset-password-page` + +Install: + +```bash +npx shadcn@latest add auth-forgot-password-card auth-forgot-password-page auth-reset-password-card auth-reset-password-page +``` + +### Social / OAuth sign-in (`social-oauth`) + +Sign in with configured identity providers (Google, GitHub, …) rendered as a button row or a prominent grid. + +- **Preset:** `auth:sso` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `connected_accounts_module`, `identity_providers_module` +- **Exposed ops:** `identityProviders`, `signInIdentity`, `signUpIdentity` +- **Blocks:** `auth-social-buttons`, `auth-social-providers-grid` + +Install: + +```bash +npx shadcn@latest add auth-social-buttons auth-social-providers-grid +``` + +### Cross-origin sign-in (`cross-origin`) + +Hand an authenticated session to another origin via a short-lived one-time token appended to the destination URL. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `requestCrossOriginToken`, `signInCrossOrigin` +- **Blocks:** `auth-cross-origin-link` + +Install: + +```bash +npx shadcn@latest add auth-cross-origin-link +``` + +## Account & session + +### Profile (`profile`) + +Let the signed-in user edit their display name and avatar, with a settings page and a security-posture summary. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `updateUser`, `currentUser` +- **Blocks:** `auth-account-profile-card`, `auth-account-settings-page`, `auth-account-security-card` + +Install: + +```bash +npx shadcn@latest add auth-account-profile-card auth-account-settings-page auth-account-security-card +``` + +### Account emails (`account-emails`) + +Manage the signed-in user's email addresses: add, verify, set primary, and remove. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `createEmail`, `updateEmail`, `deleteEmail`, `emails` +- **Blocks:** `auth-account-emails-list` + +Install: + +```bash +npx shadcn@latest add auth-account-emails-list +``` + +### Change password (`change-password`) + +An authenticated, step-up-gated form to set a new password with an inline strength meter. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `setPassword`, `checkPassword` +- **Blocks:** `auth-change-password-form` + +Install: + +```bash +npx shadcn@latest add auth-change-password-form +``` + +### Sessions (`sessions`) + +List the user's active sessions and revoke them individually or in bulk, gated behind step-up. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `revokeSession`, `extendTokenExpires` +- **Blocks:** `auth-account-sessions-list` + +Install: + +```bash +npx shadcn@latest add auth-account-sessions-list +``` + +### API keys (`api-keys`) + +Create and revoke user-scoped API keys, with a one-time reveal modal and step-up on create. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `createApiKey`, `revokeApiKey` +- **Blocks:** `auth-account-api-keys-list`, `auth-api-key-create-dialog`, `auth-api-key-created-modal` + +Install: + +```bash +npx shadcn@latest add auth-account-api-keys-list auth-api-key-create-dialog auth-api-key-created-modal +``` + +### Account deletion (`account-deletion`) + +A danger-zone card that emails a deletion confirmation, plus the page that completes the deletion from the link. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `sendAccountDeletionEmail`, `confirmDeleteAccount` +- **Blocks:** `auth-account-danger-card`, `auth-account-deletion-confirm-page` + +Install: + +```bash +npx shadcn@latest add auth-account-danger-card auth-account-deletion-confirm-page +``` + +### Step-up verification (`step-up`) + +Re-verify identity (password or TOTP) before a sensitive action, as a dialog or an imperative hook. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `requireStepUp`, `verifyPassword`, `verifyTotp` +- **Blocks:** `auth-step-up-dialog`, `use-step-up` + +Install: + +```bash +npx shadcn@latest add auth-step-up-dialog use-step-up +``` + +### Connected accounts (`connected-accounts`) + +List linked OAuth providers and disconnect them (step-up gated); offer connect links for the rest. + +- **Preset:** `auth:sso` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `connected_accounts_module`, `identity_providers_module` +- **Exposed ops:** `disconnectAccount` +- **Blocks:** `auth-account-connected-accounts` + +Install: + +```bash +npx shadcn@latest add auth-account-connected-accounts +``` + +## Authorization + +### Organizations (`organization`) + +Create and configure organizations — first-class User records (type=2) in the unified user model. + +- **Preset:** `b2b` +- **Modules:** `users_module`, `membership_types_module`, `memberships_module:app`, `memberships_module:org`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `session_secrets_module`, `rate_limits_module`, `connected_accounts_module`, `identity_providers_module`, `webauthn_credentials_module`, `webauthn_auth_module`, `phone_numbers_module`, `permissions_module:app`, `permissions_module:org`, `limits_module:app`, `limits_module:org`, `levels_module:app`, `levels_module:org`, `profiles_module:app`, `profiles_module:org`, `hierarchy_module:org`, `invites_module:app`, `invites_module:org`, `devices_module` +- **Exposed ops:** `createUser`, `updateUser`, `currentUser` +- **Blocks:** `org-create-card`, `org-settings-form` + +Install: + +```bash +npx shadcn@latest add org-create-card org-settings-form +``` + +### Org members (`org-members`) + +List an organization's members with inline role changes and step-up-gated removal. + +- **Preset:** `b2b` +- **Modules:** `users_module`, `membership_types_module`, `memberships_module:app`, `memberships_module:org`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `session_secrets_module`, `rate_limits_module`, `connected_accounts_module`, `identity_providers_module`, `webauthn_credentials_module`, `webauthn_auth_module`, `phone_numbers_module`, `permissions_module:app`, `permissions_module:org`, `limits_module:app`, `limits_module:org`, `levels_module:app`, `levels_module:org`, `profiles_module:app`, `profiles_module:org`, `hierarchy_module:org`, `invites_module:app`, `invites_module:org`, `devices_module` +- **Exposed ops:** `updateOrgMembership`, `deleteOrgMembership`, `removeOrgMember`, `orgMemberships` +- **Blocks:** `org-members-list` + +Install: + +```bash +npx shadcn@latest add org-members-list +``` + +### Org roles (`org-roles`) + +Create, edit, and delete named org role profiles that bundle the org-scoped permission set. + +- **Preset:** `b2b` +- **Modules:** `users_module`, `membership_types_module`, `memberships_module:app`, `memberships_module:org`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `session_secrets_module`, `rate_limits_module`, `connected_accounts_module`, `identity_providers_module`, `webauthn_credentials_module`, `webauthn_auth_module`, `phone_numbers_module`, `permissions_module:app`, `permissions_module:org`, `limits_module:app`, `limits_module:org`, `levels_module:app`, `levels_module:org`, `profiles_module:app`, `profiles_module:org`, `hierarchy_module:org`, `invites_module:app`, `invites_module:org`, `devices_module` +- **Exposed ops:** `createOrgProfile`, `updateOrgProfile`, `deleteOrgProfile` +- **Blocks:** `org-roles-editor` + +Install: + +```bash +npx shadcn@latest add org-roles-editor +``` + +### Org invites (`org-invites`) + +Invite members to an org by email and let invitees accept app- or org-level invitations from a token link. + +- **Preset:** `b2b` +- **Modules:** `users_module`, `membership_types_module`, `memberships_module:app`, `memberships_module:org`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `session_secrets_module`, `rate_limits_module`, `connected_accounts_module`, `identity_providers_module`, `webauthn_credentials_module`, `webauthn_auth_module`, `phone_numbers_module`, `permissions_module:app`, `permissions_module:org`, `limits_module:app`, `limits_module:org`, `levels_module:app`, `levels_module:org`, `profiles_module:app`, `profiles_module:org`, `hierarchy_module:org`, `invites_module:app`, `invites_module:org`, `devices_module` +- **Exposed ops:** `createOrgInvite`, `orgInvites`, `submitOrgInviteCode`, `submitAppInviteCode` +- **Blocks:** `org-invite-dialog`, `auth-invitation-acceptance-card`, `auth-invitation-acceptance-page` + +Install: + +```bash +npx shadcn@latest add org-invite-dialog auth-invitation-acceptance-card auth-invitation-acceptance-page +``` + +### App memberships (`app-memberships`) + +Admin-manage an org's app-level memberships: approve, revoke (step-up gated), and update profiles. + +- **Preset:** `b2b` +- **Modules:** `users_module`, `membership_types_module`, `memberships_module:app`, `memberships_module:org`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `session_secrets_module`, `rate_limits_module`, `connected_accounts_module`, `identity_providers_module`, `webauthn_credentials_module`, `webauthn_auth_module`, `phone_numbers_module`, `permissions_module:app`, `permissions_module:org`, `limits_module:app`, `limits_module:org`, `levels_module:app`, `levels_module:org`, `profiles_module:app`, `profiles_module:org`, `hierarchy_module:org`, `invites_module:app`, `invites_module:org`, `devices_module` +- **Exposed ops:** `updateAppMembership`, `deleteAppMembership`, `appMemberships` +- **Blocks:** `org-app-memberships` + +Install: + +```bash +npx shadcn@latest add org-app-memberships +``` diff --git a/.agents/skills/constructive-blocks/references/flows.json b/.agents/skills/constructive-blocks/references/flows.json new file mode 100644 index 0000000..455dcd0 --- /dev/null +++ b/.agents/skills/constructive-blocks/references/flows.json @@ -0,0 +1,906 @@ +{ + "generatedAt": null, + "source": "apps/blocks/scripts/flows-content.mjs", + "sotHash": "200f0da2506f2df472e016b5467d2c2f10aec5cd7d77a96ddf0d85be261c6bbb", + "groups": [ + { + "id": "authentication", + "label": "Authentication" + }, + { + "id": "account-session", + "label": "Account & session" + }, + { + "id": "authorization", + "label": "Authorization" + } + ], + "flows": [ + { + "id": "email-password", + "name": "Email + password", + "group": "authentication", + "status": "ga", + "summary": "The reference sign-in surface: register, sign in, sign out, and read the current user against email + password credentials.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + "permissions_module:app", + "limits_module:app", + "levels_module:app", + "memberships_module:app", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "signUp", + "signIn", + "signOut", + "currentUser" + ] + }, + "blocks": [ + "auth-sign-in-card", + "auth-sign-up-card", + "auth-sign-out-button", + "auth-sign-in-page", + "auth-sign-up-page" + ], + "howto": { + "provision": "# Provision the auth:email modules onto your database (see Backend below for the full list).\npgpm install # or: provision via databaseProvisionModule.create({ data: { modules } })", + "install": "npx shadcn@latest add auth-sign-in-card auth-sign-up-card auth-sign-out-button auth-sign-in-page auth-sign-up-page", + "wire": "import { BlocksRuntime } from '@/blocks/runtime/blocks-runtime';\nimport { tokenManager } from '@/lib/auth/token-manager';\n\n// Mount once at the app root so every auth block resolves its hook.\n tokenManager.getAccessToken()}>\n {children}\n", + "usage": "import { SignInCard } from '@/blocks/auth/sign-in-card/sign-in-card';\n\nexport function SignInRoute() {\n const router = useRouter();\n return (\n router.push(\"/\")}\n />\n );\n}" + }, + "relatedFlows": [ + "email-verification", + "password-reset", + "social-oauth", + "profile" + ] + }, + { + "id": "email-verification", + "name": "Email verification", + "group": "authentication", + "status": "ga", + "summary": "Confirm a user owns their email: a verify-link landing page plus a resend banner for unverified accounts.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + "permissions_module:app", + "limits_module:app", + "levels_module:app", + "memberships_module:app", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "verifyEmail", + "sendVerificationEmail" + ] + }, + "blocks": [ + "auth-verify-email-banner", + "auth-verify-email-page" + ], + "howto": { + "provision": "# auth:email already exposes verifyEmail / sendVerificationEmail — no extra modules.\npgpm install", + "install": "npx shadcn@latest add auth-verify-email-banner auth-verify-email-page", + "wire": "import { VerifyEmailBanner } from '@/blocks/auth/verify-email-banner/verify-email-banner';\n\n// Show the banner in your app header for signed-in, unverified users.\nexport function AppHeader({ user }) {\n if (user.isVerified) return null;\n return ;\n}", + "usage": "// Mount the page at /auth/verify-email; it reads ?email_id= and ?token= from the URL.\nimport { VerifyEmailPage } from '@/blocks/auth/verify-email-page/verify-email-page';\n\nexport default function Page() {\n return ;\n}" + }, + "relatedFlows": [ + "email-password", + "password-reset" + ] + }, + { + "id": "password-reset", + "name": "Password reset", + "group": "authentication", + "status": "ga", + "summary": "Forgot-password request plus the emailed reset-token landing — enumeration-safe by construction.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + "permissions_module:app", + "limits_module:app", + "levels_module:app", + "memberships_module:app", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "forgotPassword", + "resetPassword" + ] + }, + "blocks": [ + "auth-forgot-password-card", + "auth-forgot-password-page", + "auth-reset-password-card", + "auth-reset-password-page" + ], + "howto": { + "provision": "# forgotPassword / resetPassword ship with auth:email — no extra modules.\npgpm install", + "install": "npx shadcn@latest add auth-forgot-password-card auth-forgot-password-page auth-reset-password-card auth-reset-password-page", + "wire": "// Both pages are thin route wrappers — no extra wiring beyond BlocksRuntime.\n// forgot-password-page reads ?email=; reset-password-page reads ?token= and ?role_id=.", + "usage": "import ForgotPasswordPage from '@/blocks/auth/forgot-password-page/forgot-password-page';\n\n// app/auth/forgot-password/page.tsx\nexport default function Page() {\n return ;\n}" + }, + "relatedFlows": [ + "email-password", + "change-password" + ] + }, + { + "id": "social-oauth", + "name": "Social / OAuth sign-in", + "group": "authentication", + "status": "ga", + "summary": "Sign in with configured identity providers (Google, GitHub, …) rendered as a button row or a prominent grid.", + "backend": { + "preset": "auth:sso", + "modules": [ + "users_module", + "membership_types_module", + "permissions_module:app", + "limits_module:app", + "levels_module:app", + "memberships_module:app", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module", + "connected_accounts_module", + "identity_providers_module" + ], + "exposedOps": [ + "identityProviders", + "signInIdentity", + "signUpIdentity" + ] + }, + "blocks": [ + "auth-social-buttons", + "auth-social-providers-grid" + ], + "howto": { + "provision": "# auth:sso adds connected_accounts_module + identity_providers_module (see Backend below).\npgpm install", + "install": "npx shadcn@latest add auth-social-buttons auth-social-providers-grid", + "wire": "import { AuthSocialButtons } from '@/blocks/auth/social-buttons/social-buttons';\n\n// Omit `providers` to load enabled providers from the identity-providers API at runtime.\n", + "usage": "import { AuthSocialProvidersGrid } from '@/blocks/auth/social-providers-grid/social-providers-grid';\n\nexport function SignInExtras() {\n return ;\n}" + }, + "relatedFlows": [ + "email-password", + "connected-accounts" + ] + }, + { + "id": "cross-origin", + "name": "Cross-origin sign-in", + "group": "authentication", + "status": "ga", + "summary": "Hand an authenticated session to another origin via a short-lived one-time token appended to the destination URL.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + "permissions_module:app", + "limits_module:app", + "levels_module:app", + "memberships_module:app", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "requestCrossOriginToken", + "signInCrossOrigin" + ] + }, + "blocks": [ + "auth-cross-origin-link" + ], + "howto": { + "provision": "# requestCrossOriginToken / signInCrossOrigin ship with auth:email — no extra modules.\npgpm install", + "install": "npx shadcn@latest add auth-cross-origin-link", + "wire": "import { CrossOriginLink } from '@/blocks/auth/cross-origin-link/cross-origin-link';\n\n// Mount inside the same form that collected email/password.\n\n Continue to app\n", + "usage": "import { CrossOriginLink } from '@/blocks/auth/cross-origin-link/cross-origin-link';\n\n" + }, + "relatedFlows": [ + "email-password" + ] + }, + { + "id": "profile", + "name": "Profile", + "group": "account-session", + "status": "ga", + "summary": "Let the signed-in user edit their display name and avatar, with a settings page and a security-posture summary.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + "permissions_module:app", + "limits_module:app", + "levels_module:app", + "memberships_module:app", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "updateUser", + "currentUser" + ] + }, + "blocks": [ + "auth-account-profile-card", + "auth-account-settings-page", + "auth-account-security-card" + ], + "howto": { + "provision": "# updateUser / currentUser ship with auth:email — no extra modules.\npgpm install", + "install": "npx shadcn@latest add auth-account-profile-card auth-account-settings-page auth-account-security-card", + "wire": "import AccountSettingsPage from '@/blocks/auth/account-settings-page/account-settings-page';\n\n// The settings page composes the profile + security cards (and more) for you.\nexport default function AccountPage() {\n return ;\n}", + "usage": "import { AccountProfileCard } from '@/blocks/auth/account-profile-card/account-profile-card';\n\n toast.success(\"Profile updated\")} />" + }, + "relatedFlows": [ + "account-emails", + "change-password", + "sessions" + ] + }, + { + "id": "account-emails", + "name": "Account emails", + "group": "account-session", + "status": "ga", + "summary": "Manage the signed-in user's email addresses: add, verify, set primary, and remove.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + "permissions_module:app", + "limits_module:app", + "levels_module:app", + "memberships_module:app", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "createEmail", + "updateEmail", + "deleteEmail", + "emails" + ] + }, + "blocks": [ + "auth-account-emails-list" + ], + "howto": { + "provision": "# emails_module + its CRUD ship with auth:email — no extra modules.\npgpm install", + "install": "npx shadcn@latest add auth-account-emails-list", + "wire": "import { AccountEmailsList } from '@/blocks/auth/account-emails-list/account-emails-list';\n\n", + "usage": "import { AccountEmailsList } from '@/blocks/auth/account-emails-list/account-emails-list';\n\n" + }, + "relatedFlows": [ + "profile", + "email-verification" + ] + }, + { + "id": "change-password", + "name": "Change password", + "group": "account-session", + "status": "ga", + "summary": "An authenticated, step-up-gated form to set a new password with an inline strength meter.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + "permissions_module:app", + "limits_module:app", + "levels_module:app", + "memberships_module:app", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "setPassword", + "checkPassword" + ] + }, + "blocks": [ + "auth-change-password-form" + ], + "howto": { + "provision": "# setPassword / checkPassword ship with auth:email — no extra modules.\npgpm install", + "install": "npx shadcn@latest add auth-change-password-form", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// The form runs a step-up re-verification, so a StepUpProvider must be an ancestor.\n{children}", + "usage": "import { ChangePasswordForm } from '@/blocks/auth/change-password-form/change-password-form';\n\n toast(\"Password updated\")} />" + }, + "relatedFlows": [ + "password-reset", + "step-up", + "profile" + ] + }, + { + "id": "sessions", + "name": "Sessions", + "group": "account-session", + "status": "ga", + "summary": "List the user's active sessions and revoke them individually or in bulk, gated behind step-up.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + "permissions_module:app", + "limits_module:app", + "levels_module:app", + "memberships_module:app", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "revokeSession", + "extendTokenExpires" + ] + }, + "blocks": [ + "auth-account-sessions-list" + ], + "howto": { + "provision": "# sessions_module + revokeSession ship with auth:email — no extra modules.\npgpm install", + "install": "npx shadcn@latest add auth-account-sessions-list", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Single revoke is step-up tier=medium; revoke-all-others is tier=high.\n{children}", + "usage": "import { AccountSessionsList } from '@/blocks/auth/account-sessions-list/account-sessions-list';\n\n// The session list has no generated list hook — supply rows via the `sessions` prop.\n" + }, + "relatedFlows": [ + "step-up", + "profile" + ] + }, + { + "id": "api-keys", + "name": "API keys", + "group": "account-session", + "status": "ga", + "summary": "Create and revoke user-scoped API keys, with a one-time reveal modal and step-up on create.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + "permissions_module:app", + "limits_module:app", + "levels_module:app", + "memberships_module:app", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "createApiKey", + "revokeApiKey" + ] + }, + "blocks": [ + "auth-account-api-keys-list", + "auth-api-key-create-dialog", + "auth-api-key-created-modal" + ], + "howto": { + "provision": "# API-key CRUD ships with auth:email (user_auth_module) — no extra modules.\npgpm install", + "install": "npx shadcn@latest add auth-account-api-keys-list auth-api-key-create-dialog auth-api-key-created-modal", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Create is gated behind a high-severity step-up; mount the provider once.\n{children}", + "usage": "import { AccountApiKeysList } from '@/blocks/auth/account-api-keys-list/account-api-keys-list';\n\n// No generated list hook for user_api_keys — supply rows via the `keys` prop.\n" + }, + "relatedFlows": [ + "step-up", + "profile" + ] + }, + { + "id": "account-deletion", + "name": "Account deletion", + "group": "account-session", + "status": "ga", + "summary": "A danger-zone card that emails a deletion confirmation, plus the page that completes the deletion from the link.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + "permissions_module:app", + "limits_module:app", + "levels_module:app", + "memberships_module:app", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "sendAccountDeletionEmail", + "confirmDeleteAccount" + ] + }, + "blocks": [ + "auth-account-danger-card", + "auth-account-deletion-confirm-page" + ], + "howto": { + "provision": "# delete_account flow ships with auth:email (user_auth_module) — no extra modules.\npgpm install", + "install": "npx shadcn@latest add auth-account-danger-card auth-account-deletion-confirm-page", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// The danger card gates the deletion email behind a high-tier step-up.\n{children}", + "usage": "import { AccountDeletionConfirmPage } from '@/blocks/auth/account-deletion-confirm-page/account-deletion-confirm-page';\n\n// app/auth/delete-account/page.tsx — reads ?token= and ?user_id= from the link.\n" + }, + "relatedFlows": [ + "step-up", + "profile" + ] + }, + { + "id": "step-up", + "name": "Step-up verification", + "group": "account-session", + "status": "ga", + "summary": "Re-verify identity (password or TOTP) before a sensitive action, as a dialog or an imperative hook.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + "permissions_module:app", + "limits_module:app", + "levels_module:app", + "memberships_module:app", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "requireStepUp", + "verifyPassword", + "verifyTotp" + ] + }, + "blocks": [ + "auth-step-up-dialog", + "use-step-up" + ], + "howto": { + "provision": "# requireStepUp / verifyPassword ship with auth:email — no extra modules.\npgpm install", + "install": "npx shadcn@latest add auth-step-up-dialog use-step-up", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Mount the provider once near the app root; consumers call useStepUp() below it.\n{children}", + "usage": "import { useStepUp, StepUpError } from '@/blocks/auth/use-step-up/use-step-up';\n\nasync function onDangerousAction() {\n const stepUp = useStepUp();\n try {\n await stepUp({ tier: 'high' });\n await deleteAccount();\n } catch (err) {\n if (err instanceof StepUpError && err.reason === 'cancelled') return;\n throw err;\n }\n}" + }, + "relatedFlows": [ + "change-password", + "sessions", + "api-keys", + "account-deletion" + ] + }, + { + "id": "connected-accounts", + "name": "Connected accounts", + "group": "account-session", + "status": "ga", + "summary": "List linked OAuth providers and disconnect them (step-up gated); offer connect links for the rest.", + "backend": { + "preset": "auth:sso", + "modules": [ + "users_module", + "membership_types_module", + "permissions_module:app", + "limits_module:app", + "levels_module:app", + "memberships_module:app", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module", + "connected_accounts_module", + "identity_providers_module" + ], + "exposedOps": [ + "disconnectAccount" + ] + }, + "blocks": [ + "auth-account-connected-accounts" + ], + "howto": { + "provision": "# disconnectAccount + connected_accounts_module ship with auth:sso (see Backend below).\npgpm install", + "install": "npx shadcn@latest add auth-account-connected-accounts", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Disconnect is gated behind a step-up (tier: medium).\n{children}", + "usage": "import { AccountConnectedAccounts } from '@/blocks/auth/account-connected-accounts/account-connected-accounts';\n\n// Connection types are not yet public — pass connectedAccounts + providers as props.\n" + }, + "relatedFlows": [ + "social-oauth", + "step-up" + ] + }, + { + "id": "organization", + "name": "Organizations", + "group": "authorization", + "status": "ga", + "summary": "Create and configure organizations — first-class User records (type=2) in the unified user model.", + "backend": { + "preset": "b2b", + "modules": [ + "users_module", + "membership_types_module", + "memberships_module:app", + "memberships_module:org", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module", + "session_secrets_module", + "rate_limits_module", + "connected_accounts_module", + "identity_providers_module", + "webauthn_credentials_module", + "webauthn_auth_module", + "phone_numbers_module", + "permissions_module:app", + "permissions_module:org", + "limits_module:app", + "limits_module:org", + "levels_module:app", + "levels_module:org", + "profiles_module:app", + "profiles_module:org", + "hierarchy_module:org", + "invites_module:app", + "invites_module:org", + "devices_module" + ], + "exposedOps": [ + "createUser", + "updateUser", + "currentUser" + ] + }, + "blocks": [ + "org-create-card", + "org-settings-form" + ], + "howto": { + "provision": "# Orgs require the full B2B stack (org-scoped memberships/permissions/invites/hierarchy).\n# There is no preset smaller than b2b for org flows — see Backend below.\npgpm install", + "install": "npx shadcn@latest add org-create-card org-settings-form", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// org-settings-form gates danger-zone deletion behind a step-up.\n{children}", + "usage": "import { OrgCreateCard } from '@/blocks/org/create-card/create-card';\n\n// Creates a users row with type=2 (an organization).\n router.push(`/orgs/${org.id}`)} />" + }, + "relatedFlows": [ + "org-members", + "org-roles", + "org-invites", + "app-memberships" + ] + }, + { + "id": "org-members", + "name": "Org members", + "group": "authorization", + "status": "ga", + "summary": "List an organization's members with inline role changes and step-up-gated removal.", + "backend": { + "preset": "b2b", + "modules": [ + "users_module", + "membership_types_module", + "memberships_module:app", + "memberships_module:org", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module", + "session_secrets_module", + "rate_limits_module", + "connected_accounts_module", + "identity_providers_module", + "webauthn_credentials_module", + "webauthn_auth_module", + "phone_numbers_module", + "permissions_module:app", + "permissions_module:org", + "limits_module:app", + "limits_module:org", + "levels_module:app", + "levels_module:org", + "profiles_module:app", + "profiles_module:org", + "hierarchy_module:org", + "invites_module:app", + "invites_module:org", + "devices_module" + ], + "exposedOps": [ + "updateOrgMembership", + "deleteOrgMembership", + "removeOrgMember", + "orgMemberships" + ] + }, + "blocks": [ + "org-members-list" + ], + "howto": { + "provision": "# Org membership CRUD requires the b2b org-scoped memberships module — see Backend below.\npgpm install", + "install": "npx shadcn@latest add org-members-list", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Sensitive member actions require a step-up before the mutation fires.\n{children}", + "usage": "import { OrgMembersList } from '@/blocks/org/members-list/members-list';\n\n// transferOrgOwnership is a pending seam — the GA list + role-change + remove path stands alone.\n" + }, + "relatedFlows": [ + "organization", + "org-roles", + "org-invites" + ] + }, + { + "id": "org-roles", + "name": "Org roles", + "group": "authorization", + "status": "ga", + "summary": "Create, edit, and delete named org role profiles that bundle the org-scoped permission set.", + "backend": { + "preset": "b2b", + "modules": [ + "users_module", + "membership_types_module", + "memberships_module:app", + "memberships_module:org", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module", + "session_secrets_module", + "rate_limits_module", + "connected_accounts_module", + "identity_providers_module", + "webauthn_credentials_module", + "webauthn_auth_module", + "phone_numbers_module", + "permissions_module:app", + "permissions_module:org", + "limits_module:app", + "limits_module:org", + "levels_module:app", + "levels_module:org", + "profiles_module:app", + "profiles_module:org", + "hierarchy_module:org", + "invites_module:app", + "invites_module:org", + "devices_module" + ], + "exposedOps": [ + "createOrgProfile", + "updateOrgProfile", + "deleteOrgProfile" + ] + }, + "blocks": [ + "org-roles-editor" + ], + "howto": { + "provision": "# Org role profiles require the b2b org-scoped profiles module — see Backend below.\npgpm install", + "install": "npx shadcn@latest add org-roles-editor", + "wire": "// org-roles-editor binds to the generated admin SDK hooks — only BlocksRuntime is required.", + "usage": "import { OrgRolesEditor } from '@/blocks/org/roles-editor/roles-editor';\n\n" + }, + "relatedFlows": [ + "organization", + "org-members" + ] + }, + { + "id": "org-invites", + "name": "Org invites", + "group": "authorization", + "status": "ga", + "summary": "Invite members to an org by email and let invitees accept app- or org-level invitations from a token link.", + "backend": { + "preset": "b2b", + "modules": [ + "users_module", + "membership_types_module", + "memberships_module:app", + "memberships_module:org", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module", + "session_secrets_module", + "rate_limits_module", + "connected_accounts_module", + "identity_providers_module", + "webauthn_credentials_module", + "webauthn_auth_module", + "phone_numbers_module", + "permissions_module:app", + "permissions_module:org", + "limits_module:app", + "limits_module:org", + "levels_module:app", + "levels_module:org", + "profiles_module:app", + "profiles_module:org", + "hierarchy_module:org", + "invites_module:app", + "invites_module:org", + "devices_module" + ], + "exposedOps": [ + "createOrgInvite", + "orgInvites", + "submitOrgInviteCode", + "submitAppInviteCode" + ] + }, + "blocks": [ + "org-invite-dialog", + "auth-invitation-acceptance-card", + "auth-invitation-acceptance-page" + ], + "howto": { + "provision": "# Invite flows require the b2b invites modules (app + org scope) — see Backend below.\npgpm install", + "install": "npx shadcn@latest add org-invite-dialog auth-invitation-acceptance-card auth-invitation-acceptance-page", + "wire": "import { OrgInviteDialog } from '@/blocks/org/invite-dialog/invite-dialog';\n\n// resendOrgInvite is pending — the dialog resends by cancel + re-create.\n", + "usage": "import InvitationAcceptancePage from '@/blocks/auth/invitation-acceptance-page/invitation-acceptance-page';\n\n// app/invite/page.tsx — reads ?token= and ?kind= from the URL.\nexport default function Page() {\n return ;\n}" + }, + "relatedFlows": [ + "organization", + "org-members", + "app-memberships" + ] + }, + { + "id": "app-memberships", + "name": "App memberships", + "group": "authorization", + "status": "ga", + "summary": "Admin-manage an org's app-level memberships: approve, revoke (step-up gated), and update profiles.", + "backend": { + "preset": "b2b", + "modules": [ + "users_module", + "membership_types_module", + "memberships_module:app", + "memberships_module:org", + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module", + "session_secrets_module", + "rate_limits_module", + "connected_accounts_module", + "identity_providers_module", + "webauthn_credentials_module", + "webauthn_auth_module", + "phone_numbers_module", + "permissions_module:app", + "permissions_module:org", + "limits_module:app", + "limits_module:org", + "levels_module:app", + "levels_module:org", + "profiles_module:app", + "profiles_module:org", + "hierarchy_module:org", + "invites_module:app", + "invites_module:org", + "devices_module" + ], + "exposedOps": [ + "updateAppMembership", + "deleteAppMembership", + "appMemberships" + ] + }, + "blocks": [ + "org-app-memberships" + ], + "howto": { + "provision": "# App membership management requires the b2b app-scoped memberships module — see Backend below.\npgpm install", + "install": "npx shadcn@latest add org-app-memberships", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Revoke is gated behind a confirmation dialog + step-up (tier: medium).\n{children}", + "usage": "import { OrgAppMemberships } from '@/blocks/org/app-memberships/app-memberships';\n\n" + }, + "relatedFlows": [ + "organization", + "org-invites", + "org-members" + ] + } + ] +} diff --git a/.agents/skills/constructive-blocks/scripts/check-flows.mjs b/.agents/skills/constructive-blocks/scripts/check-flows.mjs new file mode 100755 index 0000000..2753fd4 --- /dev/null +++ b/.agents/skills/constructive-blocks/scripts/check-flows.mjs @@ -0,0 +1,542 @@ +#!/usr/bin/env node +/** + * check-flows.mjs — drift guard for the Constructive Blocks flow catalog. + * + * Part of the `constructive-blocks` agent skill. Where `check-sdk.mjs` guards the + * *frontend* contract (a block's generated-hook prerequisites), this guards the + * *flow catalog* contract: that the committed `references/flows.json` in this + * skill is still a faithful, in-sync projection of the single source of truth in + * apps/blocks (`scripts/flows-content.mjs` -> resolved `src/flows/flows.json`), + * and that the harness copy hasn't drifted from the skill copy. + * + * The catalog is GENERATED, never hand-edited. The generator + * (apps/blocks/scripts/generate-flows.mjs) computes a `sotHash` over the + * resolved flows and stamps it into every emitted `flows.json`. This script + * recomputes that hash with the SAME canonicalization and asserts it matches — + * turning silent drift (someone edits a committed flows.json, or regenerates one + * copy but not the other) into a loud, actionable failure. + * + * Zero dependencies. Pure Node (>=18), node:crypto for sha256. Run from the + * skill repo root (or anywhere with --project / env overrides): + * + * node check-flows.mjs # verify this skill's catalog is in-sync + * node check-flows.mjs --project /path/repo # resolve the skill copy from a different root + * node check-flows.mjs --sot src/flows/flows.json # explicit SoT (relative to cwd) + * node check-flows.mjs --json # machine-readable report on stdout + * node check-flows.mjs --help + * + * SoT / harness / presets are located via (highest precedence first): + * --sot explicit SoT flows.json (resolved against cwd) — used by the + * in-repo `pnpm check:flows` in apps/blocks (`--sot src/flows/flows.json`). + * FLOWS_SOT env -> apps/blocks/src/flows/flows.json (resolved SoT artifact) + * findUp apps/blocks/src/flows/flows.json walked up from this script. + * FLOWS_HARNESS env -> agentic-flow .../references/flows.json (byte-twin of skill copy) + * FLOWS_PRESETS env -> constructive/packages/node-type-registry (preset resolution) + * If a path can't be resolved it is treated as "not reachable" and SKIPPED + * (not a failure) — except the skill copy and the SoT, which are required. + * + * Exit codes (mirroring check-sdk.mjs): + * 0 everything in sync (or the only-reachable checks all passed) + * 1 DRIFT — a hash mismatch, a byte mismatch, or a referential-integrity break + * 2 the check could not run (skill copy or SoT unreadable / unparseable / bad args) + * + * What it verifies: + * 1. SoT self-consistency: sha256(canonical({flows})) === embedded sotHash. + * 2. skill copy sotHash === SoT sotHash. + * 3. harness copy (if reachable) sotHash === SoT sotHash. + * 4. skill copy bytes === harness copy bytes (if reachable). + * 5. referential integrity per flow: status==='ga', blocks non-empty, preset + * resolves from node-type-registry (if reachable), modules ⊆ preset. + * + * On any drift it prints the remediation: "re-run: (cd apps/blocks && pnpm gen:flows)". + */ + +import crypto from 'node:crypto'; +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join, resolve, dirname, isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const requireCjs = createRequire(import.meta.url); + +const scriptDir = dirname(fileURLToPath(import.meta.url)); + +// The skill copy this script is bundled alongside: ../references/flows.json. +const SKILL_FLOWS = resolve(scriptDir, '..', 'references', 'flows.json'); + +// Remediation printed on every drift — regenerating from the SoT is the only fix. +const REMEDIATION = 're-run: (cd apps/blocks && pnpm gen:flows)'; + +// --------------------------------------------------------------------------- +// args +// --------------------------------------------------------------------------- +function parseArgs(argv) { + const opts = { project: null, sot: null, json: false, help: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--project' || a === '-p') opts.project = resolve(argv[++i] ?? '.'); + else if (a === '--sot') opts.sot = resolve(argv[++i] ?? '.'); // explicit SoT flows.json, relative to cwd + else if (a === '--json') opts.json = true; + else if (a === '--help' || a === '-h') opts.help = true; + } + return opts; +} + +// --------------------------------------------------------------------------- +// reporting (mirrors check-sdk.mjs) +// --------------------------------------------------------------------------- +const C = process.stdout.isTTY + ? { red: (s) => `\x1b[31m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m`, yellow: (s) => `\x1b[33m${s}\x1b[0m` } + : { red: (s) => s, green: (s) => s, dim: (s) => s, bold: (s) => s, yellow: (s) => s }; + +function fail(code, msg) { + console.error(`${C.red('✗')} ${msg}`); + process.exit(code); +} + +const HELP = `check-flows.mjs — verify this skill's flow catalog is in sync with apps/blocks. + +Usage: + node check-flows.mjs [--project DIR] [--sot FILE] [--json] [--help] + + --project DIR root to resolve the skill copy of references/flows.json from + (default: relative to this script) + --sot FILE explicit SoT flows.json (relative to cwd). Highest precedence; + used by apps/blocks: \`check:flows --sot src/flows/flows.json\`. + --json emit a machine-readable report + --help show this help + +Env overrides (else auto-located via findUp): + FLOWS_SOT apps/blocks/src/flows/flows.json (the resolved source of truth) + FLOWS_HARNESS agentic-flow references/flows.json (byte-twin of the skill copy) + FLOWS_PRESETS constructive/packages/node-type-registry (for preset resolution) + +Exit codes: 0 in sync · 1 drift · 2 can't run. +Drift fix: ${REMEDIATION}`; + +// --------------------------------------------------------------------------- +// findUp — walk up from a start dir looking for a relative target. At each +// ancestor level it ALSO probes one level of siblings (`/*/`), +// which is what lets a sibling-worktree layout resolve: walking up from the +// skill worktree hits the shared parent (e.g. `.worktrees-v2/`), whose children +// include the dashboard worktree carrying `apps/blocks/src/flows/flows.json`. +// Direct ancestor matches always win over sibling matches; siblings are tried +// in sorted order for determinism. +// --------------------------------------------------------------------------- +function findUp(startDir, relTarget) { + let dir = startDir; + for (;;) { + const direct = join(dir, relTarget); + if (existsSync(direct)) return direct; + // One level of siblings under this ancestor. + let children; + try { + children = readdirSync(dir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort(); + } catch { + children = []; + } + for (const child of children) { + const sib = join(dir, child, relTarget); + if (existsSync(sib)) return sib; + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +// --------------------------------------------------------------------------- +// CANONICALIZATION — replicated EXACTLY from +// apps/blocks/scripts/generate-flows.mjs. Do NOT "improve" it; the hash only +// matches if this is byte-for-byte the same algorithm: +// canonical(value): +// - arrays -> "[" + canonical(item) joined by "," + "]" (ORDER PRESERVED; +// module lists keep preset declaration order, flows keep +// authored order — do NOT sort arrays). +// - objects -> "{" + for each key in Object.keys(obj).sort(): +// JSON.stringify(key) + ":" + canonical(obj[key]) +// joined by "," + "}" (KEYS SORTED). +// - else -> JSON.stringify(value). +// No whitespace. sotHash = sha256_hex(canonical({ flows: resolvedFlows })). +// The envelope ({ generatedAt, source, sotHash, groups }) is NOT part of the hash. +// --------------------------------------------------------------------------- +function canonicalize(value) { + if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`; + if (value && typeof value === 'object') { + const keys = Object.keys(value).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(value[k])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function sotHashOf(flows) { + return crypto.createHash('sha256').update(canonicalize({ flows })).digest('hex'); +} + +// --------------------------------------------------------------------------- +// preset resolution — same dist-preferred / regex-source-fallback strategy as +// generate-flows.mjs, used ONLY for the referential-integrity check +// (modules ⊆ preset). If the registry isn't reachable, this check is skipped. +// --------------------------------------------------------------------------- +const NTR_REL = join('constructive', 'packages', 'node-type-registry'); + +function normalizeModule(entry) { + if (typeof entry === 'string') return entry; + if (Array.isArray(entry)) { + const [name, opts] = entry; + if (opts && typeof opts === 'object') { + if (typeof opts.scope === 'string') return `${name}:${opts.scope}`; + const keys = Object.keys(opts).sort(); + if (keys.length) return `${name}:${keys.map((k) => `${k}=${String(opts[k])}`).join(',')}`; + } + return name; + } + return String(entry); +} + +function resolvePresetFromDist(ntrRoot, presetName) { + const distIndex = join(ntrRoot, 'dist', 'module-presets', 'index.js'); + if (!existsSync(distIndex)) return null; + let mod; + try { + // Dist is CommonJS; load it through createRequire (zero-dep, no top-level await). + mod = requireCjs(distIndex); + } catch { + return null; + } + const getPreset = mod.getModulePreset ?? mod.default?.getModulePreset; + const preset = getPreset?.(presetName); + if (!preset || !Array.isArray(preset.modules)) return null; + return preset.modules.map(normalizeModule); +} + +function presetSourceFiles(ntrRoot) { + const dir = join(ntrRoot, 'src', 'module-presets'); + const byName = new Map(); + if (!existsSync(dir)) return byName; + let entries; + try { + entries = readdirSync(dir); + } catch { + return byName; + } + for (const file of entries) { + if (!file.endsWith('.ts') || file === 'index.ts' || file === 'types.ts') continue; + let text; + try { + text = readFileSync(join(dir, file), 'utf8'); + } catch { + continue; + } + const nameMatch = text.match(/name:\s*'([^']+)'/); + if (nameMatch) byName.set(nameMatch[1], text); + } + return byName; +} + +function parseModulesBlock(text) { + const start = text.indexOf('modules:'); + if (start === -1) return null; + const open = text.indexOf('[', start); + if (open === -1) return null; + let depth = 0; + let end = -1; + for (let i = open; i < text.length; i++) { + if (text[i] === '[') depth++; + else if (text[i] === ']') { + depth--; + if (depth === 0) { + end = i; + break; + } + } + } + if (end === -1) return null; + const body = text.slice(open + 1, end); + const modules = []; + const tupleRe = /\[\s*'([^']+)'\s*,\s*\{([^}]*)\}\s*\]/g; + const consumed = []; + let m; + while ((m = tupleRe.exec(body)) !== null) { + const name = m[1]; + const optsBody = m[2]; + const scope = optsBody.match(/scope:\s*'([^']+)'/); + if (scope) modules.push(`${name}:${scope[1]}`); + else { + const kv = optsBody.match(/(\w+):\s*'?([^,'}]+)'?/); + modules.push(kv ? `${name}:${kv[1]}=${kv[2].trim()}` : name); + } + consumed.push([m.index, m.index + m[0].length]); + } + let plainSrc = body; + for (const [s, e] of consumed.reverse()) plainSrc = plainSrc.slice(0, s) + ' '.repeat(e - s) + plainSrc.slice(e); + const stringRe = /'([^']+)'/g; + while ((m = stringRe.exec(plainSrc)) !== null) modules.push(m[1]); + return modules; +} + +function resolvePresetFromSource(presetName, sourceMap, seen = new Set()) { + if (seen.has(presetName)) return []; + seen.add(presetName); + const text = sourceMap.get(presetName); + if (!text) return null; + const own = parseModulesBlock(text); + if (!own) return null; + const extendsMatch = text.match(/extends:\s*\[([^\]]*)\]/); + const parents = extendsMatch ? [...extendsMatch[1].matchAll(/'([^']+)'/g)].map((x) => x[1]) : []; + const merged = new Set(own); + for (const parent of parents) { + const parentMods = resolvePresetFromSource(parent, sourceMap, seen); + if (parentMods) for (const mod of parentMods) merged.add(mod); + } + return [...merged]; +} + +/** Returns { resolve(name)->string[]|null, via:'dist'|'regex-source'|null } or null if NTR unreachable. */ +function makePresetResolver(ntrRoot) { + if (!ntrRoot) return null; + const sourceMap = presetSourceFiles(ntrRoot); + let via = null; + const cache = new Map(); + function resolvePreset(name) { + if (cache.has(name)) return cache.get(name); + let mods = resolvePresetFromDist(ntrRoot, name); + if (mods && mods.length) { + via = via ?? 'dist'; + } else { + mods = resolvePresetFromSource(name, sourceMap); + if (mods && mods.length) via = 'regex-source'; + } + cache.set(name, mods && mods.length ? mods : null); + return cache.get(name); + } + return { resolvePreset, get via() { return via; } }; +} + +// --------------------------------------------------------------------------- +// payload loading +// --------------------------------------------------------------------------- +function loadPayload(file, label, { required }) { + if (!existsSync(file)) { + if (required) fail(2, `${label} not found at ${file}. Run from the skill repo root or pass --project / set the env override.`); + return null; + } + let bytes; + try { + bytes = readFileSync(file); + } catch (e) { + if (required) fail(2, `${label} unreadable (${file}): ${e.message}`); + return null; + } + let json; + try { + json = JSON.parse(bytes.toString('utf8')); + } catch (e) { + if (required) fail(2, `${label} is not valid JSON (${file}): ${e.message}`); + return null; + } + if (!Array.isArray(json.flows)) { + if (required) fail(2, `${label} has no \`flows\` array (${file}).`); + return null; + } + if (typeof json.sotHash !== 'string') { + if (required) fail(2, `${label} has no \`sotHash\` string (${file}).`); + return null; + } + return { file, bytes, json }; +} + +// --------------------------------------------------------------------------- +// locate SoT, harness, presets (env override -> findUp over candidate targets). +// relTargets may be a single string or an ordered list (first match wins) — the +// list covers worktree-name variants (e.g. `agentic-flow/` vs the actual +// `agentic-flow-blocks/` worktree dir) so the byte-twin harness copy resolves. +// --------------------------------------------------------------------------- +function locate(envVar, relTargets) { + const override = process.env[envVar]; + if (override) return isAbsolute(override) ? override : resolve(process.cwd(), override); + for (const rel of [].concat(relTargets)) { + const hit = findUp(scriptDir, rel); + if (hit) return hit; + } + return null; +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + console.log(HELP); + process.exit(0); + } + + // Skill copy (required). --project overrides where we look for it. + const skillFlowsPath = opts.project ? join(opts.project, '.agents', 'skills', 'constructive-blocks', 'references', 'flows.json') : SKILL_FLOWS; + const skill = loadPayload(skillFlowsPath, 'skill flows.json', { required: true }); + + // SoT (required) — the resolved artifact the generator wrote. + // Precedence: --sot (cwd-relative, used by apps/blocks `pnpm check:flows`) > FLOWS_SOT env > findUp. + const sotPath = opts.sot ?? locate('FLOWS_SOT', join('apps', 'blocks', 'src', 'flows', 'flows.json')); + if (!sotPath) { + fail( + 2, + `Could not locate the SoT flows.json (apps/blocks/src/flows/flows.json) via findUp from ${scriptDir}.\n` + + ` Set FLOWS_SOT=/abs/path/to/apps/blocks/src/flows/flows.json (e.g. the dashboard worktree) and re-run.` + ); + } + const sot = loadPayload(sotPath, 'SoT flows.json', { required: true }); + + // Harness copy (optional — skipped if not reachable). Probe both the canonical + // `agentic-flow/` name and the active `agentic-flow-blocks/` worktree dir. + const harnessPath = locate('FLOWS_HARNESS', [ + join('agentic-flow', 'references', 'flows.json'), + join('agentic-flow-blocks', 'references', 'flows.json') + ]); + const harness = harnessPath ? loadPayload(harnessPath, 'harness flows.json', { required: false }) : null; + + // Preset resolver (optional — referential-integrity modules⊆preset skipped if unreachable). + const ntrRoot = locate('FLOWS_PRESETS', NTR_REL); + const presetResolver = makePresetResolver(ntrRoot); + + const checks = []; + let failed = false; + const add = (name, ok, detail) => { + checks.push({ name, ok, detail }); + if (!ok) failed = true; + }; + + // 1. SoT self-consistency. + const sotRecomputed = sotHashOf(sot.json.flows); + add( + 'sot-self-consistent', + sotRecomputed === sot.json.sotHash, + sotRecomputed === sot.json.sotHash + ? `sotHash ${sot.json.sotHash.slice(0, 12)}… matches recomputed` + : `embedded ${sot.json.sotHash} != recomputed ${sotRecomputed} (SoT flows.json was hand-edited)` + ); + + // 2. skill copy sotHash === SoT sotHash (recompute skill too, belt-and-suspenders). + const skillRecomputed = sotHashOf(skill.json.flows); + add( + 'skill-self-consistent', + skillRecomputed === skill.json.sotHash, + skillRecomputed === skill.json.sotHash ? 'skill embedded sotHash matches its own flows' : `skill embedded ${skill.json.sotHash} != recomputed ${skillRecomputed}` + ); + add( + 'skill-matches-sot', + skill.json.sotHash === sot.json.sotHash, + skill.json.sotHash === sot.json.sotHash ? 'skill sotHash === SoT sotHash' : `skill ${skill.json.sotHash} != SoT ${sot.json.sotHash}` + ); + + // 3 + 4. harness checks (only if reachable). + if (harness) { + add( + 'harness-matches-sot', + harness.json.sotHash === sot.json.sotHash, + harness.json.sotHash === sot.json.sotHash ? 'harness sotHash === SoT sotHash' : `harness ${harness.json.sotHash} != SoT ${sot.json.sotHash}` + ); + add( + 'skill-equals-harness-bytes', + skill.bytes.equals(harness.bytes), + skill.bytes.equals(harness.bytes) ? 'skill flows.json bytes === harness flows.json bytes' : 'skill and harness flows.json are NOT byte-identical (one copy was regenerated without the other)' + ); + } + + // 5. referential integrity (per flow). Hash is over the SoT, but integrity is + // asserted on the skill copy (the artifact this repo ships). They share a + // hash, so this is equivalent; we report against what's shipped here. + const flowIds = new Set(skill.json.flows.map((f) => f.id)); + const integrity = []; + for (const flow of skill.json.flows) { + const problems = []; + if (flow.status !== 'ga') problems.push(`status='${flow.status}' (only 'ga' allowed)`); + if (!Array.isArray(flow.blocks) || flow.blocks.length === 0) problems.push('blocks[] empty'); + const preset = flow.backend?.preset; + const modules = flow.backend?.modules; + if (!preset) problems.push('backend.preset missing'); + if (!Array.isArray(modules) || modules.length === 0) problems.push('backend.modules[] empty'); + for (const rel of flow.relatedFlows ?? []) { + if (!flowIds.has(rel)) problems.push(`relatedFlows -> unknown flow '${rel}'`); + } + // modules ⊆ preset (only when the registry is reachable AND the preset resolves). + if (presetResolver && preset && Array.isArray(modules)) { + const presetMods = presetResolver.resolvePreset(preset); + if (presetMods === null) { + problems.push(`preset '${preset}' did not resolve from node-type-registry`); + } else { + const presetSet = new Set(presetMods); + const escapees = modules.filter((m) => !presetSet.has(m)); + if (escapees.length) problems.push(`modules not ⊆ preset '${preset}': [${escapees.join(', ')}]`); + } + } + integrity.push({ id: flow.id, ok: problems.length === 0, problems }); + } + const integrityOk = integrity.every((i) => i.ok); + const presetNote = presetResolver ? `via ${presetResolver.via ?? 'unresolved'}` : 'preset resolution SKIPPED (node-type-registry not reachable)'; + add('referential-integrity', integrityOk, integrityOk ? `${integrity.length} flows OK (${presetNote})` : `${integrity.filter((i) => !i.ok).length}/${integrity.length} flows have problems (${presetNote})`); + + // ------------------------------------------------------------------------- + // report + // ------------------------------------------------------------------------- + if (opts.json) { + console.log( + JSON.stringify( + { + ok: !failed, + skill: skillFlowsPath, + sot: sotPath, + harness: harnessPath ?? null, + harnessReachable: !!harness, + presetsRoot: ntrRoot ?? null, + presetResolutionVia: presetResolver?.via ?? null, + sotHash: sot.json.sotHash, + checks, + integrity + }, + null, + 2 + ) + ); + process.exit(failed ? 1 : 0); + } + + console.log(C.bold('\nConstructive Blocks — flow catalog drift guard\n')); + console.log(`${C.dim('skill ')} ${skillFlowsPath}`); + console.log(`${C.dim('sot ')} ${sotPath}`); + console.log(`${C.dim('harness')} ${harnessPath ? harnessPath : C.yellow('(not reachable — skipped)')}`); + console.log(`${C.dim('presets')} ${ntrRoot ? `${ntrRoot} ${C.dim(`(${presetResolver?.via ?? 'unresolved'})`)}` : C.yellow('(not reachable — modules⊆preset skipped)')}`); + console.log(`${C.dim('sotHash')} ${sot.json.sotHash}\n`); + + for (const c of checks) { + console.log(`${c.ok ? C.green('✓') : C.red('✗')} ${c.name} ${C.dim(`— ${c.detail}`)}`); + } + + if (!integrityOk) { + console.log(''); + for (const i of integrity.filter((x) => !x.ok)) { + console.log(` ${C.red('✗')} ${C.bold(i.id)}: ${i.problems.join('; ')}`); + } + } + + if (failed) { + console.log(C.red('\n✗ Flow catalog drift detected.')); + console.log(`\n ${C.bold(REMEDIATION)}`); + console.log( + C.dim( + '\n The catalog is generated from apps/blocks/scripts/flows-content.mjs.\n' + + ' Never hand-edit references/flows.json or references/flow-catalog.md — regenerate.' + ) + ); + process.exit(1); + } + + console.log(C.green('\n✓ Flow catalog in sync.')); + process.exit(0); +} + +main(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..af87f19 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "constructive-skills", + "version": "1.0.0", + "private": true, + "description": "AI coding-agent skills for the Constructive tooling ecosystem.", + "scripts": { + "check:flows": "node .agents/skills/constructive-blocks/scripts/check-flows.mjs" + } +} From fd49876a2116cbb3c87db4ef71d306e858bdcc24 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Mon, 1 Jun 2026 15:06:26 +0700 Subject: [PATCH 06/15] fix(constructive-blocks): tuple-form modules + drift-guard parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerate references/flows.json (+ flow-catalog.md) so backend.modules carries the preset's native ["name",{scope}] tuples — the form the provision proc accepts (colon "name:scope" strings throw NOT_FOUND). New sotHash a47970f72893f7483eb9ab844c3744a6403154ffa0a8c2b660980b87aafa631c. check-flows.mjs: the modules ⊆ preset referential check now normalizes BOTH sides to the display key, so a native tuple ["memberships_module",{scope:"app"}] matches the preset's memberships_module:app. All checks green; skill/harness flows.json byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../references/flow-catalog.md | 2 +- .../constructive-blocks/references/flows.json | 821 +++++++++++++++--- .../scripts/check-flows.mjs | 13 +- 3 files changed, 714 insertions(+), 122 deletions(-) diff --git a/.agents/skills/constructive-blocks/references/flow-catalog.md b/.agents/skills/constructive-blocks/references/flow-catalog.md index 8130aed..aa282a7 100644 --- a/.agents/skills/constructive-blocks/references/flow-catalog.md +++ b/.agents/skills/constructive-blocks/references/flow-catalog.md @@ -2,7 +2,7 @@ # Flow catalog -Source of truth: `apps/blocks/scripts/flows-content.mjs`. sotHash: `200f0da2506f2df472e016b5467d2c2f10aec5cd7d77a96ddf0d85be261c6bbb`. +Source of truth: `apps/blocks/scripts/flows-content.mjs`. sotHash: `a47970f72893f7483eb9ab844c3744a6403154ffa0a8c2b660980b87aafa631c`. Each flow is a backend-capability bundle: a preset to provision (resolved to a flat module list), the GraphQL operations it exposes, and the Blocks that wire the UI. GA-only. diff --git a/.agents/skills/constructive-blocks/references/flows.json b/.agents/skills/constructive-blocks/references/flows.json index 455dcd0..547dc12 100644 --- a/.agents/skills/constructive-blocks/references/flows.json +++ b/.agents/skills/constructive-blocks/references/flows.json @@ -1,7 +1,7 @@ { "generatedAt": null, "source": "apps/blocks/scripts/flows-content.mjs", - "sotHash": "200f0da2506f2df472e016b5467d2c2f10aec5cd7d77a96ddf0d85be261c6bbb", + "sotHash": "a47970f72893f7483eb9ab844c3744a6403154ffa0a8c2b660980b87aafa631c", "groups": [ { "id": "authentication", @@ -28,10 +28,30 @@ "modules": [ "users_module", "membership_types_module", - "permissions_module:app", - "limits_module:app", - "levels_module:app", - "memberships_module:app", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -78,10 +98,30 @@ "modules": [ "users_module", "membership_types_module", - "permissions_module:app", - "limits_module:app", - "levels_module:app", - "memberships_module:app", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -121,10 +161,30 @@ "modules": [ "users_module", "membership_types_module", - "permissions_module:app", - "limits_module:app", - "levels_module:app", - "memberships_module:app", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -166,10 +226,30 @@ "modules": [ "users_module", "membership_types_module", - "permissions_module:app", - "limits_module:app", - "levels_module:app", - "memberships_module:app", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -212,10 +292,30 @@ "modules": [ "users_module", "membership_types_module", - "permissions_module:app", - "limits_module:app", - "levels_module:app", - "memberships_module:app", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -253,10 +353,30 @@ "modules": [ "users_module", "membership_types_module", - "permissions_module:app", - "limits_module:app", - "levels_module:app", - "memberships_module:app", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -298,10 +418,30 @@ "modules": [ "users_module", "membership_types_module", - "permissions_module:app", - "limits_module:app", - "levels_module:app", - "memberships_module:app", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -342,10 +482,30 @@ "modules": [ "users_module", "membership_types_module", - "permissions_module:app", - "limits_module:app", - "levels_module:app", - "memberships_module:app", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -385,10 +545,30 @@ "modules": [ "users_module", "membership_types_module", - "permissions_module:app", - "limits_module:app", - "levels_module:app", - "memberships_module:app", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -427,10 +607,30 @@ "modules": [ "users_module", "membership_types_module", - "permissions_module:app", - "limits_module:app", - "levels_module:app", - "memberships_module:app", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -471,10 +671,30 @@ "modules": [ "users_module", "membership_types_module", - "permissions_module:app", - "limits_module:app", - "levels_module:app", - "memberships_module:app", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -514,10 +734,30 @@ "modules": [ "users_module", "membership_types_module", - "permissions_module:app", - "limits_module:app", - "levels_module:app", - "memberships_module:app", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -560,10 +800,30 @@ "modules": [ "users_module", "membership_types_module", - "permissions_module:app", - "limits_module:app", - "levels_module:app", - "memberships_module:app", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -603,8 +863,18 @@ "modules": [ "users_module", "membership_types_module", - "memberships_module:app", - "memberships_module:org", + [ + "memberships_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "org" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -619,17 +889,72 @@ "webauthn_credentials_module", "webauthn_auth_module", "phone_numbers_module", - "permissions_module:app", - "permissions_module:org", - "limits_module:app", - "limits_module:org", - "levels_module:app", - "levels_module:org", - "profiles_module:app", - "profiles_module:org", - "hierarchy_module:org", - "invites_module:app", - "invites_module:org", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "permissions_module", + { + "scope": "org" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "org" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "org" + } + ], + [ + "profiles_module", + { + "scope": "app" + } + ], + [ + "profiles_module", + { + "scope": "org" + } + ], + [ + "hierarchy_module", + { + "scope": "org" + } + ], + [ + "invites_module", + { + "scope": "app" + } + ], + [ + "invites_module", + { + "scope": "org" + } + ], "devices_module" ], "exposedOps": [ @@ -666,8 +991,18 @@ "modules": [ "users_module", "membership_types_module", - "memberships_module:app", - "memberships_module:org", + [ + "memberships_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "org" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -682,17 +1017,72 @@ "webauthn_credentials_module", "webauthn_auth_module", "phone_numbers_module", - "permissions_module:app", - "permissions_module:org", - "limits_module:app", - "limits_module:org", - "levels_module:app", - "levels_module:org", - "profiles_module:app", - "profiles_module:org", - "hierarchy_module:org", - "invites_module:app", - "invites_module:org", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "permissions_module", + { + "scope": "org" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "org" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "org" + } + ], + [ + "profiles_module", + { + "scope": "app" + } + ], + [ + "profiles_module", + { + "scope": "org" + } + ], + [ + "hierarchy_module", + { + "scope": "org" + } + ], + [ + "invites_module", + { + "scope": "app" + } + ], + [ + "invites_module", + { + "scope": "org" + } + ], "devices_module" ], "exposedOps": [ @@ -728,8 +1118,18 @@ "modules": [ "users_module", "membership_types_module", - "memberships_module:app", - "memberships_module:org", + [ + "memberships_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "org" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -744,17 +1144,72 @@ "webauthn_credentials_module", "webauthn_auth_module", "phone_numbers_module", - "permissions_module:app", - "permissions_module:org", - "limits_module:app", - "limits_module:org", - "levels_module:app", - "levels_module:org", - "profiles_module:app", - "profiles_module:org", - "hierarchy_module:org", - "invites_module:app", - "invites_module:org", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "permissions_module", + { + "scope": "org" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "org" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "org" + } + ], + [ + "profiles_module", + { + "scope": "app" + } + ], + [ + "profiles_module", + { + "scope": "org" + } + ], + [ + "hierarchy_module", + { + "scope": "org" + } + ], + [ + "invites_module", + { + "scope": "app" + } + ], + [ + "invites_module", + { + "scope": "org" + } + ], "devices_module" ], "exposedOps": [ @@ -788,8 +1243,18 @@ "modules": [ "users_module", "membership_types_module", - "memberships_module:app", - "memberships_module:org", + [ + "memberships_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "org" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -804,17 +1269,72 @@ "webauthn_credentials_module", "webauthn_auth_module", "phone_numbers_module", - "permissions_module:app", - "permissions_module:org", - "limits_module:app", - "limits_module:org", - "levels_module:app", - "levels_module:org", - "profiles_module:app", - "profiles_module:org", - "hierarchy_module:org", - "invites_module:app", - "invites_module:org", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "permissions_module", + { + "scope": "org" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "org" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "org" + } + ], + [ + "profiles_module", + { + "scope": "app" + } + ], + [ + "profiles_module", + { + "scope": "org" + } + ], + [ + "hierarchy_module", + { + "scope": "org" + } + ], + [ + "invites_module", + { + "scope": "app" + } + ], + [ + "invites_module", + { + "scope": "org" + } + ], "devices_module" ], "exposedOps": [ @@ -852,8 +1372,18 @@ "modules": [ "users_module", "membership_types_module", - "memberships_module:app", - "memberships_module:org", + [ + "memberships_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "org" + } + ], "sessions_module", "user_state_module", "user_credentials_module", @@ -868,17 +1398,72 @@ "webauthn_credentials_module", "webauthn_auth_module", "phone_numbers_module", - "permissions_module:app", - "permissions_module:org", - "limits_module:app", - "limits_module:org", - "levels_module:app", - "levels_module:org", - "profiles_module:app", - "profiles_module:org", - "hierarchy_module:org", - "invites_module:app", - "invites_module:org", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "permissions_module", + { + "scope": "org" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "org" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "org" + } + ], + [ + "profiles_module", + { + "scope": "app" + } + ], + [ + "profiles_module", + { + "scope": "org" + } + ], + [ + "hierarchy_module", + { + "scope": "org" + } + ], + [ + "invites_module", + { + "scope": "app" + } + ], + [ + "invites_module", + { + "scope": "org" + } + ], "devices_module" ], "exposedOps": [ diff --git a/.agents/skills/constructive-blocks/scripts/check-flows.mjs b/.agents/skills/constructive-blocks/scripts/check-flows.mjs index 2753fd4..70bd77b 100755 --- a/.agents/skills/constructive-blocks/scripts/check-flows.mjs +++ b/.agents/skills/constructive-blocks/scripts/check-flows.mjs @@ -46,7 +46,9 @@ * 3. harness copy (if reachable) sotHash === SoT sotHash. * 4. skill copy bytes === harness copy bytes (if reachable). * 5. referential integrity per flow: status==='ga', blocks non-empty, preset - * resolves from node-type-registry (if reachable), modules ⊆ preset. + * resolves from node-type-registry (if reachable), modules ⊆ preset + * (compared on the display key — flows.json modules are NATIVE strings + + * ["name",{scope}] tuples; the preset side is normalized to match). * * On any drift it prints the remediation: "re-run: (cd apps/blocks && pnpm gen:flows)". */ @@ -464,13 +466,18 @@ function main() { if (!flowIds.has(rel)) problems.push(`relatedFlows -> unknown flow '${rel}'`); } // modules ⊆ preset (only when the registry is reachable AND the preset resolves). + // flows.json carries NATIVE module entries (plain strings + ["name",{scope}] + // tuples — provisioning-ready); the preset resolver normalizes its entries to + // display strings. Compare on the shared display key (normalizeModule) so a + // tuple `["memberships_module",{scope:"app"}]` matches the preset's + // `memberships_module:app`. if (presetResolver && preset && Array.isArray(modules)) { const presetMods = presetResolver.resolvePreset(preset); if (presetMods === null) { problems.push(`preset '${preset}' did not resolve from node-type-registry`); } else { - const presetSet = new Set(presetMods); - const escapees = modules.filter((m) => !presetSet.has(m)); + const presetSet = new Set(presetMods.map(normalizeModule)); + const escapees = modules.map(normalizeModule).filter((m) => !presetSet.has(m)); if (escapees.length) problems.push(`modules not ⊆ preset '${preset}': [${escapees.join(', ')}]`); } } From 2f8e4a588f3bdd2aed6348c7fc1f1c2fdff525d7 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Mon, 1 Jun 2026 17:43:42 +0700 Subject: [PATCH 07/15] fix(constructive-blocks): singular-normalise check-sdk models + declared-pending ops; align field/security shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three friction fixes from the email-free verification wave (VERIFICATION-FINDINGS). F12 — check-sdk.mjs model check (the exit-1 on correctly-wired org blocks): - The ORM accessor and its models/.ts file are ALWAYS singular, but catalog manifests declare list models plural (orgMemberships, users, appMemberships, orgProfiles). Add singularizeModel() and key the SDK's model set through it; normalise the declared name the same way before matching. Plural-manifest, singular-manifest, and singular-file now collapse onto one key — so a manifest is correct whether the apps/blocks owner ships singular OR plural (the "make BOTH-correct" rule). - Support an optional manifest `pending` array: ops a block declares as backend-pending seams (transferOrgOwnership, removeOrgMember) are reported (◦ informational) but NEVER fail the check. A missing op NOT declared pending still fails clearly, so genuine wiring/stale-SDK errors are never masked. - Add a zero-dep node:test suite (check-sdk.test.mjs) + `test:sdk` script; doc the singular-normalisation + `pending` field in manifest-and-checks.md. Verified end-to-end against the real generated SDK + the shipped org manifests. F5 — constructive-sdk-fields: add a field-shape disambiguation note. db.field (metaschema) uses bare-string `type`/`defaultValue` (matches generated CreateFieldInput) and must stay that way; the OBJECT shape (type {name:'text'} + default {value:false}) belongs to the blueprint createBlueprint node, a different API. Clarify both (and the third secureTableProvision fields[] shape) so builders stop conflating them. F9 — constructive-sdk-security §5: rewrite secureTableProvision off the stale flat nodeType/grantRoles/grantPrivileges/policyType/policyData shape onto the Blueprint nodes[]/fields[]/grants[]/policies[] arrays that the generated CreateSecureTableProvisionInput actually exposes (matching the verified constructive-sdk example). Left the separate tableGrant/policy/applyRls metaschema APIs (§3/§4) untouched — those legitimately differ. flows.json / flow-catalog.md untouched; drift guard green (sotHash a47970f7). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../references/manifest-and-checks.md | 25 ++- .../constructive-blocks/scripts/check-sdk.mjs | 71 ++++++++- .../scripts/check-sdk.test.mjs | 148 ++++++++++++++++++ .../skills/constructive-sdk-fields/SKILL.md | 5 + .../skills/constructive-sdk-security/SKILL.md | 93 +++++------ package.json | 3 +- 6 files changed, 289 insertions(+), 56 deletions(-) create mode 100644 .agents/skills/constructive-blocks/scripts/check-sdk.test.mjs diff --git a/.agents/skills/constructive-blocks/references/manifest-and-checks.md b/.agents/skills/constructive-blocks/references/manifest-and-checks.md index 2e6c4bc..0a413e0 100644 --- a/.agents/skills/constructive-blocks/references/manifest-and-checks.md +++ b/.agents/skills/constructive-blocks/references/manifest-and-checks.md @@ -32,9 +32,30 @@ A block that imports from one namespace ships a single top-level object: | `namespace` | The generated namespace the block imports from (`auth`, `admin`, `objects`, `public`, or a custom API). Exactly the `` in `@/generated/`. | | `mutations` | GraphQL **operation names** the block calls — camelCase, post-inflection (`signIn`, not `SignIn`, not `useSignInMutation`). The check derives the hook name. | | `queries` | GraphQL query operation names, same convention. | -| `models` | Table **model accessors** the block needs — populated **only** when the block uses a `useQuery` list hook. Subject to the Connection rule (below). | +| `models` | Table **model accessors** the block needs — populated **only** when the block uses a `useQuery` list hook. Subject to the Connection rule (below). The ORM accessor is **singular** (`db.orgMembership`); prefer the singular name, but the check normalises plural↔singular so either form matches. | +| `pending` *(optional)* | Op/model names this block declares as **backend-pending** — a seam shipped for a proc not yet deployed in any public schema (e.g. `transferOrgOwnership`, `removeOrgMember`). The check **reports** these but never fails on them. A missing op that is **not** listed here still fails clearly. Omit when the block has no pending seam. Accepts a flat array `["transferOrgOwnership"]` or a per-kind object `{ "mutations": [...], "models": [...] }`. | -All four keys are present; unused ones are empty arrays. +The four core keys are present; unused ones are empty arrays. `pending` is optional. + +### Model names are singular-normalised + +A model accessor and its `models/.ts` file are **always singular** (the ORM exposes `db.orgMembership.findMany()`, never `db.orgMemberships`), even though the *list hook* it pairs with is plural (`useOrgMembershipsQuery`). The manifest's `models` entry names the **accessor**, so the canonical form is singular (`orgMembership`, `email`, `user`). `check-sdk.mjs` normalises both the declared name and the on-disk file name through one singulariser, so a manifest that declares `orgMemberships` (plural) and one that declares `orgMembership` (singular) **both** satisfy the same `models/orgMembership.ts` — author either, prefer singular. + +### Declaring a backend-pending seam + +Some GA blocks ship a button/path for a procedure that is real-but-not-yet-deployed (the "pending seams" called out in `flows.json` — `transferOrgOwnership`, `removeOrgMember`, `resendOrgInvite`). Such a block is still **correctly wired**: its GA path stands alone and the pending action degrades gracefully. List the pending op in `pending` so the preflight reports it as informational (`◦ … (backend-pending)`) instead of a hard `✗`: + +```json +{ + "namespace": "admin", + "mutations": ["updateOrgMembership", "deleteOrgMembership", "removeOrgMember", "transferOrgOwnership"], + "queries": [], + "models": ["orgMembership"], + "pending": ["removeOrgMember", "transferOrgOwnership"] +} +``` + +This keeps the check honest: declared-pending ops don't block a build, but any op the SDK lacks that is **not** declared pending still fails — so a genuine wiring/stale-SDK error is never masked. ## Schema — cross-namespace (locked shape) diff --git a/.agents/skills/constructive-blocks/scripts/check-sdk.mjs b/.agents/skills/constructive-blocks/scripts/check-sdk.mjs index 786157b..e44cb94 100755 --- a/.agents/skills/constructive-blocks/scripts/check-sdk.mjs +++ b/.agents/skills/constructive-blocks/scripts/check-sdk.mjs @@ -24,7 +24,14 @@ * What it verifies (per contract §9): * 1. the `@/generated/*` alias exists in the host tsconfig * 2. the generated dir for each block's namespace exists (resolved via alias) - * 3. every mutation/query/model in requires.json is an export of that SDK + * 3. every mutation/query/model in requires.json is an export of that SDK. + * Models are matched SINGULAR-insensitively: the ORM accessor (and its + * `models/.ts` file) is always singular, so a manifest may declare a + * list model plural (`orgMemberships`) or singular (`orgMembership`) — both + * satisfy the on-disk `models/orgMembership.ts`. Op/model names listed in a + * manifest's optional `pending` array are reported but NEVER fail the check + * (a block may ship a seam for a not-yet-deployed proc; a missing op that is + * NOT declared pending still fails clearly). * 5. (advisory) `@constructive/blocks-runtime` appears mounted somewhere * * Drift detection (§9.4) and generating a missing SDK (§9.6) require `cnc @@ -268,7 +275,14 @@ function collectSdk(sdkDir) { models.add(basename(file).replace(SRC_EXT, '')); } } - return { exports, models }; + // Singular comparison keys for every model file basename. The ORM exposes a + // SINGULAR accessor (`db.orgMembership`, file `models/orgMembership.ts`) even + // for list queries, so a manifest that declares the model in the plural + // (`orgMemberships`) must still match. Normalising BOTH the on-disk name and + // the declared name through the same singulariser collapses plural-manifest, + // singular-manifest, and singular-file onto one key — see §model check. + const modelKeys = new Set([...models].map(singularizeModel)); + return { exports, models, modelKeys }; } // op name (camelCase GraphQL op) → expected generated hook identifier. @@ -276,6 +290,26 @@ const pascal = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s); const mutationHook = (op) => `use${pascal(op)}Mutation`; const queryHook = (op) => `use${pascal(op)}Query`; +// Singularise a camelCase model accessor for comparison. The ORM accessor (and +// its `models/.ts` file) is ALWAYS singular, so a manifest may legally +// declare the model singular (`orgMembership`, `email`) or — as some catalog +// manifests do — plural (`orgMemberships`, `users`). Normalising every name +// through this one function makes both forms compare equal to the on-disk +// singular file (the "make both-correct" rule of the SDK Binding Contract). +// +// Only the trailing word is inflected (operates on the final char-run, so +// `orgMemberships` → `orgMembership`, not the leading `org`). Conservative: +// nouns that are already singular but end in a sibilant cluster are uncommon +// among generated accessors, and an over- or under-singularised key simply +// falls back to the exact-name check the caller also performs. +function singularizeModel(name) { + if (typeof name !== 'string' || name.length < 2) return name; + if (/[^aeiou]ies$/i.test(name)) return name.slice(0, -3) + 'y'; // identities → identity + if (/(?:ses|xes|zes|ches|shes)$/i.test(name)) return name.slice(0, -2); // boxes → box + if (/[^s]s$/i.test(name)) return name.slice(0, -1); // users → user, orgMemberships → orgMembership + return name; // address, status, email, phoneNumber — leave untouched +} + // --------------------------------------------------------------------------- // manifests: read .constructive/blocks/*.requires.json (or a named one). // A manifest is either a single { namespace, mutations, queries, models } @@ -341,7 +375,14 @@ function normalizeRequirements(raw) { namespace: r.namespace, mutations: r.mutations ?? [], queries: r.queries ?? [], - models: r.models ?? [] + models: r.models ?? [], + // Optional: op/model names the block declares as backend-PENDING — a seam + // it ships for a procedure not yet deployed in any public schema (e.g. + // `transferOrgOwnership`, `removeOrgMember`). These are reported but DO NOT + // fail the check: a correctly-wired block that merely carries a pending + // seam must not exit 1. A missing op that is NOT declared pending still + // fails clearly. Accepts a flat array or a per-kind { mutations, queries }. + pending: new Set([...(Array.isArray(r.pending) ? r.pending : []), ...(r.pending?.mutations ?? []), ...(r.pending?.queries ?? []), ...(r.pending?.models ?? [])]) })); } @@ -437,15 +478,22 @@ function main() { nsEntry.generatedDir = cached.dir; const checkOp = (op, kind, expected, present) => { - const ok = !!cached.sdk && present; - if (!ok) failed = true; - nsEntry.ops.push({ op, kind, expects: expected, ok }); + const satisfied = !!cached.sdk && present; + const pending = req.pending.has(op); + // A declared-pending op is informational: reported, never a failure — + // even when the SDK is present and the op is genuinely absent. Only a + // NON-pending unsatisfied op flips `failed`. + if (!satisfied && !pending) failed = true; + nsEntry.ops.push({ op, kind, expects: expected, ok: satisfied, pending }); }; for (const op of req.mutations) checkOp(op, 'mutation', mutationHook(op), cached.sdk?.exports.has(mutationHook(op))); for (const op of req.queries) checkOp(op, 'query', queryHook(op), cached.sdk?.exports.has(queryHook(op))); + // Model accessors are SINGULAR on disk; normalise the declared name (which + // may be plural) through the same singulariser used to key the SDK, then + // fall back to an exact export match for non-standard shapes. for (const mdl of req.models) - checkOp(mdl, 'model', `models/${mdl}`, cached.sdk?.models.has(mdl) || cached.sdk?.exports.has(mdl)); + checkOp(mdl, 'model', `models/${singularizeModel(mdl)}`, cached.sdk?.modelKeys.has(singularizeModel(mdl)) || cached.sdk?.exports.has(mdl)); blockEntry.namespaces.push(nsEntry); } @@ -472,7 +520,10 @@ function main() { ); if (!dirOk) missingNs.add(ns.namespace); for (const o of ns.ops) { - console.log(` ${o.ok ? C.green('✓') : C.red('✗')} ${o.kind} ${C.bold(o.op)} ${C.dim(`→ ${o.expects}`)}`); + // pending + absent → ◦ (informational); pending + present → ✓; else ✓/✗. + const mark = o.ok ? C.green('✓') : o.pending ? C.dim('◦') : C.red('✗'); + const note = o.pending && !o.ok ? C.dim(' (backend-pending — not yet deployed)') : ''; + console.log(` ${mark} ${o.kind} ${C.bold(o.op)} ${C.dim(`→ ${o.expects}`)}${note}`); } } } @@ -494,7 +545,11 @@ function main() { process.exit(1); } + const pendingSeams = report.flatMap((b) => b.namespaces.flatMap((n) => n.ops.filter((o) => o.pending && !o.ok).map((o) => o.op))); console.log(C.green('\n✓ All data-block prerequisites satisfied.')); + if (pendingSeams.length) { + console.log(C.dim(` (${pendingSeams.length} declared backend-pending seam(s): ${[...new Set(pendingSeams)].join(', ')} — the block's GA path stands alone until those procs ship.)`)); + } process.exit(0); } diff --git a/.agents/skills/constructive-blocks/scripts/check-sdk.test.mjs b/.agents/skills/constructive-blocks/scripts/check-sdk.test.mjs new file mode 100644 index 0000000..c799836 --- /dev/null +++ b/.agents/skills/constructive-blocks/scripts/check-sdk.test.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +/** + * Focused tests for check-sdk.mjs — the plural↔singular model normalisation + * and the declared-backend-pending op handling (F12). + * + * Zero deps, Node ≥18 built-in test runner: + * + * node --test .agents/skills/constructive-blocks/scripts/check-sdk.test.mjs + * + * Each case builds a throwaway host app (tsconfig + a tiny generated SDK whose + * model files are SINGULAR, mirroring the real ORM on-disk shape) plus a + * manifest, then runs check-sdk.mjs as a child process and asserts the exit + * code + a couple of report lines. The invariant under test is the SDK Binding + * Contract's "make BOTH-correct" rule: a manifest may declare a list model in + * the plural (`orgMemberships`) or singular (`orgMembership`) and either must + * satisfy the singular on-disk `models/orgMembership.ts`. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'check-sdk.mjs'); + +// Build a host-app fixture. `models` are SINGULAR file basenames (as codegen +// emits); `hooks` are the generated hook identifiers that exist. +function makeApp({ models = [], hooks = [], manifest }) { + const root = mkdtempSync(join(tmpdir(), 'check-sdk-')); + writeFileSync( + join(root, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/generated/*': ['./src/generated/*'] } } }) + ); + const modelsDir = join(root, 'src/generated/admin/orm/models'); + const hooksDir = join(root, 'src/generated/admin/hooks/mutations'); + mkdirSync(modelsDir, { recursive: true }); + mkdirSync(hooksDir, { recursive: true }); + for (const m of models) writeFileSync(join(modelsDir, `${m}.ts`), `export class ${m[0].toUpperCase()}${m.slice(1)}Model {}\n`); + for (const h of hooks) writeFileSync(join(hooksDir, `${h}.ts`), `export function ${h}() {}\n`); + const manifestDir = join(root, 'src/.constructive/blocks'); + mkdirSync(manifestDir, { recursive: true }); + writeFileSync(join(manifestDir, 'block.requires.json'), JSON.stringify(manifest)); + return root; +} + +function run(root) { + const r = spawnSync(process.execPath, [SCRIPT, '--project', root], { encoding: 'utf-8' }); + return { code: r.status, out: r.stdout + r.stderr }; +} + +const GA_HOOKS = ['useUpdateOrgMembershipMutation', 'useDeleteOrgMembershipMutation']; + +test('plural manifest model matches singular on-disk accessor (exit 0)', () => { + const root = makeApp({ + models: ['orgMembership'], + hooks: GA_HOOKS, + manifest: { namespace: 'admin', mutations: ['updateOrgMembership', 'deleteOrgMembership'], queries: [], models: ['orgMemberships'] } + }); + try { + const { code, out } = run(root); + assert.equal(code, 0, out); + assert.match(out, /model orgMemberships → models\/orgMembership/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('singular manifest model also matches (BOTH-correct, exit 0)', () => { + const root = makeApp({ + models: ['orgMembership'], + hooks: GA_HOOKS, + manifest: { namespace: 'admin', mutations: ['updateOrgMembership', 'deleteOrgMembership'], queries: [], models: ['orgMembership'] } + }); + try { + assert.equal(run(root).code, 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('-ies plural normalises to -y (identities → identity)', () => { + const root = makeApp({ + models: ['identity'], + hooks: [], + manifest: { namespace: 'admin', mutations: [], queries: [], models: ['identities'] } + }); + try { + assert.equal(run(root).code, 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('declared-pending op is informational, not a failure (exit 0)', () => { + const root = makeApp({ + models: ['orgMembership'], + hooks: GA_HOOKS, // removeOrgMember / transferOrgOwnership intentionally absent + manifest: { + namespace: 'admin', + mutations: ['updateOrgMembership', 'deleteOrgMembership', 'removeOrgMember', 'transferOrgOwnership'], + queries: [], + models: ['orgMemberships'], + pending: ['removeOrgMember', 'transferOrgOwnership'] + } + }); + try { + const { code, out } = run(root); + assert.equal(code, 0, out); + assert.match(out, /removeOrgMember.*backend-pending/); + assert.match(out, /declared backend-pending seam/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('a NON-pending missing op still fails (exit 1) — binding still protects', () => { + const root = makeApp({ + models: ['orgMembership'], + hooks: GA_HOOKS, + manifest: { namespace: 'admin', mutations: ['updateOrgMembership', 'totallyMissingOp'], queries: [], models: ['orgMembership'] } + }); + try { + const { code, out } = run(root); + assert.equal(code, 1, out); + assert.match(out, /✗ mutation totallyMissingOp/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('a pending op that IS present reports ✓, not suppressed (exit 0)', () => { + const root = makeApp({ + models: [], + hooks: ['useRemoveOrgMemberMutation'], + manifest: { namespace: 'admin', mutations: ['removeOrgMember'], queries: [], models: [], pending: ['removeOrgMember'] } + }); + try { + const { code, out } = run(root); + assert.equal(code, 0, out); + assert.match(out, /✓ mutation removeOrgMember/); + assert.doesNotMatch(out, /backend-pending/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/.agents/skills/constructive-sdk-fields/SKILL.md b/.agents/skills/constructive-sdk-fields/SKILL.md index 5b3593e..cea6ef6 100644 --- a/.agents/skills/constructive-sdk-fields/SKILL.md +++ b/.agents/skills/constructive-sdk-fields/SKILL.md @@ -72,6 +72,11 @@ const db = createClient({ ## Creating Fields +> **Field-shape contract — this is the `metaschema_public.field` API, not the blueprint.** +> On `db.field.create`/`db.field.update`, `type` is a **bare string** (`type: 'text'`, `type: 'numeric(10,2)'`) and `defaultValue` is a **SQL-expression string** (`defaultValue: "'draft'"`, `'uuid_generate_v4()'`). This matches the generated `CreateFieldInput` (`type: string`, `defaultValue?: string`) — do **not** wrap them in objects here. +> +> A **blueprint** field node (the `createBlueprint`/`constructBlueprint` GraphQL mutation in the provisioning APIs, `modules.localhost`) uses the **object** form instead — `type: { name: 'text' }` / `{ name: 'boolean' }` with `default: { value: false }` — and is **not** the same call as this SDK. Use the object shape only in a blueprint definition; use the bare-string shape (below) for the field SDK. The `secureTableProvision` `fields[]` array (see `constructive-sdk-security`) is a third shape again: bare-string `type` + snake_case keys (`{ name: 'title', type: 'text', is_required: true }`). + ### Basic Field Creation ```typescript diff --git a/.agents/skills/constructive-sdk-security/SKILL.md b/.agents/skills/constructive-sdk-security/SKILL.md index 2763eca..469ad81 100644 --- a/.agents/skills/constructive-sdk-security/SKILL.md +++ b/.agents/skills/constructive-sdk-security/SKILL.md @@ -138,52 +138,50 @@ Important reminder: ## 5) Primary method: `secureTableProvision` (recommended) -`secureTableProvision` can: -- add fields via `nodeType` (Data* modules) -- create grants (`grantRoles`, `grantPrivileges`) -- create policies (`policyType`, `policyData`, `policyPermissive`, `policyPrivileges`) -- enable RLS (`useRls`) +`secureTableProvision` takes the **Blueprint shape**: four independent, optional arrays, each entry discriminated by a `$type`: +- `nodes[]` — Data* field modules (`{ $type: 'DataEntityMembership' }`, optional `data`) +- `fields[]` — explicit columns (`{ name, type, is_required }`, **snake_case**, bare-string `type`) +- `grants[]` — per-role privilege targeting (`{ roles, privileges }`, privileges are `[privilege, columns]` tuples) +- `policies[]` — Authz* RLS policies (`{ $type, permissive, privileges, data }`) +- `useRls: true` — enable RLS + +> **The flat `nodeType` / `grantRoles` / `grantPrivileges` / `policyType` / `policyData` / `policyPermissive` / `policyPrivileges` shape is stale** and no longer matches the live platform (the generated `CreateSecureTableProvisionInput` exposes only `nodes` / `fields` / `grants` / `policies` / `useRls`). Use the arrays below. ### Example: Create an org-scoped table securely (fields + grants + policy + RLS) ```ts -// Wildcard grants (all columns): -const grant_privileges = [ - ['select', '*'], - ['insert', '*'], - ['update', '*'], - ['delete', '*'], -] as unknown as Record; - -// Field-level grants (restrict which columns each privilege applies to): -// const grant_privileges = [ -// ['select', '*'], // read all columns -// ['insert', ['name', 'bio', 'email']], // can only insert these columns -// ['update', ['name', 'bio']], // can only update these columns -// ] as unknown as Record; - -const policy_data: Record = { - entity_field: 'entity_id', - membership_type: 2, -}; - const provision = await db.secureTableProvision.create({ data: { databaseId: '', // schemaId is optional -- defaults to the database's app_public schema tableName: 'projects', - - // Fields: - nodeType: 'DataEntityMembership', - - // Security: useRls: true, - grantRoles: ['authenticated'], - grantPrivileges: grant_privileges, - policyType: 'AuthzEntityMembership', - policyPermissive: true, - policyData: policy_data, + // nodes[]: one entry per Data* field module (compose several in one call) + nodes: [ + { $type: 'DataEntityMembership' }, + ] as unknown as Record, + + // grants[]: each entry = roles + a privilege list of [privilege, columns] tuples. + // '*' = all columns; an array restricts the columns that privilege applies to. + grants: [ + { + roles: ['authenticated'], + privileges: [['select', '*'], ['insert', '*'], ['update', '*'], ['delete', '*']], + // Field-level example (restrict columns per privilege): + // privileges: [['select', '*'], ['insert', ['name', 'bio']], ['update', ['bio']]], + }, + ] as unknown as Record, + + // policies[]: one entry per Authz* policy, discriminated by $type + policies: [ + { + $type: 'AuthzEntityMembership', + permissive: true, + privileges: ['select', 'insert', 'update', 'delete'], + data: { entity_field: 'entity_id', membership_type: 2 }, + }, + ] as unknown as Record, }, select: { id: true, tableId: true, outFields: true }, }).execute(); @@ -191,17 +189,20 @@ const provision = await db.secureTableProvision.create({ const table_id = provision.createSecureTableProvision.secureTableProvision.tableId; ``` +> **Casting note:** `fields[]` is typed `Record[]` (an array), so a field literal assigns directly with no cast. `nodes` / `grants` / `policies` are typed as a single `Record`, so the array literal needs `as unknown as Record` (as shown). + ### Compose multiple provisions on the same table -Add timestamps after the fact (fields only): +Add timestamps after the fact (fields only) — target the same `tableId` with another `nodes[]` entry: ```ts await db.secureTableProvision.create({ data: { databaseId: '', tableId: '', - nodeType: 'DataTimestamps', - nodeData: { include_id: false }, + nodes: [ + { $type: 'DataTimestamps', data: { include_id: false } }, + ] as unknown as Record, }, select: { id: true }, }).execute(); @@ -216,13 +217,15 @@ await db.secureTableProvision.create({ data: { databaseId: '', tableId: '', - policyType: 'AuthzPublishable', - policyPermissive: true, - policyData: {}, - policyPrivileges: ['select'], // READ-only — never insert/update/delete - - // No grants in this row: - grantPrivileges: [] as unknown as Record, + // policies[]: one read-only entry; omit grants[] entirely when adding only a policy. + policies: [ + { + $type: 'AuthzPublishable', + permissive: true, + privileges: ['select'], // READ-only — never insert/update/delete + data: {}, + }, + ] as unknown as Record, }, select: { id: true }, }).execute(); diff --git a/package.json b/package.json index af87f19..7fdaba5 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "description": "AI coding-agent skills for the Constructive tooling ecosystem.", "scripts": { - "check:flows": "node .agents/skills/constructive-blocks/scripts/check-flows.mjs" + "check:flows": "node .agents/skills/constructive-blocks/scripts/check-flows.mjs", + "test:sdk": "node --test .agents/skills/constructive-blocks/scripts/check-sdk.test.mjs" } } From 51ad139cfc5b5569bd8e1dacef1a61002219d017 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 2 Jun 2026 22:39:11 +0700 Subject: [PATCH 08/15] chore(blocks): regenerate flow catalog (5 defect fixes from 11-flow fan-out) Regenerated references/flows.json + flow-catalog.md from the dashboard SoT (apps/blocks/scripts/flows-content.mjs). Captures: api-keys access-level enum (read_only|full_access, none|verified) + STEP_UP_REQUIRED note; profile reduced to minimal auth:email; org-members MembersList import + removeOrgMember dropped; org-roles gains org-create-card. check:flows GREEN. newSotHash 0a956c32d942088d59ebadeaa1d8b435abe1ceab2731f3bf98f35d07d1440f49 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../references/flow-catalog.md | 14 ++++---- .../constructive-blocks/references/flows.json | 32 +++++++++---------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/.agents/skills/constructive-blocks/references/flow-catalog.md b/.agents/skills/constructive-blocks/references/flow-catalog.md index aa282a7..49aab50 100644 --- a/.agents/skills/constructive-blocks/references/flow-catalog.md +++ b/.agents/skills/constructive-blocks/references/flow-catalog.md @@ -2,7 +2,7 @@ # Flow catalog -Source of truth: `apps/blocks/scripts/flows-content.mjs`. sotHash: `a47970f72893f7483eb9ab844c3744a6403154ffa0a8c2b660980b87aafa631c`. +Source of truth: `apps/blocks/scripts/flows-content.mjs`. sotHash: `0a956c32d942088d59ebadeaa1d8b435abe1ceab2731f3bf98f35d07d1440f49`. Each flow is a backend-capability bundle: a preset to provision (resolved to a flat module list), the GraphQL operations it exposes, and the Blocks that wire the UI. GA-only. @@ -87,17 +87,17 @@ npx shadcn@latest add auth-cross-origin-link ### Profile (`profile`) -Let the signed-in user edit their display name and avatar, with a settings page and a security-posture summary. +Let the signed-in user edit their display name and avatar against the auth:email user model. - **Preset:** `auth:email` - **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` - **Exposed ops:** `updateUser`, `currentUser` -- **Blocks:** `auth-account-profile-card`, `auth-account-settings-page`, `auth-account-security-card` +- **Blocks:** `auth-account-profile-card` Install: ```bash -npx shadcn@latest add auth-account-profile-card auth-account-settings-page auth-account-security-card +npx shadcn@latest add auth-account-profile-card ``` ### Account emails (`account-emails`) @@ -228,7 +228,7 @@ List an organization's members with inline role changes and step-up-gated remova - **Preset:** `b2b` - **Modules:** `users_module`, `membership_types_module`, `memberships_module:app`, `memberships_module:org`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `session_secrets_module`, `rate_limits_module`, `connected_accounts_module`, `identity_providers_module`, `webauthn_credentials_module`, `webauthn_auth_module`, `phone_numbers_module`, `permissions_module:app`, `permissions_module:org`, `limits_module:app`, `limits_module:org`, `levels_module:app`, `levels_module:org`, `profiles_module:app`, `profiles_module:org`, `hierarchy_module:org`, `invites_module:app`, `invites_module:org`, `devices_module` -- **Exposed ops:** `updateOrgMembership`, `deleteOrgMembership`, `removeOrgMember`, `orgMemberships` +- **Exposed ops:** `orgMemberships`, `updateOrgMembership`, `deleteOrgMembership` - **Blocks:** `org-members-list` Install: @@ -244,12 +244,12 @@ Create, edit, and delete named org role profiles that bundle the org-scoped perm - **Preset:** `b2b` - **Modules:** `users_module`, `membership_types_module`, `memberships_module:app`, `memberships_module:org`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `session_secrets_module`, `rate_limits_module`, `connected_accounts_module`, `identity_providers_module`, `webauthn_credentials_module`, `webauthn_auth_module`, `phone_numbers_module`, `permissions_module:app`, `permissions_module:org`, `limits_module:app`, `limits_module:org`, `levels_module:app`, `levels_module:org`, `profiles_module:app`, `profiles_module:org`, `hierarchy_module:org`, `invites_module:app`, `invites_module:org`, `devices_module` - **Exposed ops:** `createOrgProfile`, `updateOrgProfile`, `deleteOrgProfile` -- **Blocks:** `org-roles-editor` +- **Blocks:** `org-create-card`, `org-roles-editor` Install: ```bash -npx shadcn@latest add org-roles-editor +npx shadcn@latest add org-create-card org-roles-editor ``` ### Org invites (`org-invites`) diff --git a/.agents/skills/constructive-blocks/references/flows.json b/.agents/skills/constructive-blocks/references/flows.json index 547dc12..d581bba 100644 --- a/.agents/skills/constructive-blocks/references/flows.json +++ b/.agents/skills/constructive-blocks/references/flows.json @@ -1,7 +1,7 @@ { "generatedAt": null, "source": "apps/blocks/scripts/flows-content.mjs", - "sotHash": "a47970f72893f7483eb9ab844c3744a6403154ffa0a8c2b660980b87aafa631c", + "sotHash": "0a956c32d942088d59ebadeaa1d8b435abe1ceab2731f3bf98f35d07d1440f49", "groups": [ { "id": "authentication", @@ -347,7 +347,7 @@ "name": "Profile", "group": "account-session", "status": "ga", - "summary": "Let the signed-in user edit their display name and avatar, with a settings page and a security-posture summary.", + "summary": "Let the signed-in user edit their display name and avatar against the auth:email user model.", "backend": { "preset": "auth:email", "modules": [ @@ -391,14 +391,12 @@ ] }, "blocks": [ - "auth-account-profile-card", - "auth-account-settings-page", - "auth-account-security-card" + "auth-account-profile-card" ], "howto": { "provision": "# updateUser / currentUser ship with auth:email — no extra modules.\npgpm install", - "install": "npx shadcn@latest add auth-account-profile-card auth-account-settings-page auth-account-security-card", - "wire": "import AccountSettingsPage from '@/blocks/auth/account-settings-page/account-settings-page';\n\n// The settings page composes the profile + security cards (and more) for you.\nexport default function AccountPage() {\n return ;\n}", + "install": "npx shadcn@latest add auth-account-profile-card", + "wire": "import { AccountProfileCard } from '@/blocks/auth/account-profile-card/account-profile-card';\n\n// The profile card binds to updateUser/currentUser — only BlocksRuntime is required.\n// NOTE: auth-account-security-card (passkeys) and the auth-account-settings-page\n// composite hard-import ops OUTSIDE auth:email (webauthnCredentials, phoneNumbers/SMS,\n// connectedAccounts/SSO). They belong to a richer preset (auth:sso / b2b), not this\n// minimal profile flow — install them only once those modules are provisioned.\nexport default function AccountPage() {\n return ;\n}", "usage": "import { AccountProfileCard } from '@/blocks/auth/account-profile-card/account-profile-card';\n\n toast.success(\"Profile updated\")} />" }, "relatedFlows": [ @@ -652,8 +650,8 @@ "howto": { "provision": "# API-key CRUD ships with auth:email (user_auth_module) — no extra modules.\npgpm install", "install": "npx shadcn@latest add auth-account-api-keys-list auth-api-key-create-dialog auth-api-key-created-modal", - "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Create is gated behind a high-severity step-up; mount the provider once.\n{children}", - "usage": "import { AccountApiKeysList } from '@/blocks/auth/account-api-keys-list/account-api-keys-list';\n\n// No generated list hook for user_api_keys — supply rows via the `keys` prop.\n" + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Create is gated behind a high-severity step-up; mount the provider once.\n// The deployed create_api_key proc accepts ONLY:\n// accessLevel ∈ { 'read_only', 'full_access' } mfaLevel ∈ { 'none', 'verified' }\n// Any other value (read/write/admin, required) -> INVALID_ACCESS_LEVEL at runtime.\n// createApiKey also enforces STEP_UP_REQUIRED server-side: a verifyPassword on the\n// SAME session must precede the create. The dialog runs that step-up first; if you\n// call createApiKey directly, complete step-up (verifyPassword) before the mutation.\n{children}", + "usage": "import { AccountApiKeysList } from '@/blocks/auth/account-api-keys-list/account-api-keys-list';\n\n// No generated list hook for user_api_keys — supply rows via the `keys` prop.\n// Valid create inputs: accessLevel 'read_only' | 'full_access'; mfaLevel 'none' | 'verified'.\n" }, "relatedFlows": [ "step-up", @@ -779,7 +777,7 @@ "howto": { "provision": "# requireStepUp / verifyPassword ship with auth:email — no extra modules.\npgpm install", "install": "npx shadcn@latest add auth-step-up-dialog use-step-up", - "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Mount the provider once near the app root; consumers call useStepUp() below it.\n{children}", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Mount the provider once near the app root; consumers call useStepUp() below it.\n// Step-up resolves a verifyPassword/verifyTotp on the CURRENT session — server-side\n// gated ops (e.g. createApiKey enforces STEP_UP_REQUIRED) must be preceded by it.\n{children}", "usage": "import { useStepUp, StepUpError } from '@/blocks/auth/use-step-up/use-step-up';\n\nasync function onDangerousAction() {\n const stepUp = useStepUp();\n try {\n await stepUp({ tier: 'high' });\n await deleteAccount();\n } catch (err) {\n if (err instanceof StepUpError && err.reason === 'cancelled') return;\n throw err;\n }\n}" }, "relatedFlows": [ @@ -1086,10 +1084,9 @@ "devices_module" ], "exposedOps": [ + "orgMemberships", "updateOrgMembership", - "deleteOrgMembership", - "removeOrgMember", - "orgMemberships" + "deleteOrgMembership" ] }, "blocks": [ @@ -1099,7 +1096,7 @@ "provision": "# Org membership CRUD requires the b2b org-scoped memberships module — see Backend below.\npgpm install", "install": "npx shadcn@latest add org-members-list", "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Sensitive member actions require a step-up before the mutation fires.\n{children}", - "usage": "import { OrgMembersList } from '@/blocks/org/members-list/members-list';\n\n// transferOrgOwnership is a pending seam — the GA list + role-change + remove path stands alone.\n" + "usage": "import { MembersList } from '@/blocks/org/members-list/members-list';\n\n// GA path is updateOrgMembership (role change) + deleteOrgMembership (remove).\n// removeOrgMember / transferOrgOwnership are NOT deployed in the provisioned admin\n// schema yet — pending seams; do not call them.\n" }, "relatedFlows": [ "organization", @@ -1219,13 +1216,14 @@ ] }, "blocks": [ + "org-create-card", "org-roles-editor" ], "howto": { "provision": "# Org role profiles require the b2b org-scoped profiles module — see Backend below.\npgpm install", - "install": "npx shadcn@latest add org-roles-editor", - "wire": "// org-roles-editor binds to the generated admin SDK hooks — only BlocksRuntime is required.", - "usage": "import { OrgRolesEditor } from '@/blocks/org/roles-editor/roles-editor';\n\n" + "install": "npx shadcn@latest add org-create-card org-roles-editor", + "wire": "// Both blocks bind to the generated admin SDK hooks — only BlocksRuntime is required.", + "usage": "import { OrgCreateCard } from '@/blocks/org/create-card/create-card';\nimport { OrgRolesEditor } from '@/blocks/org/roles-editor/roles-editor';\n\n// OrgRolesEditor needs an orgId (a User row with type=2). Create the org first with\n// org-create-card, then pass its id to the editor.\n setOrgId(org.id)} />\n{orgId && }" }, "relatedFlows": [ "organization", From 37209e701ae6e1dcec1704232754d08a2903e616 Mon Sep 17 00:00:00 2001 From: Ha Gia Phat Date: Thu, 4 Jun 2026 23:06:38 +0700 Subject: [PATCH 09/15] chore(blocks): regenerate flow references with @constructive/ install ids Downstream sync of the flows install-namespace fix (sotHash db2c1caf); byte-identical to the SoT + harness copies, check:flows green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../references/flow-catalog.md | 56 ++++++++++++------- .../constructive-blocks/references/flows.json | 38 ++++++------- 2 files changed, 56 insertions(+), 38 deletions(-) diff --git a/.agents/skills/constructive-blocks/references/flow-catalog.md b/.agents/skills/constructive-blocks/references/flow-catalog.md index 49aab50..8fa5f79 100644 --- a/.agents/skills/constructive-blocks/references/flow-catalog.md +++ b/.agents/skills/constructive-blocks/references/flow-catalog.md @@ -2,7 +2,7 @@ # Flow catalog -Source of truth: `apps/blocks/scripts/flows-content.mjs`. sotHash: `0a956c32d942088d59ebadeaa1d8b435abe1ceab2731f3bf98f35d07d1440f49`. +Source of truth: `apps/blocks/scripts/flows-content.mjs`. sotHash: `db2c1caf05101d7d5d3a7f9c2ab64ac70debc1b991e87bb50e10858d6d70199b`. Each flow is a backend-capability bundle: a preset to provision (resolved to a flat module list), the GraphQL operations it exposes, and the Blocks that wire the UI. GA-only. @@ -20,7 +20,8 @@ The reference sign-in surface: register, sign in, sign out, and read the current Install: ```bash -npx shadcn@latest add auth-sign-in-card auth-sign-up-card auth-sign-out-button auth-sign-in-page auth-sign-up-page +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-sign-in-card @constructive/auth-sign-up-card @constructive/auth-sign-out-button @constructive/auth-sign-in-page @constructive/auth-sign-up-page ``` ### Email verification (`email-verification`) @@ -35,7 +36,8 @@ Confirm a user owns their email: a verify-link landing page plus a resend banner Install: ```bash -npx shadcn@latest add auth-verify-email-banner auth-verify-email-page +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-verify-email-banner @constructive/auth-verify-email-page ``` ### Password reset (`password-reset`) @@ -50,7 +52,8 @@ Forgot-password request plus the emailed reset-token landing — enumeration-saf Install: ```bash -npx shadcn@latest add auth-forgot-password-card auth-forgot-password-page auth-reset-password-card auth-reset-password-page +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-forgot-password-card @constructive/auth-forgot-password-page @constructive/auth-reset-password-card @constructive/auth-reset-password-page ``` ### Social / OAuth sign-in (`social-oauth`) @@ -65,7 +68,8 @@ Sign in with configured identity providers (Google, GitHub, …) rendered as a b Install: ```bash -npx shadcn@latest add auth-social-buttons auth-social-providers-grid +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-social-buttons @constructive/auth-social-providers-grid ``` ### Cross-origin sign-in (`cross-origin`) @@ -80,7 +84,8 @@ Hand an authenticated session to another origin via a short-lived one-time token Install: ```bash -npx shadcn@latest add auth-cross-origin-link +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-cross-origin-link ``` ## Account & session @@ -97,7 +102,8 @@ Let the signed-in user edit their display name and avatar against the auth:email Install: ```bash -npx shadcn@latest add auth-account-profile-card +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-account-profile-card ``` ### Account emails (`account-emails`) @@ -112,7 +118,8 @@ Manage the signed-in user's email addresses: add, verify, set primary, and remov Install: ```bash -npx shadcn@latest add auth-account-emails-list +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-account-emails-list ``` ### Change password (`change-password`) @@ -127,7 +134,8 @@ An authenticated, step-up-gated form to set a new password with an inline streng Install: ```bash -npx shadcn@latest add auth-change-password-form +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-change-password-form ``` ### Sessions (`sessions`) @@ -142,7 +150,8 @@ List the user's active sessions and revoke them individually or in bulk, gated b Install: ```bash -npx shadcn@latest add auth-account-sessions-list +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-account-sessions-list ``` ### API keys (`api-keys`) @@ -157,7 +166,8 @@ Create and revoke user-scoped API keys, with a one-time reveal modal and step-up Install: ```bash -npx shadcn@latest add auth-account-api-keys-list auth-api-key-create-dialog auth-api-key-created-modal +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-account-api-keys-list @constructive/auth-api-key-create-dialog @constructive/auth-api-key-created-modal ``` ### Account deletion (`account-deletion`) @@ -172,7 +182,8 @@ A danger-zone card that emails a deletion confirmation, plus the page that compl Install: ```bash -npx shadcn@latest add auth-account-danger-card auth-account-deletion-confirm-page +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-account-danger-card @constructive/auth-account-deletion-confirm-page ``` ### Step-up verification (`step-up`) @@ -187,7 +198,8 @@ Re-verify identity (password or TOTP) before a sensitive action, as a dialog or Install: ```bash -npx shadcn@latest add auth-step-up-dialog use-step-up +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-step-up-dialog @constructive/use-step-up ``` ### Connected accounts (`connected-accounts`) @@ -202,7 +214,8 @@ List linked OAuth providers and disconnect them (step-up gated); offer connect l Install: ```bash -npx shadcn@latest add auth-account-connected-accounts +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-account-connected-accounts ``` ## Authorization @@ -219,7 +232,8 @@ Create and configure organizations — first-class User records (type=2) in the Install: ```bash -npx shadcn@latest add org-create-card org-settings-form +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/org-create-card @constructive/org-settings-form ``` ### Org members (`org-members`) @@ -234,7 +248,8 @@ List an organization's members with inline role changes and step-up-gated remova Install: ```bash -npx shadcn@latest add org-members-list +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/org-members-list ``` ### Org roles (`org-roles`) @@ -249,7 +264,8 @@ Create, edit, and delete named org role profiles that bundle the org-scoped perm Install: ```bash -npx shadcn@latest add org-create-card org-roles-editor +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/org-create-card @constructive/org-roles-editor ``` ### Org invites (`org-invites`) @@ -264,7 +280,8 @@ Invite members to an org by email and let invitees accept app- or org-level invi Install: ```bash -npx shadcn@latest add org-invite-dialog auth-invitation-acceptance-card auth-invitation-acceptance-page +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/org-invite-dialog @constructive/auth-invitation-acceptance-card @constructive/auth-invitation-acceptance-page ``` ### App memberships (`app-memberships`) @@ -279,5 +296,6 @@ Admin-manage an org's app-level memberships: approve, revoke (step-up gated), an Install: ```bash -npx shadcn@latest add org-app-memberships +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/org-app-memberships ``` diff --git a/.agents/skills/constructive-blocks/references/flows.json b/.agents/skills/constructive-blocks/references/flows.json index d581bba..7312f18 100644 --- a/.agents/skills/constructive-blocks/references/flows.json +++ b/.agents/skills/constructive-blocks/references/flows.json @@ -1,7 +1,7 @@ { "generatedAt": null, "source": "apps/blocks/scripts/flows-content.mjs", - "sotHash": "0a956c32d942088d59ebadeaa1d8b435abe1ceab2731f3bf98f35d07d1440f49", + "sotHash": "db2c1caf05101d7d5d3a7f9c2ab64ac70debc1b991e87bb50e10858d6d70199b", "groups": [ { "id": "authentication", @@ -76,7 +76,7 @@ ], "howto": { "provision": "# Provision the auth:email modules onto your database (see Backend below for the full list).\npgpm install # or: provision via databaseProvisionModule.create({ data: { modules } })", - "install": "npx shadcn@latest add auth-sign-in-card auth-sign-up-card auth-sign-out-button auth-sign-in-page auth-sign-up-page", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-sign-in-card @constructive/auth-sign-up-card @constructive/auth-sign-out-button @constructive/auth-sign-in-page @constructive/auth-sign-up-page", "wire": "import { BlocksRuntime } from '@/blocks/runtime/blocks-runtime';\nimport { tokenManager } from '@/lib/auth/token-manager';\n\n// Mount once at the app root so every auth block resolves its hook.\n tokenManager.getAccessToken()}>\n {children}\n", "usage": "import { SignInCard } from '@/blocks/auth/sign-in-card/sign-in-card';\n\nexport function SignInRoute() {\n const router = useRouter();\n return (\n router.push(\"/\")}\n />\n );\n}" }, @@ -141,7 +141,7 @@ ], "howto": { "provision": "# auth:email already exposes verifyEmail / sendVerificationEmail — no extra modules.\npgpm install", - "install": "npx shadcn@latest add auth-verify-email-banner auth-verify-email-page", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-verify-email-banner @constructive/auth-verify-email-page", "wire": "import { VerifyEmailBanner } from '@/blocks/auth/verify-email-banner/verify-email-banner';\n\n// Show the banner in your app header for signed-in, unverified users.\nexport function AppHeader({ user }) {\n if (user.isVerified) return null;\n return ;\n}", "usage": "// Mount the page at /auth/verify-email; it reads ?email_id= and ?token= from the URL.\nimport { VerifyEmailPage } from '@/blocks/auth/verify-email-page/verify-email-page';\n\nexport default function Page() {\n return ;\n}" }, @@ -206,7 +206,7 @@ ], "howto": { "provision": "# forgotPassword / resetPassword ship with auth:email — no extra modules.\npgpm install", - "install": "npx shadcn@latest add auth-forgot-password-card auth-forgot-password-page auth-reset-password-card auth-reset-password-page", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-forgot-password-card @constructive/auth-forgot-password-page @constructive/auth-reset-password-card @constructive/auth-reset-password-page", "wire": "// Both pages are thin route wrappers — no extra wiring beyond BlocksRuntime.\n// forgot-password-page reads ?email=; reset-password-page reads ?token= and ?role_id=.", "usage": "import ForgotPasswordPage from '@/blocks/auth/forgot-password-page/forgot-password-page';\n\n// app/auth/forgot-password/page.tsx\nexport default function Page() {\n return ;\n}" }, @@ -272,7 +272,7 @@ ], "howto": { "provision": "# auth:sso adds connected_accounts_module + identity_providers_module (see Backend below).\npgpm install", - "install": "npx shadcn@latest add auth-social-buttons auth-social-providers-grid", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-social-buttons @constructive/auth-social-providers-grid", "wire": "import { AuthSocialButtons } from '@/blocks/auth/social-buttons/social-buttons';\n\n// Omit `providers` to load enabled providers from the identity-providers API at runtime.\n", "usage": "import { AuthSocialProvidersGrid } from '@/blocks/auth/social-providers-grid/social-providers-grid';\n\nexport function SignInExtras() {\n return ;\n}" }, @@ -334,7 +334,7 @@ ], "howto": { "provision": "# requestCrossOriginToken / signInCrossOrigin ship with auth:email — no extra modules.\npgpm install", - "install": "npx shadcn@latest add auth-cross-origin-link", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-cross-origin-link", "wire": "import { CrossOriginLink } from '@/blocks/auth/cross-origin-link/cross-origin-link';\n\n// Mount inside the same form that collected email/password.\n\n Continue to app\n", "usage": "import { CrossOriginLink } from '@/blocks/auth/cross-origin-link/cross-origin-link';\n\n" }, @@ -395,7 +395,7 @@ ], "howto": { "provision": "# updateUser / currentUser ship with auth:email — no extra modules.\npgpm install", - "install": "npx shadcn@latest add auth-account-profile-card", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-account-profile-card", "wire": "import { AccountProfileCard } from '@/blocks/auth/account-profile-card/account-profile-card';\n\n// The profile card binds to updateUser/currentUser — only BlocksRuntime is required.\n// NOTE: auth-account-security-card (passkeys) and the auth-account-settings-page\n// composite hard-import ops OUTSIDE auth:email (webauthnCredentials, phoneNumbers/SMS,\n// connectedAccounts/SSO). They belong to a richer preset (auth:sso / b2b), not this\n// minimal profile flow — install them only once those modules are provisioned.\nexport default function AccountPage() {\n return ;\n}", "usage": "import { AccountProfileCard } from '@/blocks/auth/account-profile-card/account-profile-card';\n\n toast.success(\"Profile updated\")} />" }, @@ -460,7 +460,7 @@ ], "howto": { "provision": "# emails_module + its CRUD ship with auth:email — no extra modules.\npgpm install", - "install": "npx shadcn@latest add auth-account-emails-list", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-account-emails-list", "wire": "import { AccountEmailsList } from '@/blocks/auth/account-emails-list/account-emails-list';\n\n", "usage": "import { AccountEmailsList } from '@/blocks/auth/account-emails-list/account-emails-list';\n\n" }, @@ -522,7 +522,7 @@ ], "howto": { "provision": "# setPassword / checkPassword ship with auth:email — no extra modules.\npgpm install", - "install": "npx shadcn@latest add auth-change-password-form", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-change-password-form", "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// The form runs a step-up re-verification, so a StepUpProvider must be an ancestor.\n{children}", "usage": "import { ChangePasswordForm } from '@/blocks/auth/change-password-form/change-password-form';\n\n toast(\"Password updated\")} />" }, @@ -585,7 +585,7 @@ ], "howto": { "provision": "# sessions_module + revokeSession ship with auth:email — no extra modules.\npgpm install", - "install": "npx shadcn@latest add auth-account-sessions-list", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-account-sessions-list", "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Single revoke is step-up tier=medium; revoke-all-others is tier=high.\n{children}", "usage": "import { AccountSessionsList } from '@/blocks/auth/account-sessions-list/account-sessions-list';\n\n// The session list has no generated list hook — supply rows via the `sessions` prop.\n" }, @@ -649,7 +649,7 @@ ], "howto": { "provision": "# API-key CRUD ships with auth:email (user_auth_module) — no extra modules.\npgpm install", - "install": "npx shadcn@latest add auth-account-api-keys-list auth-api-key-create-dialog auth-api-key-created-modal", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-account-api-keys-list @constructive/auth-api-key-create-dialog @constructive/auth-api-key-created-modal", "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Create is gated behind a high-severity step-up; mount the provider once.\n// The deployed create_api_key proc accepts ONLY:\n// accessLevel ∈ { 'read_only', 'full_access' } mfaLevel ∈ { 'none', 'verified' }\n// Any other value (read/write/admin, required) -> INVALID_ACCESS_LEVEL at runtime.\n// createApiKey also enforces STEP_UP_REQUIRED server-side: a verifyPassword on the\n// SAME session must precede the create. The dialog runs that step-up first; if you\n// call createApiKey directly, complete step-up (verifyPassword) before the mutation.\n{children}", "usage": "import { AccountApiKeysList } from '@/blocks/auth/account-api-keys-list/account-api-keys-list';\n\n// No generated list hook for user_api_keys — supply rows via the `keys` prop.\n// Valid create inputs: accessLevel 'read_only' | 'full_access'; mfaLevel 'none' | 'verified'.\n" }, @@ -712,7 +712,7 @@ ], "howto": { "provision": "# delete_account flow ships with auth:email (user_auth_module) — no extra modules.\npgpm install", - "install": "npx shadcn@latest add auth-account-danger-card auth-account-deletion-confirm-page", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-account-danger-card @constructive/auth-account-deletion-confirm-page", "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// The danger card gates the deletion email behind a high-tier step-up.\n{children}", "usage": "import { AccountDeletionConfirmPage } from '@/blocks/auth/account-deletion-confirm-page/account-deletion-confirm-page';\n\n// app/auth/delete-account/page.tsx — reads ?token= and ?user_id= from the link.\n" }, @@ -776,7 +776,7 @@ ], "howto": { "provision": "# requireStepUp / verifyPassword ship with auth:email — no extra modules.\npgpm install", - "install": "npx shadcn@latest add auth-step-up-dialog use-step-up", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-step-up-dialog @constructive/use-step-up", "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Mount the provider once near the app root; consumers call useStepUp() below it.\n// Step-up resolves a verifyPassword/verifyTotp on the CURRENT session — server-side\n// gated ops (e.g. createApiKey enforces STEP_UP_REQUIRED) must be preceded by it.\n{children}", "usage": "import { useStepUp, StepUpError } from '@/blocks/auth/use-step-up/use-step-up';\n\nasync function onDangerousAction() {\n const stepUp = useStepUp();\n try {\n await stepUp({ tier: 'high' });\n await deleteAccount();\n } catch (err) {\n if (err instanceof StepUpError && err.reason === 'cancelled') return;\n throw err;\n }\n}" }, @@ -841,7 +841,7 @@ ], "howto": { "provision": "# disconnectAccount + connected_accounts_module ship with auth:sso (see Backend below).\npgpm install", - "install": "npx shadcn@latest add auth-account-connected-accounts", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-account-connected-accounts", "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Disconnect is gated behind a step-up (tier: medium).\n{children}", "usage": "import { AccountConnectedAccounts } from '@/blocks/auth/account-connected-accounts/account-connected-accounts';\n\n// Connection types are not yet public — pass connectedAccounts + providers as props.\n" }, @@ -967,7 +967,7 @@ ], "howto": { "provision": "# Orgs require the full B2B stack (org-scoped memberships/permissions/invites/hierarchy).\n# There is no preset smaller than b2b for org flows — see Backend below.\npgpm install", - "install": "npx shadcn@latest add org-create-card org-settings-form", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/org-create-card @constructive/org-settings-form", "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// org-settings-form gates danger-zone deletion behind a step-up.\n{children}", "usage": "import { OrgCreateCard } from '@/blocks/org/create-card/create-card';\n\n// Creates a users row with type=2 (an organization).\n router.push(`/orgs/${org.id}`)} />" }, @@ -1094,7 +1094,7 @@ ], "howto": { "provision": "# Org membership CRUD requires the b2b org-scoped memberships module — see Backend below.\npgpm install", - "install": "npx shadcn@latest add org-members-list", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/org-members-list", "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Sensitive member actions require a step-up before the mutation fires.\n{children}", "usage": "import { MembersList } from '@/blocks/org/members-list/members-list';\n\n// GA path is updateOrgMembership (role change) + deleteOrgMembership (remove).\n// removeOrgMember / transferOrgOwnership are NOT deployed in the provisioned admin\n// schema yet — pending seams; do not call them.\n" }, @@ -1221,7 +1221,7 @@ ], "howto": { "provision": "# Org role profiles require the b2b org-scoped profiles module — see Backend below.\npgpm install", - "install": "npx shadcn@latest add org-create-card org-roles-editor", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/org-create-card @constructive/org-roles-editor", "wire": "// Both blocks bind to the generated admin SDK hooks — only BlocksRuntime is required.", "usage": "import { OrgCreateCard } from '@/blocks/org/create-card/create-card';\nimport { OrgRolesEditor } from '@/blocks/org/roles-editor/roles-editor';\n\n// OrgRolesEditor needs an orgId (a User row with type=2). Create the org first with\n// org-create-card, then pass its id to the editor.\n setOrgId(org.id)} />\n{orgId && }" }, @@ -1349,7 +1349,7 @@ ], "howto": { "provision": "# Invite flows require the b2b invites modules (app + org scope) — see Backend below.\npgpm install", - "install": "npx shadcn@latest add org-invite-dialog auth-invitation-acceptance-card auth-invitation-acceptance-page", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/org-invite-dialog @constructive/auth-invitation-acceptance-card @constructive/auth-invitation-acceptance-page", "wire": "import { OrgInviteDialog } from '@/blocks/org/invite-dialog/invite-dialog';\n\n// resendOrgInvite is pending — the dialog resends by cancel + re-create.\n", "usage": "import InvitationAcceptancePage from '@/blocks/auth/invitation-acceptance-page/invitation-acceptance-page';\n\n// app/invite/page.tsx — reads ?token= and ?kind= from the URL.\nexport default function Page() {\n return ;\n}" }, @@ -1475,7 +1475,7 @@ ], "howto": { "provision": "# App membership management requires the b2b app-scoped memberships module — see Backend below.\npgpm install", - "install": "npx shadcn@latest add org-app-memberships", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/org-app-memberships", "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Revoke is gated behind a confirmation dialog + step-up (tier: medium).\n{children}", "usage": "import { OrgAppMemberships } from '@/blocks/org/app-memberships/app-memberships';\n\n" }, From c4150e769d3846a3f47c1c6de6019419cedd5d35 Mon Sep 17 00:00:00 2001 From: Ha Gia Phat Date: Thu, 4 Jun 2026 23:31:06 +0700 Subject: [PATCH 10/15] chore(blocks): regenerate flow refs with block-backend contract notes Downstream sync of P1-CONTRACT-CATALOG (sotHash e0e94325); byte-identical to SoT + harness, check:flows green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../references/flow-catalog.md | 8 +++++++- .../constructive-blocks/references/flows.json | 20 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.agents/skills/constructive-blocks/references/flow-catalog.md b/.agents/skills/constructive-blocks/references/flow-catalog.md index 8fa5f79..dc0497d 100644 --- a/.agents/skills/constructive-blocks/references/flow-catalog.md +++ b/.agents/skills/constructive-blocks/references/flow-catalog.md @@ -2,7 +2,7 @@ # Flow catalog -Source of truth: `apps/blocks/scripts/flows-content.mjs`. sotHash: `db2c1caf05101d7d5d3a7f9c2ab64ac70debc1b991e87bb50e10858d6d70199b`. +Source of truth: `apps/blocks/scripts/flows-content.mjs`. sotHash: `e0e943259833ed66bc1649ebbccc0fce8e93d9e617607f3547849b4fc8853d22`. Each flow is a backend-capability bundle: a preset to provision (resolved to a flat module list), the GraphQL operations it exposes, and the Blocks that wire the UI. GA-only. @@ -146,6 +146,9 @@ List the user's active sessions and revoke them individually or in bulk, gated b - **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` - **Exposed ops:** `revokeSession`, `extendTokenExpires` - **Blocks:** `auth-account-sessions-list` +- **Contract:** Single revoke is step-up tier=medium; revoke-all-others is tier=high — both must complete a step-up before the mutation fires. +- **Contract:** No generated list hook for sessions — supply rows via the `sessions` prop; the block lists but does not fetch. +- **Known backend limitation:** revokeSession is uncallable from the auth result: the id on a signUp/signIn result is a UUIDv5 identity/credential id, not the UUIDv7 sessions.id, and no field exposes the real session id — so revokeSession(authResult.id) returns SESSION_NOT_FOUND. Ship revoke-current-session as backend-pending; do NOT hand-craft a session id or fall back to SQL. (PLATFORM-GAPS GAP-2) Install: @@ -162,6 +165,9 @@ Create and revoke user-scoped API keys, with a one-time reveal modal and step-up - **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` - **Exposed ops:** `createApiKey`, `revokeApiKey` - **Blocks:** `auth-account-api-keys-list`, `auth-api-key-create-dialog`, `auth-api-key-created-modal` +- **Contract:** createApiKey accessLevel accepts ONLY { 'read_only', 'full_access' } — any other value (read/write/admin, required) fails with INVALID_ACCESS_LEVEL at runtime; the auth-api-key-create-dialog block ships an accessLevelOptions list (read/write/admin) that does NOT match the deployed proc, so constrain the UI to the two valid values. +- **Contract:** createApiKey enforces STEP_UP_REQUIRED server-side: a verifyPassword on the SAME session must precede the create (defense-in-depth beyond the client gate). The dialog runs that step-up first; a direct createApiKey call must complete step-up before the mutation. +- **Known backend limitation:** revokeApiKey returns true and writes an audit-log entry but never sets revoked_at — the key keeps working. Treat its true as a no-op, not proof of revocation; do not surface "revoked" as terminal state. (PLATFORM-GAPS GAP-3) Install: diff --git a/.agents/skills/constructive-blocks/references/flows.json b/.agents/skills/constructive-blocks/references/flows.json index 7312f18..20b82b0 100644 --- a/.agents/skills/constructive-blocks/references/flows.json +++ b/.agents/skills/constructive-blocks/references/flows.json @@ -1,7 +1,7 @@ { "generatedAt": null, "source": "apps/blocks/scripts/flows-content.mjs", - "sotHash": "db2c1caf05101d7d5d3a7f9c2ab64ac70debc1b991e87bb50e10858d6d70199b", + "sotHash": "e0e943259833ed66bc1649ebbccc0fce8e93d9e617607f3547849b4fc8853d22", "groups": [ { "id": "authentication", @@ -589,6 +589,15 @@ "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Single revoke is step-up tier=medium; revoke-all-others is tier=high.\n{children}", "usage": "import { AccountSessionsList } from '@/blocks/auth/account-sessions-list/account-sessions-list';\n\n// The session list has no generated list hook — supply rows via the `sessions` prop.\n" }, + "contract": { + "constraints": [ + "Single revoke is step-up tier=medium; revoke-all-others is tier=high — both must complete a step-up before the mutation fires.", + "No generated list hook for sessions — supply rows via the `sessions` prop; the block lists but does not fetch." + ], + "knownBackendLimitations": [ + "revokeSession is uncallable from the auth result: the id on a signUp/signIn result is a UUIDv5 identity/credential id, not the UUIDv7 sessions.id, and no field exposes the real session id — so revokeSession(authResult.id) returns SESSION_NOT_FOUND. Ship revoke-current-session as backend-pending; do NOT hand-craft a session id or fall back to SQL. (PLATFORM-GAPS GAP-2)" + ] + }, "relatedFlows": [ "step-up", "profile" @@ -653,6 +662,15 @@ "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Create is gated behind a high-severity step-up; mount the provider once.\n// The deployed create_api_key proc accepts ONLY:\n// accessLevel ∈ { 'read_only', 'full_access' } mfaLevel ∈ { 'none', 'verified' }\n// Any other value (read/write/admin, required) -> INVALID_ACCESS_LEVEL at runtime.\n// createApiKey also enforces STEP_UP_REQUIRED server-side: a verifyPassword on the\n// SAME session must precede the create. The dialog runs that step-up first; if you\n// call createApiKey directly, complete step-up (verifyPassword) before the mutation.\n{children}", "usage": "import { AccountApiKeysList } from '@/blocks/auth/account-api-keys-list/account-api-keys-list';\n\n// No generated list hook for user_api_keys — supply rows via the `keys` prop.\n// Valid create inputs: accessLevel 'read_only' | 'full_access'; mfaLevel 'none' | 'verified'.\n" }, + "contract": { + "constraints": [ + "createApiKey accessLevel accepts ONLY { 'read_only', 'full_access' } — any other value (read/write/admin, required) fails with INVALID_ACCESS_LEVEL at runtime; the auth-api-key-create-dialog block ships an accessLevelOptions list (read/write/admin) that does NOT match the deployed proc, so constrain the UI to the two valid values.", + "createApiKey enforces STEP_UP_REQUIRED server-side: a verifyPassword on the SAME session must precede the create (defense-in-depth beyond the client gate). The dialog runs that step-up first; a direct createApiKey call must complete step-up before the mutation." + ], + "knownBackendLimitations": [ + "revokeApiKey returns true and writes an audit-log entry but never sets revoked_at — the key keeps working. Treat its true as a no-op, not proof of revocation; do not surface \"revoked\" as terminal state. (PLATFORM-GAPS GAP-3)" + ] + }, "relatedFlows": [ "step-up", "profile" From 3aaef1b1288ae8cc4ef469160ad26fdd47728e7e Mon Sep 17 00:00:00 2001 From: Ha Gia Phat Date: Fri, 5 Jun 2026 09:04:42 +0700 Subject: [PATCH 11/15] docs(skills): Blocks<->domain-UI scope boundary (blocks=auth/account/org/shell; domain CRUD=constructive-frontend) constructive-blocks/SKILL.md gains a scope-boundary paragraph (68 blocks + 18 flows = auth/account/org/shell capability bundles, NOT a general app-flow library; domain-entity CRUD UI = constructive-frontend CRUD Stack + _meta meta-forms, automated by scaffold-frontend.mjs) + cross-ref; constructive-frontend/SKILL.md cross-refs back. Regenerated flow-catalog.md carries the scope note. So an agent reading either skill in isolation isn't misled toward the auth-only catalog for domain UI. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/skills/constructive-blocks/SKILL.md | 4 +++- .agents/skills/constructive-blocks/references/flow-catalog.md | 2 ++ .agents/skills/constructive-frontend/SKILL.md | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.agents/skills/constructive-blocks/SKILL.md b/.agents/skills/constructive-blocks/SKILL.md index a2e027a..2883866 100644 --- a/.agents/skills/constructive-blocks/SKILL.md +++ b/.agents/skills/constructive-blocks/SKILL.md @@ -30,6 +30,8 @@ Use this skill when: - **Host wiring**: aliasing `@/generated/*`, mounting ``, adding a namespace to the runtime, generating a missing SDK with `cnc codegen`. - **Authoring a block**: writing a new block that calls a generated hook, choosing its namespace, declaring its `requires.json`, adding the override seam. +**Scope boundary — Blocks are auth/account/org/shell ONLY.** The catalogued blocks and flows cover **auth, account, organization, and app-shell** capability bundles (sign-in, password reset, MFA, membership, invites, settings). They are **not** a general application-flow library. For your **domain-entity CRUD UI** — the React UI over your own business tables — use **`constructive-frontend`** (CRUD Stack cards + runtime-generic `_meta` meta-forms), **not** blocks; the harness automates that path via `scripts/scaffold-frontend.mjs`. A "flow" here answers *"which auth flow?"*, never *"which business workflow?"*. + If the request is about generating the SDK itself, defer to the codegen skills — this skill *consumes* that SDK: **`constructive-codegen`** (codegen CLI/config flags), **`constructive-hooks`** / **`constructive-orm`** (generated hook/ORM output shapes, pagination), **`constructive-search`** (search). ## Host setup — three steps (once per app) @@ -260,5 +262,5 @@ UI is built on `@constructive-io/ui` (consumed as an npm dependency — **never* ## Cross-References - `constructive-codegen` / `constructive-hooks` / `constructive-orm` / `constructive-search` — generating the SDK this skill consumes: `cnc codegen` flags, hook/ORM output shapes, selection/pagination/search. -- `constructive-frontend` — the `@constructive-io/ui` component library blocks are built on. +- `constructive-frontend` — the `@constructive-io/ui` component library blocks are built on, **and** the home of domain-entity CRUD UI (CRUD Stack + `_meta` meta-forms, scaffolded by `scaffold-frontend.mjs`). Reach for it for business-table UI; reach for blocks for auth/account/org/shell. - `constructive-platform` — CNC CLI, server config, API/endpoint deployment (what determines which ops a namespace exposes). diff --git a/.agents/skills/constructive-blocks/references/flow-catalog.md b/.agents/skills/constructive-blocks/references/flow-catalog.md index dc0497d..58619b1 100644 --- a/.agents/skills/constructive-blocks/references/flow-catalog.md +++ b/.agents/skills/constructive-blocks/references/flow-catalog.md @@ -6,6 +6,8 @@ Source of truth: `apps/blocks/scripts/flows-content.mjs`. sotHash: `e0e943259833 Each flow is a backend-capability bundle: a preset to provision (resolved to a flat module list), the GraphQL operations it exposes, and the Blocks that wire the UI. GA-only. +**Scope:** Flows are auth, account, and organization capability bundles — the identity/membership surface. They are NOT general app flows and do NOT cover your domain data UI. For YOUR business-entity screens (list/create/edit/delete of your tables), build domain UI from the data model with constructive-frontend (CRUD Stack + _meta meta-forms) — automated by the harness’s scripts/scaffold-frontend.mjs (Phase 4). + ## Authentication ### Email + password (`email-password`) diff --git a/.agents/skills/constructive-frontend/SKILL.md b/.agents/skills/constructive-frontend/SKILL.md index c00b4e7..b6a5e4e 100644 --- a/.agents/skills/constructive-frontend/SKILL.md +++ b/.agents/skills/constructive-frontend/SKILL.md @@ -20,6 +20,8 @@ Use this skill when: - Setting up theming, dark mode, OKLCH tokens - Using the shadcn registry for Constructive components +This skill is for **YOUR domain-entity CRUD** — UI over any business table, via CRUD Stack + `_meta` meta-forms. For **auth/account/org/shell** capability UI (sign-in, password reset, MFA, membership, invites) use **`constructive-blocks`** (the flow catalog) instead. + ## UI Components 50+ components on Base UI + Tailwind CSS v4 with cva variants and data-slot architecture. @@ -80,6 +82,7 @@ See [meta-forms.md](./references/meta-forms.md) for DynamicFormCard, locked FK p ## Cross-References +- `constructive-blocks` — auth/account/org/shell capability UI (copy-in blocks + flow catalog); use it for those bundles, this skill for domain-entity CRUD over any table. - `constructive-codegen` — Code generation and SDK usage (data fetching for components) - `pgpm` — Starter kits and Next.js app boilerplate (uses these UI components) — in [constructive-io/constructive](https://github.com/constructive-io/constructive) - `constructive-platform` — Platform core, server configuration From cfb147a52e27521ab6f7ca3c6aa864823e170d61 Mon Sep 17 00:00:00 2001 From: Ha Gia Phat Date: Fri, 5 Jun 2026 10:57:08 +0700 Subject: [PATCH 12/15] fix(check-sdk): only fail on backend symbols the block actually imports, not declared-but-unimported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #11 — Phase-2.6 check-sdk was over-strict (e.g. org-members-list declares removeOrgMember/transferOrgOwnership it doesn't import + this constructive-db doesn't expose). Now gates the hard-fail on import-presence; declared-but-unimported -> backend-pending warn, matching SKILL S4d. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../constructive-blocks/scripts/check-sdk.mjs | 124 ++++++++++++++++-- 1 file changed, 112 insertions(+), 12 deletions(-) diff --git a/.agents/skills/constructive-blocks/scripts/check-sdk.mjs b/.agents/skills/constructive-blocks/scripts/check-sdk.mjs index e44cb94..82f33b6 100755 --- a/.agents/skills/constructive-blocks/scripts/check-sdk.mjs +++ b/.agents/skills/constructive-blocks/scripts/check-sdk.mjs @@ -28,10 +28,19 @@ * Models are matched SINGULAR-insensitively: the ORM accessor (and its * `models/.ts` file) is always singular, so a manifest may declare a * list model plural (`orgMemberships`) or singular (`orgMembership`) — both - * satisfy the on-disk `models/orgMembership.ts`. Op/model names listed in a - * manifest's optional `pending` array are reported but NEVER fail the check - * (a block may ship a seam for a not-yet-deployed proc; a missing op that is - * NOT declared pending still fails clearly). + * satisfy the on-disk `models/orgMembership.ts`. + * + * IMPORT-PRESENCE GATE: a missing op only HARD-FAILS when the block actually + * IMPORTS the hook that op maps to (from `@/generated/*` in the host source) + * — i.e. a genuine compile-against-a-missing-export. A manifest routinely + * declares the full capability surface (so the catalog is honest), but a + * block degrades when an op isn't deployed and simply never imports its hook + * (e.g. `org-members-list` declares removeOrgMember/transferOrgOwnership yet + * imports neither, referencing them only in comments/override seams). Such a + * declared-but-unimported op is reported as backend-pending, NEVER a failure. + * Op/model names listed in a manifest's optional `pending` array are likewise + * reported but never fail. (A wholly-missing generated dir/alias still fails + * independently — see §1–2.) * 5. (advisory) `@constructive/blocks-runtime` appears mounted somewhere * * Drift detection (§9.4) and generating a missing SDK (§9.6) require `cnc @@ -402,6 +411,68 @@ function runtimeMounted(projectRoot) { return false; } +// --------------------------------------------------------------------------- +// imported generated symbols: which `@/generated/*` identifiers does the host +// source ACTUALLY import? (§9 import-presence gate.) +// +// The gate hard-fails only on ops a block genuinely IMPORTS — not ops merely +// DECLARED in its requires.json. A correctly-wired block routinely declares the +// full capability surface in its manifest yet degrades when an op isn't +// deployed: `org-members-list` declares removeOrgMember + transferOrgOwnership +// but imports only useUpdateOrgMembershipMutation / useDeleteOrgMembershipMutation, +// referencing the absent procs solely in comments/override seams. Such a block +// compiles and runs; failing it would be a false negative. +// +// So we scan for the bindings actually pulled from a `@/generated/...` module +// and key the hard-fail on import-presence. Detection is STATEMENT-AWARE: only +// the named/default/namespace bindings of a real `import … from '@/generated/…'` +// are collected. A symbol that appears only in a comment or doc block (e.g. +// "useTransferOrgOwnershipMutation does NOT exist yet") is NOT an import and is +// never counted — otherwise the comment alone would re-introduce the false fail. +// +// We collect into one project-wide set (the SOURCE name, post-`as`-rename, so an +// `import { useFooMutation as foo }` still registers `useFooMutation`). Keying by +// the generated name rather than per-file keeps it robust to barrel re-exports +// and is sufficient: the check asks "is the hook this op maps to imported from +// the SDK anywhere?", which is exactly the compile-against-a-missing-export risk. +const GEN_IMPORT_RE = /import\s+([^;'"]*?)\s+from\s+['"]@\/generated\/[^'"]*['"]/g; + +function collectGeneratedImports(projectRoot) { + const src = join(projectRoot, 'src'); + const root = existsSync(src) ? src : projectRoot; + const imported = new Set(); + for (const file of walk(root)) { + let txt; + try { + txt = readFileSync(file, 'utf-8'); + } catch { + continue; + } + let m; + GEN_IMPORT_RE.lastIndex = 0; + while ((m = GEN_IMPORT_RE.exec(txt))) { + // clause = whatever sits between `import` and `from '@/generated/…'`: + // { a, b as c, type D } | Foo | * as NS | Foo, { a } + let clause = m[1].trim(); + const brace = clause.match(/\{([^}]*)\}/); + if (brace) { + for (let item of brace[1].split(',')) { + item = item.trim().replace(/^type\s+/, ''); + if (!item) continue; + const as = item.split(/\s+as\s+/); // SOURCE name = before `as` + const name = (as[0] ?? '').trim(); + if (/^[A-Za-z0-9_$]+$/.test(name)) imported.add(name); + } + clause = clause.replace(/\{[^}]*\}/, '').replace(/^\s*,|,\s*$/g, '').trim(); + } + // default / namespace binding remnant (e.g. `Foo` or `* as NS`) + const def = clause.replace(/^\*\s+as\s+/, '').trim(); + if (/^[A-Za-z0-9_$]+$/.test(def)) imported.add(def); + } + } + return imported; +} + // --------------------------------------------------------------------------- // reporting // --------------------------------------------------------------------------- @@ -450,6 +521,10 @@ function main() { } const aliasOk = hasGeneratedAlias(ts.paths); + // Which `@/generated/*` identifiers does the host source actually import? The + // hard-fail is gated on this set (import-presence, §9): an op a block declares + // but does not import is backend-pending, not a failure (it degrades). + const importedSymbols = collectGeneratedImports(opts.project); const sdkCache = new Map(); // ns -> { dir, sdk } | { dir:null } const report = []; let failed = false; @@ -477,14 +552,34 @@ function main() { const cached = sdkCache.get(ns); nsEntry.generatedDir = cached.dir; + // A missing generated dir (alias unresolved or the resolved dir absent) is + // a fundamental, op-independent failure — the namespace's SDK doesn't exist + // at all, so nothing the block imports can resolve. Surface it as exit 1 + // here so the import-presence op gate below (which would otherwise mark + // every op backend-pending for a block that imports none of them) cannot + // mask a wholly-missing SDK. The human report already names the missing + // namespace and prints the `cnc codegen` remediation. + if (!cached.sdk) failed = true; + const checkOp = (op, kind, expected, present) => { const satisfied = !!cached.sdk && present; - const pending = req.pending.has(op); - // A declared-pending op is informational: reported, never a failure — - // even when the SDK is present and the op is genuinely absent. Only a - // NON-pending unsatisfied op flips `failed`. - if (!satisfied && !pending) failed = true; - nsEntry.ops.push({ op, kind, expects: expected, ok: satisfied, pending }); + const declaredPending = req.pending.has(op); + // Import-presence gate (§9): is the symbol this op maps to actually + // imported from `@/generated/*` somewhere in the host source? Models map + // to an accessor object, not a hook — a list block imports the hook, not + // the model name — so only mutation/query hooks are import-gated; a + // declared-but-unimported model is treated the same (informational). + const imported = kind === 'model' ? false : importedSymbols.has(expected); + // A missing op that the block does NOT import is backend-pending: the + // block declared the full capability surface but degrades to the ops it + // wires (e.g. org-members-list declares removeOrgMember/transferOrgOwnership + // yet imports neither). Reported, never a failure. Only a missing op the + // block GENUINELY IMPORTS (a real compile-against-a-missing-export) — or + // a missing op when the SDK dir itself is absent — flips `failed`. An + // explicit `pending` declaration also suppresses the failure. + const pending = declaredPending || (!satisfied && !imported); + if (!satisfied && imported && !declaredPending) failed = true; + nsEntry.ops.push({ op, kind, expects: expected, ok: satisfied, pending, imported, declaredPending }); }; for (const op of req.mutations) checkOp(op, 'mutation', mutationHook(op), cached.sdk?.exports.has(mutationHook(op))); @@ -522,7 +617,12 @@ function main() { for (const o of ns.ops) { // pending + absent → ◦ (informational); pending + present → ✓; else ✓/✗. const mark = o.ok ? C.green('✓') : o.pending ? C.dim('◦') : C.red('✗'); - const note = o.pending && !o.ok ? C.dim(' (backend-pending — not yet deployed)') : ''; + // Distinguish WHY an absent op is informational: an explicit `pending` + // declaration vs detected as declared-but-not-imported (the block + // degrades — it never imports this op's hook, so it cannot fail to + // compile against it). + const why = o.declaredPending ? 'backend-pending — not yet deployed' : 'declared, not imported — block degrades (backend-pending)'; + const note = o.pending && !o.ok ? C.dim(` (${why})`) : ''; console.log(` ${mark} ${o.kind} ${C.bold(o.op)} ${C.dim(`→ ${o.expects}`)}${note}`); } } @@ -548,7 +648,7 @@ function main() { const pendingSeams = report.flatMap((b) => b.namespaces.flatMap((n) => n.ops.filter((o) => o.pending && !o.ok).map((o) => o.op))); console.log(C.green('\n✓ All data-block prerequisites satisfied.')); if (pendingSeams.length) { - console.log(C.dim(` (${pendingSeams.length} declared backend-pending seam(s): ${[...new Set(pendingSeams)].join(', ')} — the block's GA path stands alone until those procs ship.)`)); + console.log(C.dim(` (${pendingSeams.length} backend-pending seam(s): ${[...new Set(pendingSeams)].join(', ')} — declared or imported-degraded; the block's GA path stands alone until those procs ship.)`)); } process.exit(0); } From d87b9f3c9f08a9b427acc1882c85c51bb478f243 Mon Sep 17 00:00:00 2001 From: Ha Gia Phat Date: Fri, 5 Jun 2026 23:42:01 +0700 Subject: [PATCH 13/15] =?UTF-8?q?chore(skills):=20P2=20hygiene=20=E2=80=94?= =?UTF-8?q?=20check-sdk=20contract=20advisories=20(WARN-only)=20+=20SKILL.?= =?UTF-8?q?md=20gap=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add present-but-defective op detection to check-sdk.mjs: a CONTRACT PREFLIGHT that emits WARN-only advisories (warnings[] in --json) for known arg-domain / defective ops an installed block touches, never changing the exit code. Document the two gap classes (A absent ops via binding gate, B present-but-defective via contract advisories) in SKILL.md with the KNOWN_AXES mirror table, and cover the new behavior in check-sdk.test.mjs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/skills/constructive-blocks/SKILL.md | 25 +- .../constructive-blocks/scripts/check-sdk.mjs | 235 +++++++++++++++++- .../scripts/check-sdk.test.mjs | 216 +++++++++++++++- 3 files changed, 470 insertions(+), 6 deletions(-) diff --git a/.agents/skills/constructive-blocks/SKILL.md b/.agents/skills/constructive-blocks/SKILL.md index 2883866..47f8bff 100644 --- a/.agents/skills/constructive-blocks/SKILL.md +++ b/.agents/skills/constructive-blocks/SKILL.md @@ -150,13 +150,15 @@ node scripts/check-sdk.mjs auth-sign-in-card # check one block (name or manifes node scripts/check-sdk.mjs --project /path/app --json ``` -It (1) verifies the `@/generated/*` alias exists in `tsconfig.json`, (2) resolves and checks the generated dir for each block's namespace, (3) asserts every manifest op maps to a real SDK export (`signIn` → `useSignInMutation`), and (4) advises whether `` is mounted. **Exit codes: `0`** satisfied · **`1`** a prerequisite is missing · **`2`** the check couldn't run (no tsconfig / bad manifest). +It (1) verifies the `@/generated/*` alias exists in `tsconfig.json`, (2) resolves and checks the generated dir for each block's namespace, (3) asserts every manifest op maps to a real SDK export (`signIn` → `useSignInMutation`), (4) advises whether `` is mounted, and (5) emits **contract advisories** (WARN-only) for known arg-domain / defective ops an installed block touches — see the **(B)** table under "Known SDK gaps". **Exit codes: `0`** satisfied · **`1`** a prerequisite is missing · **`2`** the check couldn't run (no tsconfig / bad manifest). **Contract advisories never change the exit code** — they're read from `warnings[]` in `--json`. On failure it prints the exact remediation: - **Alias or generated dir missing** → it prints the `cnc codegen --api-names --react-query --orm -o src/generated` to run, then re-check. - **SDK present but an op is absent** → the backend likely hasn't deployed that procedure, or the SDK is stale. Regenerate and drift-check with `cnc codegen … --dry-run`. +It also prints **contract advisories** (WARN, exit code unchanged): a `⚠` line per known arg-domain / defective op an installed block touches (see the **(B)** table above). For an **arg-domain** WARN, pass the safe value (e.g. `createApiKey` → `read_only`/`full_access`, not `read`/`write`/`admin`). For a **defective** WARN (GAP-N), the op is upstream-broken — don't build a flow that depends on it succeeding; treat it as backend-pending. The harness reads these from `warnings[]` in `--json` (`node scripts/check-sdk.mjs --json`). + **This script never runs `cnc codegen` itself** — generation needs an endpoint and operator confirmation. It *detects*; you *remediate*. If the SDK is genuinely missing, confirm the endpoint/api-names with the operator, run `cnc codegen`, then re-run the check. ## Extending the runtime with a new namespace @@ -197,14 +199,33 @@ await signIn.mutateAsync({ email, password, rememberMe }); ## Known SDK gaps (consequences, not bugs) +There are **two** distinct gap classes, surfaced by `check-sdk.mjs` in two different ways: + +**(A) Absent ops — caught by the binding gate (HARD-FAIL on import, ◦ when degraded).** The op isn't in the SDK at all (not-yet-deployed proc or no Connection type). A block that *imports* it fails the check; a block that *declares but degrades* (never imports it) reports `◦` and passes. + | Capability | Status | Block handling | |---|---|---| | List active sessions | No Connection type (`user_sessions` is private) → no list hook | `auth-account-sessions-list` is **out of frontend scope** until an API exposes a sessions Connection. Only `revokeSession` exists. | | List API keys | Same — `user_api_keys` is private | `auth-account-api-keys-list` likewise out of scope; `createApiKey`/`revokeApiKey` exist. | -| Passkeys / TOTP-enroll / magic-link / email-OTP / anonymous / context-switch / org transfer+delete | Procedures **not yet deployed** in any public schema | Blocks kept **backend-pending** with a "not buildable until proc ships" banner; their `requires.json` names the pending op so `check-sdk.mjs` fails clearly. | +| Passkeys / TOTP-enroll / magic-link / email-OTP / anonymous / context-switch / org transfer+delete (`removeOrgMember` / `transferOrgOwnership` / `delete_org`) | Procedures **not yet deployed** in any public schema | Blocks kept **backend-pending** with a "not buildable until proc ships" banner; their `requires.json` names the pending op so `check-sdk.mjs` fails clearly (or marks it `◦` when degraded). Route member-remove through GA `deleteOrgMembership`. | A block whose required op is absent **fails the check with a precise message** rather than compiling against a guess — that is the gap surfacing honestly, not a defect. +**(B) Present-but-defective ops — surfaced by the CONTRACT PREFLIGHT (WARN, never a failure).** These ops *exist* and type-check (they pass the binding gate), but calling them the way a block ships fails at **runtime**: a wrong **arg-domain** (a live `INVALID_ACCESS_LEVEL`) or a known **upstream defect** (silent no-op / RLS-deny / abort). The binding gate can't see this — the export is present — so `check-sdk.mjs` emits a **contract advisory** naming the op, the GAP-N, and the safe value. **This table is the source `check-sdk.mjs` mirrors** (the `KNOWN_AXES` table in the script); keep them in sync — a new row here with an op signature should gain a `KNOWN_AXES` entry. The advisories appear under "⚠ contract advisories" in the human report and as a `warnings[]` array in `--json`. Based on the harness's confirmed-live facts in **`PLATFORM-GAPS.md`** + **`planning/upstream-gaps-stress-test-2026-06-05.md`**. + +| Op(s) | Axis | GAP | Safe value / behavior | +|---|---|---|---| +| `createApiKey` | **arg-domain** `accessLevel ∈ {read_only, full_access}` | auth-api-key axis | The `auth-api-key-create-dialog` ships `{read, write, admin}` → live **`INVALID_ACCESS_LEVEL`**. Pass `read_only` or `full_access`. (`createApiKey` also enforces `STEP_UP_REQUIRED` server-side.) | +| `createUser(type=2)` / `createOrganization` | **defective** (RLS-deny) | GAP-6 | RLS-denied for an authenticated session (`new row violates row-level security policy for table "users"`) — no self-service org can be minted on the b2b tier. Confirmed via both the block and the direct API. Upstream (constructive-db). | +| `userSessions` / `sessions` (list) | **defective** (no Connection) | GAP-2 | No `userSessions` list query is exposed → the Sessions flow can't enumerate sessions to revoke. Out of frontend scope until a Connection ships. | +| `revokeSession` | **defective** (id mismatch) | GAP-2 | Returns `SESSION_NOT_FOUND` for the id on a `signIn`/`signUp` result (UUIDv5 identity id ≠ `sessions`-row UUIDv7; reads `user_sessions` while `signIn` writes `sessions`). Treat sessions-revoke as backend-pending; don't hand-craft a session id. | +| `revokeApiKey` | **defective** (silent partial write) | GAP-3 | Returns `true` + writes an audit-log entry but never sets `revoked_at` — the key keeps working. Don't trust its `true` as a revoke (security footgun). | +| `sendVerificationEmail` | **defective** (aborts) | GAP-9 | Aborts before any email enqueues (`user_secrets_del(uuid, text[]) does not exist`). Email-verification unreachable on `auth:email`; the send raises server-side. No workaround. | +| `sendAccountDeletionEmail` | **defective** (silent no-op) | GAP-10 | Returns HTTP 200 but enqueues nothing — the UI claims "a confirmation email has been sent" while Mailpit stays empty. Don't hand-roll the deletion email. | +| `forgotPassword` / `signOut` | **defective** (empty selection) | GAP-11 | `forgot-password-card` + `sign-out-button` (dashboard-blocks) ship `selection:{fields:{}}` which codegen rejects (`… must have a selection of subfields`). App-local fix: set the selection to `{ clientMutationId: true }`. (`signOut` codegen is also broken per GAP-4.) | + +A contract advisory is **not** a failure — the block is installable and compiles. It is a heads-up so the build doesn't burn a round-trip on a runtime arg-domain error or a silent no-op. (GAP-5 absent ops live in table **(A)**, handled by the binding gate's pending mechanism — they are intentionally **not** duplicated as contract advisories.) + ## The override seam (portability) The default path is the generated hook. Every data block also accepts an `onSubmit` (mutations) / `adapter` (queries) prop that **fully replaces** the network call, so the block runs on a non-Constructive backend. The block keeps owning form state, validation, error mapping, and notifications either way: diff --git a/.agents/skills/constructive-blocks/scripts/check-sdk.mjs b/.agents/skills/constructive-blocks/scripts/check-sdk.mjs index 82f33b6..f08b53b 100755 --- a/.agents/skills/constructive-blocks/scripts/check-sdk.mjs +++ b/.agents/skills/constructive-blocks/scripts/check-sdk.mjs @@ -42,6 +42,14 @@ * reported but never fail. (A wholly-missing generated dir/alias still fails * independently — see §1–2.) * 5. (advisory) `@constructive/blocks-runtime` appears mounted somewhere + * 6. (advisory, WARN-only) CONTRACT PREFLIGHT: when an installed block declares + * or imports a known arg-domain or defective op, emit a WARN naming the axis, + * the GAP-N, and the safe value — e.g. `createApiKey.accessLevel` only accepts + * {read_only, full_access} (a block shipping {read,write,admin} → live + * INVALID_ACCESS_LEVEL), or `sendVerificationEmail` aborts upstream (GAP-9). + * These NEVER change the exit code (the op exists + type-checks; only its + * runtime arg-domain/behavior is wrong) and are surfaced in --json as a + * `warnings[]` array. The table mirrors SKILL.md "Known SDK gaps". * * Drift detection (§9.4) and generating a missing SDK (§9.6) require `cnc * codegen` + an endpoint + operator confirmation, so they are NOT run here — @@ -395,6 +403,208 @@ function normalizeRequirements(raw) { })); } +// --------------------------------------------------------------------------- +// CONTRACT PREFLIGHT — known arg-domain + defective/RLS-blocked op advisories. +// +// A data-driven, WARN-only layer (NEVER a hard-fail) over the confirmed-live +// platform facts in the harness's PLATFORM-GAPS.md + planning/upstream-gaps- +// stress-test-2026-06-05.md. The import-presence binding gate above answers "does +// the op EXIST in the SDK?"; this layer answers a different question the SDK can't +// see: "this op exists and type-checks, but calling it the way a block ships it +// fails at RUNTIME (wrong arg-domain) or no-ops (a known upstream defect)." +// +// Why WARN and not a new hard-fail class: every op below belongs to a **GA block** +// whose SDK export is genuinely present — failing the check would false-fail blocks +// that ship today and pass the binding gate. The harness reads `warnings[]` from +// --json to surface the safe value / known defect at build time; a human run prints +// them under a "contract advisories" heading. Exit code is unchanged by warnings. +// +// The table mirrors SKILL.md "Known SDK gaps" (the prose table is the human-facing +// source; this is its executable twin). Keep them in sync: a new GAP-N row in +// SKILL.md that has an op signature should gain an entry here. +// +// Each axis: +// kind 'arg-domain' (a field/enum has a constrained safe set the block +// violates) | 'defective' (the op exists but no-ops / RLS-denies / +// aborts at runtime). +// ops GraphQL op name(s) (camelCase, pre-hook) this axis attaches to. +// A manifest matches when it DECLARES the op (mutations/queries) OR +// the host source IMPORTS the op's generated hook. +// gap the PLATFORM-GAPS GAP-N id (the escalation channel). +// safe for arg-domain: the values that actually work at runtime. +// bad for arg-domain: the values a block is known to ship that fail. +// field for arg-domain: the argument/enum the domain constrains. +// note one-line operator-facing summary (symptom + safe action). +// sources literal substrings searched in the host source to corroborate an +// arg-domain WARN (e.g. the bad enum values a block hard-codes). A +// source hit RAISES confidence ('confirmed') vs a name-only match +// ('declared'); never required to emit the WARN. +// --------------------------------------------------------------------------- +const KNOWN_AXES = [ + { + id: 'createApiKey-accessLevel', + kind: 'arg-domain', + ops: ['createApiKey'], + gap: 'GAP (auth-api-key-create-dialog)', + field: 'accessLevel', + safe: ['read_only', 'full_access'], + bad: ['read', 'write', 'admin'], + sources: ['read_only', 'full_access', "'read'", "'write'", "'admin'", '"read"', '"write"', '"admin"', 'accessLevelOptions'], + note: "createApiKey.accessLevel only accepts {read_only, full_access}; the auth-api-key-create-dialog ships {read,write,admin} → live INVALID_ACCESS_LEVEL. Pass read_only or full_access. (createApiKey also enforces STEP_UP_REQUIRED server-side.)" + }, + { + id: 'createUser-org-rls', + kind: 'defective', + ops: ['createUser', 'createOrganization'], + gap: 'GAP-6', + note: "createUser(type=2 Organization)/createOrganization is RLS-denied for an authenticated session (`new row violates row-level security policy for table \"users\"`) — no self-service org can be minted on the b2b tier. Confirmed live via both the block and the direct API. No app-side workaround; upstream (constructive-db)." + }, + { + id: 'sessions-list', + kind: 'defective', + ops: ['userSessions', 'sessions'], + gap: 'GAP-2', + note: "No userSessions list query is exposed (user_sessions is private, no Connection) — the Sessions flow cannot enumerate sessions to revoke. auth-account-sessions-list is out of frontend scope until an API exposes a sessions Connection." + }, + { + id: 'revokeSession-id', + kind: 'defective', + ops: ['revokeSession'], + gap: 'GAP-2', + note: "revokeSession(id) returns SESSION_NOT_FOUND for the id on a signIn/signUp result (auth-result id is a UUIDv5 identity id, not the sessions-row UUIDv7; revokeSession also reads user_sessions while signIn writes sessions). Treat sessions-revoke as backend-pending; do NOT hand-craft a session id." + }, + { + id: 'revokeApiKey-noop', + kind: 'defective', + ops: ['revokeApiKey'], + gap: 'GAP-3', + note: "revokeApiKey returns true and writes an audit-log entry but never sets revoked_at — the key keeps working. Do NOT treat its `true` as a successful revoke (security footgun). Upstream defect." + }, + { + id: 'sendVerificationEmail-abort', + kind: 'defective', + ops: ['sendVerificationEmail'], + gap: 'GAP-9', + note: "sendVerificationEmail aborts before any email enqueues (`user_secrets_del(uuid, text[]) does not exist` — signature/overload mismatch). Email-verification is unreachable on auth:email; the send raises server-side. No workaround (upstream constructive-db)." + }, + { + id: 'sendAccountDeletionEmail-noop', + kind: 'defective', + ops: ['sendAccountDeletionEmail'], + gap: 'GAP-10', + note: "sendAccountDeletionEmail returns HTTP 200 but enqueues nothing (silent no-op) — the UI claims 'a confirmation email has been sent' while Mailpit stays empty, so deletion can never be confirmed. Do NOT hand-roll the deletion email. Upstream (constructive-db)." + }, + { + id: 'forgotPassword-empty-selection', + kind: 'defective', + ops: ['forgotPassword', 'signOut'], + gap: 'GAP-11', + note: "forgot-password-card + sign-out-button (dashboard-blocks) ship an empty GraphQL selection (selection:{fields:{}}) that codegen rejects (`forgotPassword must have a selection of subfields`) — the block cannot issue its mutation. App-local fix: set the selection to { clientMutationId: true }. (signOut codegen is also broken per GAP-4.) Upstream owner is dashboard-blocks." + } + // NOTE — GAP-5 org-admin seams (`removeOrgMember` / `transferOrgOwnership` / + // `deleteOrg`) are deliberately NOT in this table. Those ops are *absent* + // (not-yet-deployed), which the BINDING gate's existing `pending`/import-presence + // mechanism already surfaces (declared-but-unimported → informational ◦, or a + // manifest `pending` entry). This contract layer covers the orthogonal class the + // binding gate cannot see: ops that EXIST + type-check but fail/no-op/abort at + // runtime (arg-domain, RLS-deny, silent no-op). Adding GAP-5 here would duplicate + // the binding gate and is intentionally left to it. +]; + +// Build an op → axis index once (an op may map to at most one axis here). +const AXIS_BY_OP = new Map(); +for (const axis of KNOWN_AXES) for (const op of axis.ops) if (!AXIS_BY_OP.has(op)) AXIS_BY_OP.set(op, axis); + +// Does the host source literally contain any of the corroborating substrings? +// Used only to upgrade an arg-domain WARN from 'declared' to 'confirmed' (the +// block hard-codes a bad enum value). Scans the same src/ tree as the import +// collector; best-effort and never required to emit a WARN. +function sourceContainsAny(projectRoot, needles) { + if (!needles || !needles.length) return false; + const src = join(projectRoot, 'src'); + const root = existsSync(src) ? src : projectRoot; + for (const file of walk(root)) { + let txt; + try { + txt = readFileSync(file, 'utf-8'); + } catch { + continue; + } + for (const n of needles) if (txt.includes(n)) return true; + } + return false; +} + +// Walk the manifests' declared ops + the host's imported generated symbols and +// collect a WARN for every known axis they touch. Returns a flat warnings[]: +// { id, kind, gap, op, block, namespace, field?, safe?, bad?, via, confidence, message } +// `via` is 'declared' (named in a requires.json) or 'imported' (its hook is +// imported from @/generated/*); `confidence` is 'confirmed' when corroborating +// source text was found, else 'declared'/'imported'. WARNs NEVER affect exit code. +function collectContractWarnings(report, importedSymbols, projectRoot) { + const warnings = []; + const seen = new Set(); // de-dupe (axis,block,namespace,op,via) + + // helper: which import names corroborate this op? the op's mutation OR query hook. + const opImported = (op) => importedSymbols.has(mutationHook(op)) || importedSymbols.has(queryHook(op)); + + const push = (axis, op, block, namespace, via) => { + const key = `${axis.id}|${block}|${namespace}|${op}|${via}`; + if (seen.has(key)) return; + seen.add(key); + // Corroborate an arg-domain WARN by looking for the bad enum literals the + // block hard-codes (quoted both ways). A hit upgrades 'declared'/'imported' + // to 'confirmed'; otherwise confidence is just the discovery channel. + let confidence = via; + if (axis.kind === 'arg-domain' && Array.isArray(axis.bad)) { + const needles = axis.bad.flatMap((v) => [`'${v}'`, `"${v}"`]); + if (sourceContainsAny(projectRoot, needles)) confidence = 'confirmed'; + } + const head = + axis.kind === 'arg-domain' + ? `arg-domain ${op}.${axis.field} — safe ${JSON.stringify(axis.safe)}, NOT ${JSON.stringify(axis.bad)}` + : `defective op ${op}`; + warnings.push({ + id: axis.id, + kind: axis.kind, + gap: axis.gap, + op, + block, + namespace, + field: axis.field ?? null, + safe: axis.safe ?? null, + bad: axis.bad ?? null, + via, + confidence, + message: `[${axis.gap}] ${head}. ${axis.note}` + }); + }; + + for (const b of report) { + for (const ns of b.namespaces) { + for (const o of ns.ops) { + const axis = AXIS_BY_OP.get(o.op); + if (!axis) continue; + // 'declared' — the op is named in this block's manifest (always true here, + // since ns.ops comes from the manifest). 'imported' takes precedence as the + // stronger signal (the block actually wires the hook). + const via = o.kind !== 'model' && opImported(o.op) ? 'imported' : 'declared'; + push(axis, o.op, b.block, ns.namespace, via); + } + } + } + // Also flag axes whose hook the host IMPORTS but which no manifest declared + // (e.g. a block author calls createApiKey directly without a requires.json entry, + // or a presentational wrapper imports the hook). Attributed to '(imported)'. + for (const [op, axis] of AXIS_BY_OP) { + if (opImported(op)) { + const already = warnings.some((w) => w.id === axis.id && w.op === op); + if (!already) push(axis, op, '(imported)', '(host source)', 'imported'); + } + } + return warnings; +} + // --------------------------------------------------------------------------- // advisory: is mounted anywhere in the host source? // --------------------------------------------------------------------------- @@ -493,9 +703,15 @@ Usage: [block] a block name (auth-sign-in-card) or manifest path; omit to check all --project DIR project root to check (default: cwd) --manifests-dir DIR explicit .constructive/blocks dir (overrides auto-discovery) - --json emit a machine-readable report + --json emit a machine-readable report (includes a warnings[] array) --help show this help +In addition to the hard binding gate, the check emits WARN-only CONTRACT +ADVISORIES for known arg-domain / defective ops an installed block touches +(e.g. createApiKey.accessLevel ∈ {read_only, full_access}; sendVerificationEmail +aborts upstream). Advisories never change the exit code; read them from +warnings[] in --json. The advisory table mirrors SKILL.md "Known SDK gaps". + Manifests are auto-discovered under both /.constructive/blocks and /src/.constructive/blocks (shadcn writes to the latter when the blocks target lives under src/). Use --manifests-dir to point at a non-standard location.`; @@ -596,9 +812,12 @@ function main() { } const runtimeOk = runtimeMounted(opts.project); + // Contract-preflight advisories: known arg-domain + defective/RLS-blocked ops + // touched by the installed blocks. WARN-only — they NEVER change `failed`. + const warnings = collectContractWarnings(report, importedSymbols, opts.project); if (opts.json) { - console.log(JSON.stringify({ project: opts.project, aliasOk, runtimeMounted: runtimeOk, blocks: report, ok: !failed }, null, 2)); + console.log(JSON.stringify({ project: opts.project, aliasOk, runtimeMounted: runtimeOk, blocks: report, warnings, ok: !failed }, null, 2)); process.exit(failed ? 1 : 0); } @@ -629,6 +848,18 @@ function main() { } console.log(`\n${runtimeOk ? C.green('✓') : C.dim('•')} ${runtimeOk ? 'mounted' : 'not found (mount it once at the app root — advisory)'}`); + // Contract advisories (WARN, never a failure). These name an op that exists + + // type-checks but has a known runtime arg-domain or upstream defect, with the + // safe value / known behavior — so the build doesn't burn a round-trip on + // INVALID_ACCESS_LEVEL or a silent no-op. Mirrors SKILL.md "Known SDK gaps". + if (warnings.length) { + console.log(C.bold(`\n⚠ ${warnings.length} contract advisor${warnings.length === 1 ? 'y' : 'ies'} (WARN — not a failure):`)); + for (const w of warnings) { + const where = w.block === '(imported)' ? C.dim('(imported in host source)') : `${C.bold(w.block)} ${C.dim(`/ ${w.namespace}`)}`; + console.log(` ${C.bold('⚠')} ${where}\n ${w.message}`); + } + } + if (failed) { console.log(C.red('\n✗ Unsatisfied prerequisites.')); if (missingNs.size) { diff --git a/.agents/skills/constructive-blocks/scripts/check-sdk.test.mjs b/.agents/skills/constructive-blocks/scripts/check-sdk.test.mjs index c799836..7f4dde5 100644 --- a/.agents/skills/constructive-blocks/scripts/check-sdk.test.mjs +++ b/.agents/skills/constructive-blocks/scripts/check-sdk.test.mjs @@ -109,19 +109,32 @@ test('declared-pending op is informational, not a failure (exit 0)', () => { try { const { code, out } = run(root); assert.equal(code, 0, out); + // Each declared-pending op is reported informationally (◦) as backend-pending, + // and the run summarises them as backend-pending seam(s) — but never fails. assert.match(out, /removeOrgMember.*backend-pending/); - assert.match(out, /declared backend-pending seam/); + assert.match(out, /backend-pending seam/); } finally { rmSync(root, { recursive: true, force: true }); } }); -test('a NON-pending missing op still fails (exit 1) — binding still protects', () => { +test('a NON-pending missing op that IS IMPORTED still fails (exit 1) — binding still protects', () => { + // The import-presence gate (§9) hard-fails only on a missing op the block + // genuinely IMPORTS from @/generated/* (a real compile-against-a-missing-export); + // a declared-but-unimported op degrades (◦, exit 0). So to exercise the protection + // this fixture IMPORTS the missing hook in a source file. const root = makeApp({ models: ['orgMembership'], hooks: GA_HOOKS, manifest: { namespace: 'admin', mutations: ['updateOrgMembership', 'totallyMissingOp'], queries: [], models: ['orgMembership'] } }); + // add a source file that imports the missing op's hook (triggers the hard-fail) + const blocksDir = join(root, 'src/blocks'); + mkdirSync(blocksDir, { recursive: true }); + writeFileSync( + join(blocksDir, 'uses-missing.tsx'), + "import { useTotallyMissingOpMutation } from '@/generated/admin';\nexport function X() { useTotallyMissingOpMutation({}); return null; }\n" + ); try { const { code, out } = run(root); assert.equal(code, 1, out); @@ -146,3 +159,202 @@ test('a pending op that IS present reports ✓, not suppressed (exit 0)', () => rmSync(root, { recursive: true, force: true }); } }); + +// --------------------------------------------------------------------------- +// CONTRACT PREFLIGHT — known arg-domain + defective/RLS-blocked op advisories. +// +// These assert the WARN-only contract layer: an op that EXISTS (passes the +// binding gate) but has a known runtime arg-domain (createApiKey.accessLevel) or +// upstream defect (sendVerificationEmail GAP-9, revokeApiKey GAP-3, createUser +// GAP-6, sessions GAP-2, …) must produce a `warnings[]` entry naming the GAP-N + +// safe value, WITHOUT changing the exit code. The advisory table mirrors +// SKILL.md "Known SDK gaps" and the harness PLATFORM-GAPS.md confirmed-live facts. +// +// `manifest` is written verbatim (so a test controls the namespace + declared +// ops). `hooks` are generated hook identifiers that EXIST (so the binding gate +// passes). `src` is an optional map of {relativePath: contents} written under +// src/ — used to exercise import-presence + arg-domain corroboration. +// --------------------------------------------------------------------------- +function makeContractApp({ ns = 'auth', hooks = [], manifest, src = {} }) { + const root = mkdtempSync(join(tmpdir(), 'check-sdk-contract-')); + writeFileSync( + join(root, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/generated/*': ['./src/generated/*'] } } }) + ); + const hooksDir = join(root, `src/generated/${ns}/hooks/mutations`); + mkdirSync(hooksDir, { recursive: true }); + for (const h of hooks) writeFileSync(join(hooksDir, `${h}.ts`), `export function ${h}() {}\n`); + for (const [rel, contents] of Object.entries(src)) { + const p = join(root, 'src', rel); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, contents); + } + const manifestDir = join(root, 'src/.constructive/blocks'); + mkdirSync(manifestDir, { recursive: true }); + writeFileSync(join(manifestDir, 'block.requires.json'), JSON.stringify(manifest)); + return root; +} + +// Run with --json and parse the report (so we can assert on warnings[] structurally). +function runJson(root) { + const r = spawnSync(process.execPath, [SCRIPT, '--project', root, '--json'], { encoding: 'utf-8' }); + let json = null; + try { + json = JSON.parse(r.stdout); + } catch { + /* leave null — the assertion will surface stderr */ + } + return { code: r.status, json, out: r.stdout + r.stderr }; +} + +test('arg-domain: createApiKey accessLevel WARNs {read_only,full_access}, never fails (exit 0)', () => { + const root = makeContractApp({ + ns: 'auth', + hooks: ['useCreateApiKeyMutation'], // op EXISTS → binding gate passes + manifest: { namespace: 'auth', mutations: ['createApiKey'], queries: [], models: [] }, + src: { + // block hard-codes the BAD enum values → corroboration upgrades to 'confirmed' + 'blocks/auth/api-key-create-dialog.tsx': + "import { useCreateApiKeyMutation } from '@/generated/auth';\nconst accessLevelOptions = ['read', 'write', 'admin'];\nexport function D() { useCreateApiKeyMutation({ selection: { fields: { clientMutationId: true } } }); return null; }\n" + } + }); + try { + const { code, json, out } = runJson(root); + assert.equal(code, 0, out); // WARN, NOT a failure + assert.ok(json, out); + assert.equal(json.ok, true); + const w = json.warnings.find((x) => x.id === 'createApiKey-accessLevel'); + assert.ok(w, `expected a createApiKey arg-domain warning, got ${JSON.stringify(json.warnings)}`); + assert.equal(w.kind, 'arg-domain'); + assert.equal(w.field, 'accessLevel'); + assert.deepEqual(w.safe, ['read_only', 'full_access']); + assert.deepEqual(w.bad, ['read', 'write', 'admin']); + assert.equal(w.confidence, 'confirmed'); // the bad literals were found in source + assert.match(w.message, /INVALID_ACCESS_LEVEL/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('defective: sendVerificationEmail WARNs GAP-9, exit 0 (op present, runtime aborts)', () => { + const root = makeContractApp({ + ns: 'auth', + hooks: ['useSendVerificationEmailMutation'], + manifest: { namespace: 'auth', mutations: ['sendVerificationEmail'], queries: [], models: [] } + }); + try { + const { code, json, out } = runJson(root); + assert.equal(code, 0, out); + const w = json.warnings.find((x) => x.id === 'sendVerificationEmail-abort'); + assert.ok(w, out); + assert.equal(w.kind, 'defective'); + assert.equal(w.gap, 'GAP-9'); + assert.equal(w.via, 'declared'); // named in the manifest, hook not imported here + assert.match(w.message, /user_secrets_del/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('defective: createUser(type=2) / createOrganization WARN GAP-6 (RLS-denied)', () => { + const root = makeContractApp({ + ns: 'admin', + hooks: ['useCreateUserMutation'], + manifest: { namespace: 'admin', mutations: ['createUser'], queries: [], models: [] } + }); + try { + const { code, json, out } = runJson(root); + assert.equal(code, 0, out); + const w = json.warnings.find((x) => x.id === 'createUser-org-rls'); + assert.ok(w, out); + assert.equal(w.gap, 'GAP-6'); + assert.match(w.message, /row-level security/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('defective: revokeApiKey imported WITHOUT a manifest entry still WARNs GAP-3 (import-presence)', () => { + const root = makeContractApp({ + ns: 'auth', + hooks: ['useRevokeApiKeyMutation', 'useSignInMutation'], + // manifest is for a DIFFERENT, clean op — revokeApiKey is only ever imported + manifest: { namespace: 'auth', mutations: ['signIn'], queries: [], models: [] }, + src: { + 'blocks/auth/keys.tsx': + "import { useRevokeApiKeyMutation } from '@/generated/auth';\nexport function K() { useRevokeApiKeyMutation({ selection: { fields: { clientMutationId: true } } }); return null; }\n" + } + }); + try { + const { code, json, out } = runJson(root); + assert.equal(code, 0, out); + const w = json.warnings.find((x) => x.id === 'revokeApiKey-noop'); + assert.ok(w, `expected revokeApiKey warning from an imported-only op, got ${JSON.stringify(json.warnings)}`); + assert.equal(w.via, 'imported'); + assert.equal(w.block, '(imported)'); + assert.equal(w.gap, 'GAP-3'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('no false positives: a clean GA block (signIn) emits ZERO contract advisories', () => { + const root = makeContractApp({ + ns: 'auth', + hooks: ['useSignInMutation'], + manifest: { namespace: 'auth', mutations: ['signIn'], queries: [], models: [] }, + src: { + 'blocks/auth/sign-in.tsx': + "import { useSignInMutation } from '@/generated/auth';\nexport function S() { useSignInMutation({ selection: { fields: { result: { select: { userId: true } } } } }); return null; }\n" + } + }); + try { + const { code, json, out } = runJson(root); + assert.equal(code, 0, out); + assert.equal(json.warnings.length, 0, `expected no advisories for a clean GA app, got ${JSON.stringify(json.warnings)}`); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('GAP-5 org-admin seams are NOT a contract advisory (left to the binding gate, no "backend-pending" WARN)', () => { + // removeOrgMember is an *absent* (not-deployed) op — the binding gate's + // pending/import-presence mechanism already surfaces it. The contract layer must + // NOT add a redundant WARN (which would also collide with the present-pending + // "doesNotMatch(/backend-pending/)" expectation when such an op IS deployed). + const root = makeContractApp({ + ns: 'admin', + hooks: ['useRemoveOrgMemberMutation'], // present → binding gate clean + manifest: { namespace: 'admin', mutations: ['removeOrgMember'], queries: [], models: [], pending: ['removeOrgMember'] } + }); + try { + const { code, json, out } = runJson(root); + assert.equal(code, 0, out); + assert.equal(json.warnings.length, 0, `GAP-5 ops must not appear in contract warnings, got ${JSON.stringify(json.warnings)}`); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('a contract advisory does NOT mask a real binding failure (exit 1 still wins)', () => { + // createApiKey present (so its WARN fires) + a genuinely-missing imported op. + const root = makeContractApp({ + ns: 'auth', + hooks: ['useCreateApiKeyMutation'], // present + manifest: { namespace: 'auth', mutations: ['createApiKey', 'reallyMissingOp'], queries: [], models: [] }, + src: { + // import BOTH: createApiKey (warns) AND reallyMissingOp (hard-fail — imported but absent) + 'blocks/auth/x.tsx': + "import { useCreateApiKeyMutation, useReallyMissingOpMutation } from '@/generated/auth';\nexport function X() { useCreateApiKeyMutation({}); useReallyMissingOpMutation({}); return null; }\n" + } + }); + try { + const { code, json, out } = runJson(root); + assert.equal(code, 1, out); // binding failure dominates + assert.equal(json.ok, false); + // the WARN is still recorded (advisory layer runs regardless of failure) + assert.ok(json.warnings.some((x) => x.id === 'createApiKey-accessLevel'), out); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); From 02919c70f7f2bedaaaf36acd78e8044a9ea8ac0c Mon Sep 17 00:00:00 2001 From: Ha Gia Phat Date: Sat, 6 Jun 2026 23:36:21 +0700 Subject: [PATCH 14/15] check-sdk: accept a workspace root for --project and derive the app package The scaffolders take a WORKSPACE ROOT (the dir holding packages/) as their appDir, but check-sdk consumes the app PACKAGE directly (its tsconfig alias + src/.constructive/blocks manifests). Pointing --project at a workspace root therefore reported a spurious 'No data-block manifests' because the manifests live under packages/app, not the root. resolveAppRoot() reconciles the two: an explicit app package dir (tsconfig.json + src/) is used as-is (back-compat), else packages/app then app are probed for that marker and the package is derived, else it fails loudly (exit 2) naming the dirs probed. The resolution notice goes to stderr so --json stdout stays pure. Adds tests covering packages/app and root-level app/ resolution, --json purity, explicit-package back-compat, and the unresolvable-root failure. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../constructive-blocks/scripts/check-sdk.mjs | 90 +++++++++++++++- .../scripts/check-sdk.test.mjs | 100 ++++++++++++++++++ 2 files changed, 188 insertions(+), 2 deletions(-) diff --git a/.agents/skills/constructive-blocks/scripts/check-sdk.mjs b/.agents/skills/constructive-blocks/scripts/check-sdk.mjs index f08b53b..72b75a1 100755 --- a/.agents/skills/constructive-blocks/scripts/check-sdk.mjs +++ b/.agents/skills/constructive-blocks/scripts/check-sdk.mjs @@ -13,9 +13,15 @@ * * node check-sdk.mjs # check every installed manifest * node check-sdk.mjs auth-sign-in-card # check one block (name or path) - * node check-sdk.mjs --project /path/app # check a different project root + * node check-sdk.mjs --project /path/ws # check a workspace root (or app pkg) * node check-sdk.mjs --json # machine-readable report on stdout * + * --project accepts EITHER the WORKSPACE ROOT (the dir holding packages/, the + * same the scaffold-provision/scaffold-frontend/wire-app scripts take) + * OR the app package dir directly. Given a workspace root, the actual app package + * (`packages/app`, else a root-level `app/`) is derived internally — its tsconfig + * + src/.constructive/blocks are what get checked. See resolveAppRoot(). + * * Exit codes: * 0 every prerequisite satisfied (or nothing to check) * 1 a prerequisite is missing (alias / generated dir / op export) @@ -79,6 +85,73 @@ function parseArgs(argv) { return opts; } +// --------------------------------------------------------------------------- +// app-package locator — accept a WORKSPACE ROOT and derive the app PACKAGE. +// +// The harness scaffolders (scaffold-provision/scaffold-frontend/wire-app) all +// take the WORKSPACE ROOT — the dir holding `packages/` — as their +// argument; the pgpm nextjs template unpacks the Next.js app one level down at +// `/packages/app` (an older layout uses a root-level `/app`). But +// this check consumes the app PACKAGE directly: it reads that package's +// `tsconfig.json` for the `@/generated/*` alias and scans its +// `src/.constructive/blocks` for manifests. So when `--project` is pointed at a +// workspace root (the natural thing, matching the scaffolders) the manifests & +// tsconfig sit under `packages/app`, NOT at the root — the symptom the 5-app run +// hit: `check-sdk --project ` → "No data-block manifests". +// +// resolveAppRoot() reconciles the two: given the supplied `--project`, it returns +// the app package dir it denotes. Resolution mirrors wire-app.mjs's appUnder(): +// 1. the project IS already the app package (holds tsconfig.json + src/) — used +// as-is, so an explicit package dir (or a flat/non-nested layout, incl. the +// test fixtures, whose root carries tsconfig.json + src/) keeps working +// unchanged. This is the back-compat path. +// 2. else probe `/packages/app` then `/app` for that same +// marker and derive the package — the workspace-root path. +// 3. else fall back to the project as-given ONLY when it at least carries a +// tsconfig.json (a degenerate layout we shouldn't second-guess); otherwise +// FAIL LOUDLY (exit 2) naming the root + the dirs probed, rather than +// silently proceeding against a root with no tsconfig/manifests (which would +// surface as the misleading "No data-block manifests" / "No tsconfig"). +// +// The marker is `tsconfig.json` + `src/` (not package.json + src/ as wire-app +// uses): tsconfig is what THIS check actually consumes, and a workspace root +// carries package.json + tsconfig.json but crucially NO `src/`, so requiring +// `src/` is exactly what distinguishes the app package from the workspace root. +// `packages/app` is probed before `app` (template default first), matching the +// scaffolders' own preference order. +// --------------------------------------------------------------------------- +function isAppPackage(dir) { + // The app package always carries a tsconfig.json (the alias source) AND a src/ + // tree (where codegen + the .constructive/blocks manifests live). A workspace + // root has tsconfig.json + package.json but no src/, so it is NOT matched here. + return existsSync(join(dir, 'tsconfig.json')) && existsSync(join(dir, 'src')); +} + +function resolveAppRoot(project) { + // 1) back-compat: the supplied dir IS already the app package (or a flat + // fixture layout) — use it verbatim, no derivation. + if (isAppPackage(project)) return { dir: project, derivedFrom: null }; + // 2) workspace root: derive the nested app package the template unpacks. Probe + // packages/app first (template default), then a root-level app/. + for (const sub of ['packages/app', 'app']) { + const cand = join(project, sub); + if (isAppPackage(cand)) return { dir: cand, derivedFrom: project }; + } + // 3) neither the root nor packages/app|app is an app package. If the root at + // least has a tsconfig.json, defer to loadTsconfig() (a degenerate layout we + // won't override — preserves the original "no manifests"/tsconfig messages). + // Otherwise FAIL LOUDLY: a bare workspace root with no resolvable app. + if (existsSync(join(project, 'tsconfig.json'))) return { dir: project, derivedFrom: null }; + const probed = ['packages/app', 'app'].map((s) => join(project, s)).join(', '); + fail( + 2, + `No app package found at or under ${project}.\n` + + ` Looked for tsconfig.json + src/ at the project itself and under: ${probed}.\n` + + ` Pass the WORKSPACE ROOT (the dir holding packages/, the same the scaffolders take) ` + + `or the app package dir directly (the dir with tsconfig.json + src/).` + ); +} + // --------------------------------------------------------------------------- // tsconfig: read compilerOptions.paths (+ baseUrl), following one `extends`. // JSONC-tolerant (tsconfig allows comments + trailing commas). @@ -701,7 +774,9 @@ Usage: node check-sdk.mjs [block] [--project DIR] [--manifests-dir DIR] [--json] [block] a block name (auth-sign-in-card) or manifest path; omit to check all - --project DIR project root to check (default: cwd) + --project DIR workspace root OR app package dir to check (default: cwd). + A workspace root (holding packages/, as the scaffolders take) + resolves to its app package (packages/app | app) internally. --manifests-dir DIR explicit .constructive/blocks dir (overrides auto-discovery) --json emit a machine-readable report (includes a warnings[] array) --help show this help @@ -726,6 +801,17 @@ function main() { process.exit(0); } + // Accept a WORKSPACE ROOT for --project (the same the scaffolders take) + // and derive the app PACKAGE (packages/app | app) it denotes; an explicit app + // package dir is used as-is (back-compat). Everything below (tsconfig, manifest + // discovery, source scans) then operates on the resolved package. Fails loudly + // when no app package resolves under the given root (see resolveAppRoot). + const resolved = resolveAppRoot(opts.project); + if (resolved.derivedFrom) { + console.error(`${C.dim('•')} resolved app package ${resolved.dir} (from workspace root ${resolved.derivedFrom})`); + } + opts.project = resolved.dir; + const ts = loadTsconfig(opts.project); if (!ts) fail(2, `No tsconfig.json in ${opts.project}. Run from the host app root or pass --project.`); diff --git a/.agents/skills/constructive-blocks/scripts/check-sdk.test.mjs b/.agents/skills/constructive-blocks/scripts/check-sdk.test.mjs index 7f4dde5..b180c68 100644 --- a/.agents/skills/constructive-blocks/scripts/check-sdk.test.mjs +++ b/.agents/skills/constructive-blocks/scripts/check-sdk.test.mjs @@ -358,3 +358,103 @@ test('a contract advisory does NOT mask a real binding failure (exit 1 still win rmSync(root, { recursive: true, force: true }); } }); + +// --------------------------------------------------------------------------- +// WORKSPACE-ROOT RESOLUTION — --project accepts the workspace root (the dir +// holding packages/, the same the scaffolders take) and derives the +// app package (packages/app | app) internally. The 5-app run hit +// `check-sdk --project ` → "No data-block manifests" because +// the manifests + tsconfig actually live under packages/app, not the root. +// +// Build a WORKSPACE ROOT whose app package sits one level down (`packages/app` +// by default, or a root-level `app/`). The root itself carries package.json + +// tsconfig.json but NO src/ — exactly the real pgpm/lerna workspace shape that +// must NOT be mistaken for the app package. +// --------------------------------------------------------------------------- +function makeWorkspace({ appSub = 'packages/app', hooks = ['useSignInMutation'], manifest = { namespace: 'auth', mutations: ['signIn'], queries: [], models: [] } } = {}) { + const root = mkdtempSync(join(tmpdir(), 'check-sdk-ws-')); + // Workspace-root markers: a package.json + tsconfig.json but deliberately NO + // src/ (so isAppPackage() rejects the root and derives the nested package). + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'ws-root', private: true })); + writeFileSync(join(root, 'tsconfig.json'), JSON.stringify({ files: [] })); + writeFileSync(join(root, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n"); + const appDir = join(root, appSub); + mkdirSync(appDir, { recursive: true }); + writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'app' })); + writeFileSync(join(appDir, 'tsconfig.json'), JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/generated/*': ['./src/generated/*'] } } })); + const hooksDir = join(appDir, 'src/generated/auth/hooks/mutations'); + mkdirSync(hooksDir, { recursive: true }); + for (const h of hooks) writeFileSync(join(hooksDir, `${h}.ts`), `export function ${h}() {}\n`); + const manifestDir = join(appDir, 'src/.constructive/blocks'); + mkdirSync(manifestDir, { recursive: true }); + writeFileSync(join(manifestDir, 'block.requires.json'), JSON.stringify(manifest)); + return { root, appDir }; +} + +test('workspace root --project resolves packages/app and finds manifests (exit 0)', () => { + const { root, appDir } = makeWorkspace(); + try { + const { code, out } = run(root); + assert.equal(code, 0, out); + // the derivation notice (stderr) names the resolved app package + the root + assert.match(out, /resolved app package/); + assert.ok(out.includes(appDir), out); + // the manifest under packages/app/src was actually checked + assert.match(out, /✓ mutation signIn → useSignInMutation/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('workspace root --project with a root-level app/ layout also resolves (exit 0)', () => { + const { root, appDir } = makeWorkspace({ appSub: 'app' }); + try { + const { code, out } = run(root); + assert.equal(code, 0, out); + assert.ok(out.includes(appDir), out); + assert.match(out, /✓ mutation signIn/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('--json on a workspace root keeps stdout PURE JSON (resolution notice → stderr)', () => { + const { root, appDir } = makeWorkspace(); + try { + const r = spawnSync(process.execPath, [SCRIPT, '--project', root, '--json'], { encoding: 'utf-8' }); + assert.equal(r.status, 0, r.stdout + r.stderr); + // stdout must parse cleanly — the "resolved app package" notice must NOT leak into it + const json = JSON.parse(r.stdout); + assert.equal(json.ok, true); + assert.equal(json.project, appDir); // report reflects the DERIVED package + assert.match(r.stderr, /resolved app package/); // notice went to stderr + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('an explicit app package dir is still used as-is (no derivation, no notice)', () => { + // Back-compat: pointing --project AT the app package (not the workspace root) + // must behave exactly as before — used verbatim, with no resolution notice. + const { root, appDir } = makeWorkspace(); + try { + const { code, out } = run(appDir); + assert.equal(code, 0, out); + assert.doesNotMatch(out, /resolved app package/); + assert.match(out, /✓ mutation signIn/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('an unresolvable --project (no app package, no tsconfig) fails loudly (exit 2)', () => { + const dir = mkdtempSync(join(tmpdir(), 'check-sdk-bare-')); + try { + const { code, out } = run(dir); + assert.equal(code, 2, out); + assert.match(out, /No app package found at or under/); + assert.match(out, /packages\/app/); // names the dirs it probed + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From 207fd9a30cec9cd450dbcdcccc1c5dd8c77f2dbe Mon Sep 17 00:00:00 2001 From: Ha Gia Phat Date: Sun, 7 Jun 2026 00:43:19 +0700 Subject: [PATCH 15/15] =?UTF-8?q?docs(constructive-blocks):=20make=20the?= =?UTF-8?q?=20build=20surface=20ambient=20=E2=80=94=20replace=20"harness"?= =?UTF-8?q?=20with=20app-domain=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrub the word "harness" from the agent-facing constructive-blocks skill text an app-building agent reads, replacing it with "the toolkit" / "the build flow" without changing meaning. - SKILL.md: scope-boundary note, contract-advisory note, and the present-but- defective ops table now say "the toolkit" / "the build flow". - scripts/check-flows.mjs: the drift-guard's diagnostic header label "harness" (printed when the flow-catalog drift check fails, surfaced to an agent via verify-phase.sh) → "toolkit"; column width unchanged (7 chars). The internal --harness-flows flag, FLOWS_HARNESS env var, and harnessPath variable are the developer-only CLI contract and are left unchanged. Kept (justified): references/flow-catalog.md is GENERATED (DO-NOT-EDIT banner, byte-coupled to the toolkit copy, SoT in dashboard-blocks/apps/blocks/scripts/ flows-content.mjs which is consume-only here). Developer-only comments in check-sdk.mjs / check-flows.mjs are left as-is. node --check passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/skills/constructive-blocks/SKILL.md | 6 +++--- .agents/skills/constructive-blocks/scripts/check-flows.mjs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/skills/constructive-blocks/SKILL.md b/.agents/skills/constructive-blocks/SKILL.md index 47f8bff..afe3163 100644 --- a/.agents/skills/constructive-blocks/SKILL.md +++ b/.agents/skills/constructive-blocks/SKILL.md @@ -30,7 +30,7 @@ Use this skill when: - **Host wiring**: aliasing `@/generated/*`, mounting ``, adding a namespace to the runtime, generating a missing SDK with `cnc codegen`. - **Authoring a block**: writing a new block that calls a generated hook, choosing its namespace, declaring its `requires.json`, adding the override seam. -**Scope boundary — Blocks are auth/account/org/shell ONLY.** The catalogued blocks and flows cover **auth, account, organization, and app-shell** capability bundles (sign-in, password reset, MFA, membership, invites, settings). They are **not** a general application-flow library. For your **domain-entity CRUD UI** — the React UI over your own business tables — use **`constructive-frontend`** (CRUD Stack cards + runtime-generic `_meta` meta-forms), **not** blocks; the harness automates that path via `scripts/scaffold-frontend.mjs`. A "flow" here answers *"which auth flow?"*, never *"which business workflow?"*. +**Scope boundary — Blocks are auth/account/org/shell ONLY.** The catalogued blocks and flows cover **auth, account, organization, and app-shell** capability bundles (sign-in, password reset, MFA, membership, invites, settings). They are **not** a general application-flow library. For your **domain-entity CRUD UI** — the React UI over your own business tables — use **`constructive-frontend`** (CRUD Stack cards + runtime-generic `_meta` meta-forms), **not** blocks; the toolkit automates that path via `scripts/scaffold-frontend.mjs`. A "flow" here answers *"which auth flow?"*, never *"which business workflow?"*. If the request is about generating the SDK itself, defer to the codegen skills — this skill *consumes* that SDK: **`constructive-codegen`** (codegen CLI/config flags), **`constructive-hooks`** / **`constructive-orm`** (generated hook/ORM output shapes, pagination), **`constructive-search`** (search). @@ -157,7 +157,7 @@ On failure it prints the exact remediation: - **Alias or generated dir missing** → it prints the `cnc codegen --api-names --react-query --orm -o src/generated` to run, then re-check. - **SDK present but an op is absent** → the backend likely hasn't deployed that procedure, or the SDK is stale. Regenerate and drift-check with `cnc codegen … --dry-run`. -It also prints **contract advisories** (WARN, exit code unchanged): a `⚠` line per known arg-domain / defective op an installed block touches (see the **(B)** table above). For an **arg-domain** WARN, pass the safe value (e.g. `createApiKey` → `read_only`/`full_access`, not `read`/`write`/`admin`). For a **defective** WARN (GAP-N), the op is upstream-broken — don't build a flow that depends on it succeeding; treat it as backend-pending. The harness reads these from `warnings[]` in `--json` (`node scripts/check-sdk.mjs --json`). +It also prints **contract advisories** (WARN, exit code unchanged): a `⚠` line per known arg-domain / defective op an installed block touches (see the **(B)** table above). For an **arg-domain** WARN, pass the safe value (e.g. `createApiKey` → `read_only`/`full_access`, not `read`/`write`/`admin`). For a **defective** WARN (GAP-N), the op is upstream-broken — don't build a flow that depends on it succeeding; treat it as backend-pending. The toolkit reads these from `warnings[]` in `--json` (`node scripts/check-sdk.mjs --json`). **This script never runs `cnc codegen` itself** — generation needs an endpoint and operator confirmation. It *detects*; you *remediate*. If the SDK is genuinely missing, confirm the endpoint/api-names with the operator, run `cnc codegen`, then re-run the check. @@ -211,7 +211,7 @@ There are **two** distinct gap classes, surfaced by `check-sdk.mjs` in two diffe A block whose required op is absent **fails the check with a precise message** rather than compiling against a guess — that is the gap surfacing honestly, not a defect. -**(B) Present-but-defective ops — surfaced by the CONTRACT PREFLIGHT (WARN, never a failure).** These ops *exist* and type-check (they pass the binding gate), but calling them the way a block ships fails at **runtime**: a wrong **arg-domain** (a live `INVALID_ACCESS_LEVEL`) or a known **upstream defect** (silent no-op / RLS-deny / abort). The binding gate can't see this — the export is present — so `check-sdk.mjs` emits a **contract advisory** naming the op, the GAP-N, and the safe value. **This table is the source `check-sdk.mjs` mirrors** (the `KNOWN_AXES` table in the script); keep them in sync — a new row here with an op signature should gain a `KNOWN_AXES` entry. The advisories appear under "⚠ contract advisories" in the human report and as a `warnings[]` array in `--json`. Based on the harness's confirmed-live facts in **`PLATFORM-GAPS.md`** + **`planning/upstream-gaps-stress-test-2026-06-05.md`**. +**(B) Present-but-defective ops — surfaced by the CONTRACT PREFLIGHT (WARN, never a failure).** These ops *exist* and type-check (they pass the binding gate), but calling them the way a block ships fails at **runtime**: a wrong **arg-domain** (a live `INVALID_ACCESS_LEVEL`) or a known **upstream defect** (silent no-op / RLS-deny / abort). The binding gate can't see this — the export is present — so `check-sdk.mjs` emits a **contract advisory** naming the op, the GAP-N, and the safe value. **This table is the source `check-sdk.mjs` mirrors** (the `KNOWN_AXES` table in the script); keep them in sync — a new row here with an op signature should gain a `KNOWN_AXES` entry. The advisories appear under "⚠ contract advisories" in the human report and as a `warnings[]` array in `--json`. Based on the build flow's confirmed-live facts in **`PLATFORM-GAPS.md`** + **`planning/upstream-gaps-stress-test-2026-06-05.md`**. | Op(s) | Axis | GAP | Safe value / behavior | |---|---|---|---| diff --git a/.agents/skills/constructive-blocks/scripts/check-flows.mjs b/.agents/skills/constructive-blocks/scripts/check-flows.mjs index 70bd77b..8578c84 100755 --- a/.agents/skills/constructive-blocks/scripts/check-flows.mjs +++ b/.agents/skills/constructive-blocks/scripts/check-flows.mjs @@ -515,7 +515,7 @@ function main() { console.log(C.bold('\nConstructive Blocks — flow catalog drift guard\n')); console.log(`${C.dim('skill ')} ${skillFlowsPath}`); console.log(`${C.dim('sot ')} ${sotPath}`); - console.log(`${C.dim('harness')} ${harnessPath ? harnessPath : C.yellow('(not reachable — skipped)')}`); + console.log(`${C.dim('toolkit')} ${harnessPath ? harnessPath : C.yellow('(not reachable — skipped)')}`); console.log(`${C.dim('presets')} ${ntrRoot ? `${ntrRoot} ${C.dim(`(${presetResolver?.via ?? 'unresolved'})`)}` : C.yellow('(not reachable — modules⊆preset skipped)')}`); console.log(`${C.dim('sotHash')} ${sot.json.sotHash}\n`);