Initial commit
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"path": "AGENTS.md",
|
||||
"sha256": "c4cb865dc6eb42d8bac7b23cd00938b9de3777b6c4adcfc8e81c149314230209"
|
||||
},
|
||||
{
|
||||
"path": "eslint.config.js",
|
||||
"sha256": "64a85c158e386417d855793d256b9c59be38635ce2febcb5a6a15b17f4143745"
|
||||
},
|
||||
{
|
||||
"path": "package.json",
|
||||
"sha256": "91591798591061068905b8f4ca55aed284fd749acd3eaa818a25289a33c17cc5"
|
||||
},
|
||||
{
|
||||
"path": "src/index.ts",
|
||||
"sha256": "d60ea0ad5b84b5362b3de9ae9efd7e7e8122062f58f6e4cf867a4eb72ba18d4d"
|
||||
},
|
||||
{
|
||||
"path": "tsconfig.json",
|
||||
"sha256": "0ed0e183a0842acb42a8c712a9dad0217b4612a9ae17e1f3a3f61b638d204147"
|
||||
},
|
||||
{
|
||||
"path": "turbo.json",
|
||||
"sha256": "ae544305202c960399110a31fe976e2c38577956c5aa738989f5d885f156d130"
|
||||
},
|
||||
{
|
||||
"path": "vitest.config.ts",
|
||||
"sha256": "cc7a9e6aba7cbe81af665c3e1bf50f535f77a2b950db8ccc62775c7b5d380df7"
|
||||
}
|
||||
]
|
||||
142
turbo/generators/__snapshots__/core-package/audit.snapshot.json
Normal file
142
turbo/generators/__snapshots__/core-package/audit.snapshot.json
Normal file
@@ -0,0 +1,142 @@
|
||||
[
|
||||
{
|
||||
"path": "AGENTS.md",
|
||||
"sha256": "65b3fe18cf85c2fb23fe88b5d7bda102316eccee7539c0d326947eb1c0b9a645"
|
||||
},
|
||||
{
|
||||
"path": "docs/library-decisions/2026-05-14-@trpc/server.md",
|
||||
"sha256": "40217b9dd1938083adaf3ccf5faa586447b82c5df60c30f662809f3cd668e4d6"
|
||||
},
|
||||
{
|
||||
"path": "docs/library-decisions/2026-05-14-zod.md",
|
||||
"sha256": "8f18b4c90031dd453c278f549b8d3dbc20083aacef2e6a422233a893afa29bd9"
|
||||
},
|
||||
{
|
||||
"path": "eslint.config.js",
|
||||
"sha256": "64a85c158e386417d855793d256b9c59be38635ce2febcb5a6a15b17f4143745"
|
||||
},
|
||||
{
|
||||
"path": "package.json",
|
||||
"sha256": "8cab25d20ec35d1fea7a219ae826eef968ffcc54a023e5e60c1e14aa99459361"
|
||||
},
|
||||
{
|
||||
"path": "src/audit-log.interface.ts",
|
||||
"sha256": "f316595a17c9b6805d0601fd3b6301ab2d4d8959b11d0690d58df7f127baebe7"
|
||||
},
|
||||
{
|
||||
"path": "src/audit-logs-collection.test.ts",
|
||||
"sha256": "7fc919718cd0fe753e03ba7abc9ef8d423b678da27503ffb6ff66dc4ea1916f0"
|
||||
},
|
||||
{
|
||||
"path": "src/audit-logs-collection.ts",
|
||||
"sha256": "398f868ba38ad27a7393e0b73ccddec4e8a145aec3c055a403a7973e484ddc3f"
|
||||
},
|
||||
{
|
||||
"path": "src/di/bind-audit.test.ts",
|
||||
"sha256": "fbeb4dfdc4fd791f1f0e1ccc6427bd1681994f1726a62e87c1b228100ad2d8bb"
|
||||
},
|
||||
{
|
||||
"path": "src/di/bind-audit.ts",
|
||||
"sha256": "8f8ddad552e0ae8510b98f5e1d422045e7af56fefcc44312a95181481cb99cf1"
|
||||
},
|
||||
{
|
||||
"path": "src/di/symbols.ts",
|
||||
"sha256": "00dfd799408966c7cf7ec0bb2f9c38c2bce3dd4beabd7b0cca66043b51051483"
|
||||
},
|
||||
{
|
||||
"path": "src/hooks/audit-after-read-hook.test.ts",
|
||||
"sha256": "e83b6a83c7ef21383ac9202ad66d859fe9cea1c065903bf6eb36ebefac60ab59"
|
||||
},
|
||||
{
|
||||
"path": "src/hooks/audit-after-read-hook.ts",
|
||||
"sha256": "39c027bde689ecaa5ce06101ae56b30389123ae8589230a32d6cbbaabeb9a28b"
|
||||
},
|
||||
{
|
||||
"path": "src/hooks/audit-erasure-hook.test.ts",
|
||||
"sha256": "2ac009a983b203eade0790ec6ff95c6cb8d7983cbbc1851ae6ec0efd35573838"
|
||||
},
|
||||
{
|
||||
"path": "src/hooks/audit-erasure-hook.ts",
|
||||
"sha256": "aa2cd2264eb65afc76cb81079174a8f7e7c4e4f884fba87f4b72463a5623bae5"
|
||||
},
|
||||
{
|
||||
"path": "src/hooks/index.ts",
|
||||
"sha256": "dfeeba5f12da914d270b1f3ec87541c91435dcec7e9c1f5542148d7bcc288f8b"
|
||||
},
|
||||
{
|
||||
"path": "src/index.ts",
|
||||
"sha256": "1fc3cc9a3c7d80ff5daf9ff9174a3b27a2b4fe8d8b775d73cf15585da0f30f60"
|
||||
},
|
||||
{
|
||||
"path": "src/integrations/api/procedures.ts",
|
||||
"sha256": "f142ac0e40459a8f652a90b06fa6315caac0187b215a1b28f12fcb40bd50f65d"
|
||||
},
|
||||
{
|
||||
"path": "src/integrations/api/router.test.ts",
|
||||
"sha256": "309a9643d88fc6dd2fd2db7defdcf3e826210a9f30c4ba5eb60f9a4074763a45"
|
||||
},
|
||||
{
|
||||
"path": "src/integrations/api/router.ts",
|
||||
"sha256": "6c6ef05ef723776cb6a15ab13ef8b9f5f27c4de81a7e09b6eb299c303bfca841"
|
||||
},
|
||||
{
|
||||
"path": "src/multi-sink-audit-log.test.ts",
|
||||
"sha256": "7b63a2dc865bf0020aef97b052f3c4b69a35a471365ee67b1a3ba88b8728a547"
|
||||
},
|
||||
{
|
||||
"path": "src/multi-sink-audit-log.ts",
|
||||
"sha256": "010045b2c54f0799c4096eb90c8d25ebcb7281e25fe5b1985d9426779f99a5fb"
|
||||
},
|
||||
{
|
||||
"path": "src/noop-audit-log.test.ts",
|
||||
"sha256": "161089a8fdb5c9c2eddae6006ac38cc0d18da82e9f28b6c5acd42b4660876ffb"
|
||||
},
|
||||
{
|
||||
"path": "src/noop-audit-log.ts",
|
||||
"sha256": "b0421ed50916af095740073ae358d8c7b303af6e8639499563bf51f571e81909"
|
||||
},
|
||||
{
|
||||
"path": "src/payload-audit-log.test.ts",
|
||||
"sha256": "fc328519888a850b99cc6e0c9abc0c3888b03573cd5f34a3219ebd889d9b7f10"
|
||||
},
|
||||
{
|
||||
"path": "src/payload-audit-log.ts",
|
||||
"sha256": "e486a18ecb0220596aea0babcb42d3d85e20c9bae630edffc039be89c153beb5"
|
||||
},
|
||||
{
|
||||
"path": "src/pseudonymize.test.ts",
|
||||
"sha256": "bfd0af626ae461ad1a481b99a42053ee75f00ac92c2463b9872e875b178d76ca"
|
||||
},
|
||||
{
|
||||
"path": "src/pseudonymize.ts",
|
||||
"sha256": "8139dccdf79ac6930be44c5b462965cebc59c9cfa4fbde841c7ed9acf1fab1ba"
|
||||
},
|
||||
{
|
||||
"path": "src/stdout-json-audit-log.test.ts",
|
||||
"sha256": "9b4d659a374f5bbd3bb62927afb564e852975dd79a38deace3ca18b027b79735"
|
||||
},
|
||||
{
|
||||
"path": "src/stdout-json-audit-log.ts",
|
||||
"sha256": "6cbc38b1d746e5cd2fce2ae0f3a28c430c3131ce4e09a124c30da74223c39959"
|
||||
},
|
||||
{
|
||||
"path": "src/trace-id-enriching-audit-log.test.ts",
|
||||
"sha256": "d9259dd13a8db1e6c3bf75d9441001031c3bb26495f9f388c8c93f85ec67de22"
|
||||
},
|
||||
{
|
||||
"path": "src/trace-id-enriching-audit-log.ts",
|
||||
"sha256": "1a80e12cbec2e13a5d31545b8641bf777d57a2fc6fbabf2c3cd92917c74f5141"
|
||||
},
|
||||
{
|
||||
"path": "tsconfig.json",
|
||||
"sha256": "0ed0e183a0842acb42a8c712a9dad0217b4612a9ae17e1f3a3f61b638d204147"
|
||||
},
|
||||
{
|
||||
"path": "turbo.json",
|
||||
"sha256": "ae544305202c960399110a31fe976e2c38577956c5aa738989f5d885f156d130"
|
||||
},
|
||||
{
|
||||
"path": "vitest.config.ts",
|
||||
"sha256": "cc7a9e6aba7cbe81af665c3e1bf50f535f77a2b950db8ccc62775c7b5d380df7"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"path": "AGENTS.md",
|
||||
"sha256": "a3307770deb2e9a9df9dab056b0044effcd670c554f7c578ddd801106bf3b543"
|
||||
},
|
||||
{
|
||||
"path": "eslint.config.js",
|
||||
"sha256": "64a85c158e386417d855793d256b9c59be38635ce2febcb5a6a15b17f4143745"
|
||||
},
|
||||
{
|
||||
"path": "package.json",
|
||||
"sha256": "e11fb392c9c97b2998e3cac367a8eacea7f83154d8d7434091a8247cc4bf6a8e"
|
||||
},
|
||||
{
|
||||
"path": "src/index.ts",
|
||||
"sha256": "5c3a1b2a7ad29c987cf4ab08f6b6479572842d11565af7b12ecfa99cf0b7d818"
|
||||
},
|
||||
{
|
||||
"path": "tsconfig.json",
|
||||
"sha256": "0ed0e183a0842acb42a8c712a9dad0217b4612a9ae17e1f3a3f61b638d204147"
|
||||
},
|
||||
{
|
||||
"path": "turbo.json",
|
||||
"sha256": "ae544305202c960399110a31fe976e2c38577956c5aa738989f5d885f156d130"
|
||||
},
|
||||
{
|
||||
"path": "vitest.config.ts",
|
||||
"sha256": "cc7a9e6aba7cbe81af665c3e1bf50f535f77a2b950db8ccc62775c7b5d380df7"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"path": "AGENTS.md",
|
||||
"sha256": "7c8f31314379d35b44cd5cdb71e4726698e169bd5b058457359dd2a35230f183"
|
||||
},
|
||||
{
|
||||
"path": "eslint.config.js",
|
||||
"sha256": "64a85c158e386417d855793d256b9c59be38635ce2febcb5a6a15b17f4143745"
|
||||
},
|
||||
{
|
||||
"path": "package.json",
|
||||
"sha256": "3df6efaf45ae2c5d9b1a268c9a222db97f2f073a0a0e898a1514c26bb3948396"
|
||||
},
|
||||
{
|
||||
"path": "src/index.ts",
|
||||
"sha256": "e9458cd5cacd4f1e574a794baeaee4790737c2481506b4268d965613f6fe744a"
|
||||
},
|
||||
{
|
||||
"path": "tsconfig.json",
|
||||
"sha256": "0ed0e183a0842acb42a8c712a9dad0217b4612a9ae17e1f3a3f61b638d204147"
|
||||
},
|
||||
{
|
||||
"path": "turbo.json",
|
||||
"sha256": "ae544305202c960399110a31fe976e2c38577956c5aa738989f5d885f156d130"
|
||||
},
|
||||
{
|
||||
"path": "vitest.config.ts",
|
||||
"sha256": "cc7a9e6aba7cbe81af665c3e1bf50f535f77a2b950db8ccc62775c7b5d380df7"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
[
|
||||
{
|
||||
"path": "AGENTS.md",
|
||||
"sha256": "8f476285d06563b0d0a3b76ab31bec49311cdc81dffd81f71f4893126270d056"
|
||||
},
|
||||
{
|
||||
"path": "docs/library-decisions/2026-05-14-zod.md",
|
||||
"sha256": "6b159ae8890a51a0ff1cc94a8cb1ee70bc1343a87fc2e6e4d0b197a6c5801ff7"
|
||||
},
|
||||
{
|
||||
"path": "eslint.config.js",
|
||||
"sha256": "64a85c158e386417d855793d256b9c59be38635ce2febcb5a6a15b17f4143745"
|
||||
},
|
||||
{
|
||||
"path": "package.json",
|
||||
"sha256": "63f96a45b46d5609001a75af082b80fbd1ac1a979cf0b0e9b2ca034cddc514fa"
|
||||
},
|
||||
{
|
||||
"path": "src/event-bus.interface.ts",
|
||||
"sha256": "5f82077958c5069c1bce99c8fd6a9a357eb2fa934881db6a9415552b9b5db3b9"
|
||||
},
|
||||
{
|
||||
"path": "src/event-descriptor.test.ts",
|
||||
"sha256": "a9da99d1bcb05cc04f2c95e3871533f41d4ea47b511d63c2548ca5e8104c71b6"
|
||||
},
|
||||
{
|
||||
"path": "src/event-descriptor.ts",
|
||||
"sha256": "c5fc282c93cc6387cdd53a80ec0899e07991bd8ed32583970982a24c53a051f5"
|
||||
},
|
||||
{
|
||||
"path": "src/in-memory-event-bus.test.ts",
|
||||
"sha256": "56f704de153f7676bf243d6350f4bebe28cb9ffec1eb3afef8f5f2bb08561c21"
|
||||
},
|
||||
{
|
||||
"path": "src/in-memory-event-bus.ts",
|
||||
"sha256": "d7de709049bd864bdcd8f02316d981cf0407efbc5b6f13fd2ab86094ecaddb8a"
|
||||
},
|
||||
{
|
||||
"path": "src/index.ts",
|
||||
"sha256": "79094770bdde014adee978707cafcf130da437e445db9e17b97bdc2324fcf411"
|
||||
},
|
||||
{
|
||||
"path": "src/payload-jobs-event-bus.test.ts",
|
||||
"sha256": "cda493839ed9af7d48a4c240392933af7167805a94ebaf30efd64f8a374b7441"
|
||||
},
|
||||
{
|
||||
"path": "src/payload-jobs-event-bus.ts",
|
||||
"sha256": "5fbeb611af7f4ba4302828de6a0262a74d9b4bb6a11cbbb8ed40712aae731ded"
|
||||
},
|
||||
{
|
||||
"path": "src/symbols.ts",
|
||||
"sha256": "b4c4179bae709a6dc38844eb9177ee2a80c2eedb26ccfe645ffd226bbec26f91"
|
||||
},
|
||||
{
|
||||
"path": "tsconfig.json",
|
||||
"sha256": "770de4ddfc7c8e8984a9795fd29b74ec3a5dde2fbff758e4e64c4c176bf42361"
|
||||
},
|
||||
{
|
||||
"path": "turbo.json",
|
||||
"sha256": "ae544305202c960399110a31fe976e2c38577956c5aa738989f5d885f156d130"
|
||||
},
|
||||
{
|
||||
"path": "vitest.config.ts",
|
||||
"sha256": "cc7a9e6aba7cbe81af665c3e1bf50f535f77a2b950db8ccc62775c7b5d380df7"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,122 @@
|
||||
[
|
||||
{
|
||||
"path": "AGENTS.md",
|
||||
"sha256": "a9b9527c327a4164ee4ffca81727118f3876711c1cf3f0b8f32542d08531ccc3"
|
||||
},
|
||||
{
|
||||
"path": "docs/library-decisions/2026-05-14-socket.io.md",
|
||||
"sha256": "7fd94c82037bdb92a90329355123950132050ca6b7b828292359438f8c9a562f"
|
||||
},
|
||||
{
|
||||
"path": "docs/library-decisions/2026-05-14-zod.md",
|
||||
"sha256": "412f92181566e983259982cefc0c7c3b632f289d566971badc855f7a5a03f4a5"
|
||||
},
|
||||
{
|
||||
"path": "eslint.config.js",
|
||||
"sha256": "64a85c158e386417d855793d256b9c59be38635ce2febcb5a6a15b17f4143745"
|
||||
},
|
||||
{
|
||||
"path": "package.json",
|
||||
"sha256": "ce70964118f988701e169f2df5fb5c558833dcf44204b0fa1d3c4641e67d45a3"
|
||||
},
|
||||
{
|
||||
"path": "src/authorize.test.ts",
|
||||
"sha256": "0030cc5ddb12a38a8de1c50b9973cdba09ac4055400e25983dc034dbe71193cb"
|
||||
},
|
||||
{
|
||||
"path": "src/authorize.ts",
|
||||
"sha256": "404c4b72f48000cb9fd8fc4ced7d782e9427e498ee5e492687b8076fdde3b642"
|
||||
},
|
||||
{
|
||||
"path": "src/channel-room.ts",
|
||||
"sha256": "a10260850326718e46c3cb848de8b30df886006020125aaf6516ea0224997329"
|
||||
},
|
||||
{
|
||||
"path": "src/channel-template.test.ts",
|
||||
"sha256": "35a7e15128d89885dcfd91a515a6de33324d0997540a4a1cd79719047e6bb711"
|
||||
},
|
||||
{
|
||||
"path": "src/channel-template.ts",
|
||||
"sha256": "f25a0a975a1d25629c8b07bad8dc8a5c113abdd4825ce72b11f59ca1df6f1f97"
|
||||
},
|
||||
{
|
||||
"path": "src/in-memory-realtime-broadcaster.test.ts",
|
||||
"sha256": "e61df5d86315d91f756be5d9af22642f96631ee1710e3e8e0d5d28699840483d"
|
||||
},
|
||||
{
|
||||
"path": "src/in-memory-realtime-broadcaster.ts",
|
||||
"sha256": "0e1f14a1227d3479fbfed899b930c0b5e4fef9197cd81eb4d49ccfad063d16fc"
|
||||
},
|
||||
{
|
||||
"path": "src/index.ts",
|
||||
"sha256": "a6600ba0d37658e40e9a29aef107d5375626dcb96abed2acfa424b1fbd81360d"
|
||||
},
|
||||
{
|
||||
"path": "src/realtime-authenticator.interface.ts",
|
||||
"sha256": "d5134e00f5381e3d6184575b515e29a2b0be86e35eab645c93d48aadde813467"
|
||||
},
|
||||
{
|
||||
"path": "src/realtime-broadcaster.interface.ts",
|
||||
"sha256": "83f79438dd18dfd34da886d2c0c9a01b5b8b6a5c0343476e42d4564eccf5535e"
|
||||
},
|
||||
{
|
||||
"path": "src/realtime-channel.test.ts",
|
||||
"sha256": "b48da304a506d34fff3fb313f9697c27922d3e16b3bb7e0f9f26503ab7b4d8a0"
|
||||
},
|
||||
{
|
||||
"path": "src/realtime-channel.ts",
|
||||
"sha256": "8a9764e2cbd6aa3019e82042b1ad7b33aeae64c80d35fdd29a1bd142c348ba59"
|
||||
},
|
||||
{
|
||||
"path": "src/realtime-handler-registry.test.ts",
|
||||
"sha256": "4a1a22639d772452848ec376e2cfbe5aa5eaa663168a058421c3d29e318f064e"
|
||||
},
|
||||
{
|
||||
"path": "src/realtime-handler-registry.ts",
|
||||
"sha256": "6726a36377d83d828f481febd38901611180071e28a8f3fa06aa735904bf7ee0"
|
||||
},
|
||||
{
|
||||
"path": "src/realtime-handler.interface.ts",
|
||||
"sha256": "c28f56e374281d93b39f50e6c74713afd25b6e51fa3891ad170d48e1c7de8343"
|
||||
},
|
||||
{
|
||||
"path": "src/realtime-ping.ts",
|
||||
"sha256": "2c68dc003865ba30ad0f9e2c1207030bc893151195df68e9c5d7358bdc68e08f"
|
||||
},
|
||||
{
|
||||
"path": "src/realtime-server.interface.ts",
|
||||
"sha256": "3928bcdc5e7e39048db554664ddaa3a8b444020ebc1d984f2eb6882ed3e7d5ef"
|
||||
},
|
||||
{
|
||||
"path": "src/socket-io-realtime-broadcaster.test.ts",
|
||||
"sha256": "1b1918f321e83196153a760da6193edd6a7d1cadc59e0947f97a598c3c2dd0e1"
|
||||
},
|
||||
{
|
||||
"path": "src/socket-io-realtime-broadcaster.ts",
|
||||
"sha256": "ae9d177b4d7bb47c2152b2b9ad2f5681d9ddcfb6fd09f2de565b55cd5837c7d7"
|
||||
},
|
||||
{
|
||||
"path": "src/socket-io-realtime-server.test.ts",
|
||||
"sha256": "ea13510ba1cfc8591c98336a00c598e1f756c2ca373bfaa705faf502752322e7"
|
||||
},
|
||||
{
|
||||
"path": "src/socket-io-realtime-server.ts",
|
||||
"sha256": "93b23af378de5bb2fe2be588a9e6d517f97ed68ab49c00ce4ff91ceb0d2553de"
|
||||
},
|
||||
{
|
||||
"path": "src/symbols.ts",
|
||||
"sha256": "3f899581aa6861a2e9e29127f4666cc701793cd478f4d25c5d537af0fd6db69d"
|
||||
},
|
||||
{
|
||||
"path": "tsconfig.json",
|
||||
"sha256": "739ba5cffa02bc348254cfe14829f8188ae1f67eb15d33a8e7a995331d39a58d"
|
||||
},
|
||||
{
|
||||
"path": "turbo.json",
|
||||
"sha256": "3b9d3e6a95048be20ff1eb2169abc76336390e7e5efc95250bab8a05ef27b4ce"
|
||||
},
|
||||
{
|
||||
"path": "vitest.config.ts",
|
||||
"sha256": "cc7a9e6aba7cbe81af665c3e1bf50f535f77a2b950db8ccc62775c7b5d380df7"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,78 @@
|
||||
[
|
||||
{
|
||||
"path": "AGENTS.md",
|
||||
"sha256": "b1b9c2c1be6925f37576841d88929916286dc530a2ad979e675d038235aab6ad"
|
||||
},
|
||||
{
|
||||
"path": "docs/library-decisions/2026-05-14-@tanstack/react-query.md",
|
||||
"sha256": "9e3fe29d5e6f9a9b81fa70a01f966f485e8a7c1a841a1654e117e3171e07aa6c"
|
||||
},
|
||||
{
|
||||
"path": "docs/library-decisions/2026-05-14-@trpc/client.md",
|
||||
"sha256": "0c871657850954ee7fb0141070c36f8aa0c79932c510773d292aaf8e65ceb871"
|
||||
},
|
||||
{
|
||||
"path": "docs/library-decisions/2026-05-14-@trpc/react-query.md",
|
||||
"sha256": "fc02764ca7727d6353ccdc7230f90c5054f6976304ef6c81d7864a917815f002"
|
||||
},
|
||||
{
|
||||
"path": "docs/library-decisions/2026-05-14-@trpc/server.md",
|
||||
"sha256": "094ae3505561e84090002a8646967a0ae9dcf4ce121bbbc240da06af2cc078c8"
|
||||
},
|
||||
{
|
||||
"path": "docs/library-decisions/2026-05-14-@trpc/tanstack-react-query.md",
|
||||
"sha256": "f139e506a7b09bd0154818cb9616c7a80295d191a89b7fbd7e3fd2eb7912e8f2"
|
||||
},
|
||||
{
|
||||
"path": "docs/library-decisions/2026-05-14-react.md",
|
||||
"sha256": "dbd8727184632f403404a34f7d26be572461bbbf8923cbbf747438ae4feb1529"
|
||||
},
|
||||
{
|
||||
"path": "docs/library-decisions/2026-05-14-superjson.md",
|
||||
"sha256": "b6404b77aa0e222fd6100a7b29e2572f75103a01c7d905d112d90a31e8443eed"
|
||||
},
|
||||
{
|
||||
"path": "eslint.config.js",
|
||||
"sha256": "64a85c158e386417d855793d256b9c59be38635ce2febcb5a6a15b17f4143745"
|
||||
},
|
||||
{
|
||||
"path": "package.json",
|
||||
"sha256": "ea6f2ec723f178ce75add8b35bad831082c41edca1dccd35d43bc29fe79622f6"
|
||||
},
|
||||
{
|
||||
"path": "src/client.test.ts",
|
||||
"sha256": "e2e0c89115d7fc2549cce75156b47cfc1ebb5baf1250f6aa7ef77b038042cd77"
|
||||
},
|
||||
{
|
||||
"path": "src/client.ts",
|
||||
"sha256": "5bc70d960a627d3d645b08b67e422ec2881e59813cdb136069e775d41bf914d4"
|
||||
},
|
||||
{
|
||||
"path": "src/index.ts",
|
||||
"sha256": "edc8be26a0cb5483b144f26d98e0e0a9e21d5131b5eb4e1e7c61a8ca69904c79"
|
||||
},
|
||||
{
|
||||
"path": "src/providers/next-provider.tsx",
|
||||
"sha256": "dbf6b0024f406a553b7e8a331ea33768f73dc4e1b70c470344dc14e8528ec4f8"
|
||||
},
|
||||
{
|
||||
"path": "src/providers/tanstack-provider.tsx",
|
||||
"sha256": "ec532c1b221811d4cc4c849f1f9e964946f014d1a91c3355fe049e4471d7db40"
|
||||
},
|
||||
{
|
||||
"path": "src/query-client.ts",
|
||||
"sha256": "59a8502f0879bea2bc0599c40af5e1ab50e0d646cbe9139ebe9a95ca140df3de"
|
||||
},
|
||||
{
|
||||
"path": "tsconfig.json",
|
||||
"sha256": "4ec92b5c639df1585812929a0acc87a578f82a6f33321ab6e524d4c5e9e9e436"
|
||||
},
|
||||
{
|
||||
"path": "turbo.json",
|
||||
"sha256": "5fea14905eb99eb1ea6d974e4802117f330aabf8bb59c983c7af94df244fe937"
|
||||
},
|
||||
{
|
||||
"path": "vitest.config.ts",
|
||||
"sha256": "76514d8802c94e002a8df04d30946eb5c40890421b37036dfd43a8bcbd2434c2"
|
||||
}
|
||||
]
|
||||
126
turbo/generators/__snapshots__/core-package/ui.snapshot.json
Normal file
126
turbo/generators/__snapshots__/core-package/ui.snapshot.json
Normal file
@@ -0,0 +1,126 @@
|
||||
[
|
||||
{
|
||||
"path": "AGENTS.md",
|
||||
"sha256": "626329a8a409b4428307ba0014fb8be179a5db7d1fc2a43b017941ab44476188"
|
||||
},
|
||||
{
|
||||
"path": "docs/library-decisions/2026-05-14-clsx.md",
|
||||
"sha256": "3507768e0ee216dac6983b35b1ac7a6326a85f35a659863d266a340655602429"
|
||||
},
|
||||
{
|
||||
"path": "docs/library-decisions/2026-05-14-react.md",
|
||||
"sha256": "9d6178b1ec0b0ddba60b83ef8c5d65791f6890092e18d1813f7a2db1e8bb1e94"
|
||||
},
|
||||
{
|
||||
"path": "docs/library-decisions/2026-05-14-tailwind-merge.md",
|
||||
"sha256": "e8e2474dfc62f95f0671c659c04055371dec7dc9a2f2d26fc318eccaa7d59504"
|
||||
},
|
||||
{
|
||||
"path": "eslint.config.js",
|
||||
"sha256": "64a85c158e386417d855793d256b9c59be38635ce2febcb5a6a15b17f4143745"
|
||||
},
|
||||
{
|
||||
"path": "package.json",
|
||||
"sha256": "8ffd2e9c8adb1e0eab863a9abc81bb90a05afdbd625d1348e0f2ba7874d0680f"
|
||||
},
|
||||
{
|
||||
"path": "src/atoms/button/button.stories.tsx",
|
||||
"sha256": "5ea74a9a4b2431b2a4dbae456bcce6d051842caad19e83ca8e15d99f79d36b5d"
|
||||
},
|
||||
{
|
||||
"path": "src/atoms/button/button.test.tsx",
|
||||
"sha256": "e768f2c1ebd7e120d543a95c67a2a703a85c5ad6847b7e7f8420fa472771fb82"
|
||||
},
|
||||
{
|
||||
"path": "src/atoms/button/button.tsx",
|
||||
"sha256": "4d62a3a9703721f64d397e468cdd90225b1d6dd739fd8c17b90962c8e9a7b2c3"
|
||||
},
|
||||
{
|
||||
"path": "src/atoms/button/index.ts",
|
||||
"sha256": "fb0a14a0c15536e05a58d6d03f41afcee85fb6a24c5ca6910cb172ae26fbcc34"
|
||||
},
|
||||
{
|
||||
"path": "src/atoms/index.ts",
|
||||
"sha256": "2764f7cbc20b84cba7bde6ab1bb3a0726060e3db227dfd41c3781ce11cba6239"
|
||||
},
|
||||
{
|
||||
"path": "src/atoms/input/index.ts",
|
||||
"sha256": "490ba122bc3161dc59d24b274372608b8b319f5c0270699fcdcc3fb6d5d4cffd"
|
||||
},
|
||||
{
|
||||
"path": "src/atoms/input/input.stories.tsx",
|
||||
"sha256": "c95b51b7b3c4ad42f7b118a9a6569ad6d6a3200f6bf1f2d8f029a92bc307c53e"
|
||||
},
|
||||
{
|
||||
"path": "src/atoms/input/input.test.tsx",
|
||||
"sha256": "b0fdb584cc9def4c1c7394e6f524c5d614ac74284a50d298ee48254dd4a80162"
|
||||
},
|
||||
{
|
||||
"path": "src/atoms/input/input.tsx",
|
||||
"sha256": "bd2f477b3a79a29549d18db9605e1a34290fa31ea97d6292d6be77d2e95729f4"
|
||||
},
|
||||
{
|
||||
"path": "src/atoms/label/index.ts",
|
||||
"sha256": "d9a205b2fd3133823a74c265e09b01dcd5068ce21b82217386d6166bbec68fb6"
|
||||
},
|
||||
{
|
||||
"path": "src/atoms/label/label.test.tsx",
|
||||
"sha256": "ac8135a7a05a61e34ec9346148d9305190b5f8ec3dd3a5d15df2b2841e24caac"
|
||||
},
|
||||
{
|
||||
"path": "src/atoms/label/label.tsx",
|
||||
"sha256": "6c6f13125fbc96b55806b9ff2230472e8d24ba45c7cd39e58573460691e23885"
|
||||
},
|
||||
{
|
||||
"path": "src/index.ts",
|
||||
"sha256": "56303fd5e5d7fe574855887defcb974d2f86667dc926ea690f3d3757bbcc4617"
|
||||
},
|
||||
{
|
||||
"path": "src/lib/utils.ts",
|
||||
"sha256": "d1f1e0d62cb8d8d1e04c26e14de842d8a151f75812d81b046c65b5d1fe8e4b27"
|
||||
},
|
||||
{
|
||||
"path": "src/molecules/form-field/form-field.stories.tsx",
|
||||
"sha256": "914c842de560d80091970e3e1ae15c214c396893cc735b6f25460620528e5157"
|
||||
},
|
||||
{
|
||||
"path": "src/molecules/form-field/form-field.test.tsx",
|
||||
"sha256": "a63244a6a00f7fdba01223c0697a9cfe93a52917e7d09b577e9ffa6547b1399c"
|
||||
},
|
||||
{
|
||||
"path": "src/molecules/form-field/form-field.tsx",
|
||||
"sha256": "f51a6c4e9550fccaff90a932b9ad3906e8973c82faf0237cf07e1c864536f5f6"
|
||||
},
|
||||
{
|
||||
"path": "src/molecules/form-field/index.ts",
|
||||
"sha256": "7fe955a7b2c65b1732ee799812be4ac3150346c5bdc91ea6afe0ea7c1034d2cc"
|
||||
},
|
||||
{
|
||||
"path": "src/molecules/index.ts",
|
||||
"sha256": "6c5a7132e904cf0592599347713093926b678422a4f0e9515216434fd3a643c1"
|
||||
},
|
||||
{
|
||||
"path": "src/organisms/index.ts",
|
||||
"sha256": "3cef9504d6eca5a949207145c8e8787c988f2a389cb19d293c128a1afd617dcd"
|
||||
},
|
||||
{
|
||||
"path": "src/styles/globals.css",
|
||||
"sha256": "58c6e64ac0b26f3897f9a76f86a35dfff1acc7bae8e7e761bca0c8035763cfd2"
|
||||
},
|
||||
{
|
||||
"path": "src/templates/index.ts",
|
||||
"sha256": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"
|
||||
},
|
||||
{
|
||||
"path": "tsconfig.json",
|
||||
"sha256": "7bbc28ad21b936ca80c7a3ec21094d34012d624ca1d4e63a6f3c3120abba551f"
|
||||
},
|
||||
{
|
||||
"path": "turbo.json",
|
||||
"sha256": "ae544305202c960399110a31fe976e2c38577956c5aa738989f5d885f156d130"
|
||||
},
|
||||
{
|
||||
"path": "vitest.config.ts",
|
||||
"sha256": "053df54e347b147d85632286d44e6587aa6c3ae39677fbc1a8599e89ae48a81c"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, onTestFinished } from "vitest";
|
||||
import { mkdtempSync, rmSync, cpSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { computeSnapshot } from "../lib/snapshot.js";
|
||||
import expectedSnapshot from "../__snapshots__/core-package/analytics.snapshot.json" with { type: "json" };
|
||||
|
||||
// Repo root is 2 levels up from turbo/generators/__tests__
|
||||
const REPO_ROOT = resolve(
|
||||
fileURLToPath(import.meta.url),
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
);
|
||||
|
||||
describe("e2e: core-package analytics", () => {
|
||||
it(
|
||||
"byte-identical reconstruction matches snapshot",
|
||||
{ timeout: 120_000 },
|
||||
() => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "e2e-analytics-"));
|
||||
// Each e2e run clones the repo into the OS temp dir; remove it when
|
||||
// the test finishes so sequential runs don't accumulate and fill disk.
|
||||
onTestFinished(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
cpSync(REPO_ROOT, tmp, {
|
||||
recursive: true,
|
||||
filter: (src) =>
|
||||
!src.includes("node_modules") &&
|
||||
!src.includes(".turbo") &&
|
||||
!src.includes(".pnpm-store") &&
|
||||
!src.includes("packages/core-analytics"),
|
||||
});
|
||||
|
||||
// No other package.json depends on @repo/core-analytics, so no strip
|
||||
// step is needed — excluding the source dir is sufficient.
|
||||
execSync(`cd ${tmp} && pnpm install --frozen-lockfile=false`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
execSync(`cd ${tmp} && pnpm turbo gen core-package --args analytics`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
const result = computeSnapshot(join(tmp, "packages/core-analytics"));
|
||||
expect(result).toEqual(expectedSnapshot);
|
||||
},
|
||||
);
|
||||
});
|
||||
78
turbo/generators/__tests__/core-package-audit.e2e.test.ts
Normal file
78
turbo/generators/__tests__/core-package-audit.e2e.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, onTestFinished } from "vitest";
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
cpSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { computeSnapshot } from "../lib/snapshot.js";
|
||||
import expectedSnapshot from "../__snapshots__/core-package/audit.snapshot.json" with { type: "json" };
|
||||
|
||||
// Repo root is 2 levels up from turbo/generators/__tests__
|
||||
const REPO_ROOT = resolve(
|
||||
fileURLToPath(import.meta.url),
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
);
|
||||
|
||||
/**
|
||||
* Strip "@repo/core-audit" from a package.json file in the tmp tree.
|
||||
* Required when simulating a fresh scaffold: core-audit does not exist yet
|
||||
* but the snapshot was captured before removal. Apps in the current tree
|
||||
* may still list it as a dependency.
|
||||
*/
|
||||
function stripCoreAuditDep(pkgJsonPath: string): void {
|
||||
const raw = readFileSync(pkgJsonPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, Record<string, string>>;
|
||||
for (const section of [
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"peerDependencies",
|
||||
] as const) {
|
||||
if (parsed[section]?.["@repo/core-audit"]) {
|
||||
delete parsed[section]["@repo/core-audit"];
|
||||
}
|
||||
}
|
||||
writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + "\n");
|
||||
}
|
||||
|
||||
describe("e2e: core-package audit", () => {
|
||||
it(
|
||||
"byte-identical reconstruction matches snapshot",
|
||||
{ timeout: 120_000 },
|
||||
() => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "e2e-audit-"));
|
||||
// Each e2e run clones the repo into the OS temp dir; remove it when
|
||||
// the test finishes so sequential runs don't accumulate and fill disk.
|
||||
onTestFinished(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
cpSync(REPO_ROOT, tmp, {
|
||||
recursive: true,
|
||||
filter: (src) =>
|
||||
!src.includes("node_modules") &&
|
||||
!src.includes(".turbo") &&
|
||||
!src.includes(".pnpm-store") &&
|
||||
!src.includes("packages/core-audit"),
|
||||
});
|
||||
|
||||
// Strip @repo/core-audit from apps/web-next/package.json so pnpm install
|
||||
// succeeds without the package being present (simulating the post-removal state).
|
||||
stripCoreAuditDep(join(tmp, "apps", "web-next", "package.json"));
|
||||
|
||||
execSync(`cd ${tmp} && pnpm install --frozen-lockfile=false`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
execSync(`cd ${tmp} && pnpm turbo gen core-package --args audit`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
const result = computeSnapshot(join(tmp, "packages/core-audit"));
|
||||
expect(result).toEqual(expectedSnapshot);
|
||||
},
|
||||
);
|
||||
});
|
||||
79
turbo/generators/__tests__/core-package-consent.e2e.test.ts
Normal file
79
turbo/generators/__tests__/core-package-consent.e2e.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, onTestFinished } from "vitest";
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
cpSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { computeSnapshot } from "../lib/snapshot.js";
|
||||
import expectedSnapshot from "../__snapshots__/core-package/consent.snapshot.json" with { type: "json" };
|
||||
|
||||
// Repo root is 2 levels up from turbo/generators/__tests__
|
||||
const REPO_ROOT = resolve(
|
||||
fileURLToPath(import.meta.url),
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
);
|
||||
|
||||
/**
|
||||
* Strip "@repo/core-consent" from a package.json file in the tmp tree.
|
||||
* Required when simulating a fresh scaffold: core-consent does not exist yet
|
||||
* but the snapshot was captured before removal. Other packages in the current
|
||||
* tree may still list it as a dependency.
|
||||
*/
|
||||
function stripCoreConsentDep(pkgJsonPath: string): void {
|
||||
const raw = readFileSync(pkgJsonPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, Record<string, string>>;
|
||||
for (const section of [
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"peerDependencies",
|
||||
] as const) {
|
||||
if (parsed[section]?.["@repo/core-consent"]) {
|
||||
delete parsed[section]["@repo/core-consent"];
|
||||
}
|
||||
}
|
||||
writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + "\n");
|
||||
}
|
||||
|
||||
describe("e2e: core-package consent", () => {
|
||||
it(
|
||||
"byte-identical reconstruction matches snapshot",
|
||||
{ timeout: 120_000 },
|
||||
() => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "e2e-consent-"));
|
||||
// Each e2e run clones the repo into the OS temp dir; remove it when
|
||||
// the test finishes so sequential runs don't accumulate and fill disk.
|
||||
onTestFinished(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
cpSync(REPO_ROOT, tmp, {
|
||||
recursive: true,
|
||||
filter: (src) =>
|
||||
!src.includes("node_modules") &&
|
||||
!src.includes(".turbo") &&
|
||||
!src.includes(".pnpm-store") &&
|
||||
!src.includes("packages/core-consent"),
|
||||
});
|
||||
|
||||
// Strip @repo/core-consent from package.json files so pnpm install
|
||||
// succeeds without the package being present (simulating the post-removal state).
|
||||
stripCoreConsentDep(join(tmp, "packages", "core-api", "package.json"));
|
||||
stripCoreConsentDep(join(tmp, "packages", "core-ui", "package.json"));
|
||||
|
||||
execSync(`cd ${tmp} && pnpm install --frozen-lockfile=false`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
execSync(`cd ${tmp} && pnpm turbo gen core-package --args consent`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
const result = computeSnapshot(join(tmp, "packages/core-consent"));
|
||||
expect(result).toEqual(expectedSnapshot);
|
||||
},
|
||||
);
|
||||
});
|
||||
78
turbo/generators/__tests__/core-package-dsr.e2e.test.ts
Normal file
78
turbo/generators/__tests__/core-package-dsr.e2e.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, onTestFinished } from "vitest";
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
cpSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { computeSnapshot } from "../lib/snapshot.js";
|
||||
import expectedSnapshot from "../__snapshots__/core-package/dsr.snapshot.json" with { type: "json" };
|
||||
|
||||
// Repo root is 2 levels up from turbo/generators/__tests__
|
||||
const REPO_ROOT = resolve(
|
||||
fileURLToPath(import.meta.url),
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
);
|
||||
|
||||
/**
|
||||
* Strip "@repo/core-dsr" from a package.json file in the tmp tree.
|
||||
* Required when simulating a fresh scaffold: core-dsr does not exist yet
|
||||
* but the snapshot was captured before removal. Other packages in the current
|
||||
* tree may still list it as a dependency.
|
||||
*/
|
||||
function stripCoreDsrDep(pkgJsonPath: string): void {
|
||||
const raw = readFileSync(pkgJsonPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, Record<string, string>>;
|
||||
for (const section of [
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"peerDependencies",
|
||||
] as const) {
|
||||
if (parsed[section]?.["@repo/core-dsr"]) {
|
||||
delete parsed[section]["@repo/core-dsr"];
|
||||
}
|
||||
}
|
||||
writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + "\n");
|
||||
}
|
||||
|
||||
describe("e2e: core-package dsr", () => {
|
||||
it(
|
||||
"byte-identical reconstruction matches snapshot",
|
||||
{ timeout: 120_000 },
|
||||
() => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "e2e-dsr-"));
|
||||
// Each e2e run clones the repo into the OS temp dir; remove it when
|
||||
// the test finishes so sequential runs don't accumulate and fill disk.
|
||||
onTestFinished(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
cpSync(REPO_ROOT, tmp, {
|
||||
recursive: true,
|
||||
filter: (src) =>
|
||||
!src.includes("node_modules") &&
|
||||
!src.includes(".turbo") &&
|
||||
!src.includes(".pnpm-store") &&
|
||||
!src.includes("packages/core-dsr"),
|
||||
});
|
||||
|
||||
// Strip @repo/core-dsr from package.json files so pnpm install
|
||||
// succeeds without the package being present (simulating the post-removal state).
|
||||
stripCoreDsrDep(join(tmp, "packages", "core-api", "package.json"));
|
||||
|
||||
execSync(`cd ${tmp} && pnpm install --frozen-lockfile=false`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
execSync(`cd ${tmp} && pnpm turbo gen core-package --args dsr`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
const result = computeSnapshot(join(tmp, "packages/core-dsr"));
|
||||
expect(result).toEqual(expectedSnapshot);
|
||||
},
|
||||
);
|
||||
});
|
||||
90
turbo/generators/__tests__/core-package-events.e2e.test.ts
Normal file
90
turbo/generators/__tests__/core-package-events.e2e.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, onTestFinished } from "vitest";
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
cpSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { computeSnapshot } from "../lib/snapshot.js";
|
||||
import expectedSnapshot from "../__snapshots__/core-package/events.snapshot.json" with { type: "json" };
|
||||
|
||||
// Repo root is 2 levels up from turbo/generators/__tests__
|
||||
const REPO_ROOT = resolve(
|
||||
fileURLToPath(import.meta.url),
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
);
|
||||
|
||||
/**
|
||||
* Strip "@repo/core-events" from a package.json file in the tmp tree.
|
||||
* Required when simulating a fresh scaffold: core-events does not exist yet
|
||||
* but the snapshot was captured before removal. Feature packages in the
|
||||
* current tree may still list it as a dependency.
|
||||
*/
|
||||
function stripCoreEventsDep(pkgJsonPath: string): void {
|
||||
const raw = readFileSync(pkgJsonPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, Record<string, string>>;
|
||||
for (const section of [
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"peerDependencies",
|
||||
] as const) {
|
||||
if (parsed[section]?.["@repo/core-events"]) {
|
||||
delete parsed[section]["@repo/core-events"];
|
||||
}
|
||||
}
|
||||
writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + "\n");
|
||||
}
|
||||
|
||||
describe("e2e: core-package events", () => {
|
||||
it(
|
||||
"byte-identical reconstruction matches snapshot",
|
||||
{ timeout: 120_000 },
|
||||
() => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "e2e-events-"));
|
||||
// Each e2e run clones the repo into the OS temp dir; remove it when
|
||||
// the test finishes so sequential runs don't accumulate and fill disk.
|
||||
onTestFinished(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
cpSync(REPO_ROOT, tmp, {
|
||||
recursive: true,
|
||||
filter: (src) =>
|
||||
!src.includes("node_modules") &&
|
||||
!src.includes(".turbo") &&
|
||||
!src.includes(".pnpm-store") &&
|
||||
!src.includes("packages/core-events"),
|
||||
});
|
||||
|
||||
// Strip @repo/core-events from feature package.json files so pnpm install
|
||||
// succeeds without the package being present (simulating the post-removal state).
|
||||
const featurePackages = [
|
||||
"auth",
|
||||
"blog",
|
||||
"media",
|
||||
"marketing-pages",
|
||||
"navigation",
|
||||
];
|
||||
for (const pkg of featurePackages) {
|
||||
const pkgJson = join(tmp, "packages", pkg, "package.json");
|
||||
stripCoreEventsDep(pkgJson);
|
||||
}
|
||||
// Also strip from apps/web-next
|
||||
stripCoreEventsDep(join(tmp, "apps", "web-next", "package.json"));
|
||||
|
||||
execSync(`cd ${tmp} && pnpm install --frozen-lockfile=false`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
execSync(`cd ${tmp} && pnpm turbo gen core-package --args events`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
const result = computeSnapshot(join(tmp, "packages/core-events"));
|
||||
expect(result).toEqual(expectedSnapshot);
|
||||
},
|
||||
);
|
||||
});
|
||||
46
turbo/generators/__tests__/core-package-realtime.e2e.test.ts
Normal file
46
turbo/generators/__tests__/core-package-realtime.e2e.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, onTestFinished } from "vitest";
|
||||
import { mkdtempSync, rmSync, cpSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { computeSnapshot } from "../lib/snapshot.js";
|
||||
import expectedSnapshot from "../__snapshots__/core-package/realtime.snapshot.json" with { type: "json" };
|
||||
|
||||
// Repo root is 2 levels up from turbo/generators/__tests__
|
||||
const REPO_ROOT = resolve(
|
||||
fileURLToPath(import.meta.url),
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
);
|
||||
|
||||
describe("e2e: core-package realtime", () => {
|
||||
it(
|
||||
"byte-identical reconstruction matches snapshot",
|
||||
{ timeout: 120_000 },
|
||||
() => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "e2e-"));
|
||||
// Each e2e run clones the repo into the OS temp dir; remove it when
|
||||
// the test finishes so sequential runs don't accumulate and fill disk.
|
||||
onTestFinished(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
cpSync(REPO_ROOT, tmp, {
|
||||
recursive: true,
|
||||
filter: (src) =>
|
||||
!src.includes("node_modules") &&
|
||||
!src.includes(".turbo") &&
|
||||
!src.includes(".pnpm-store") &&
|
||||
!src.includes("packages/core-realtime"),
|
||||
});
|
||||
execSync(`cd ${tmp} && pnpm install --frozen-lockfile=false`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
execSync(`cd ${tmp} && pnpm turbo gen core-package --args realtime`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
const result = computeSnapshot(join(tmp, "packages/core-realtime"));
|
||||
expect(result).toEqual(expectedSnapshot);
|
||||
},
|
||||
);
|
||||
});
|
||||
88
turbo/generators/__tests__/core-package-trpc.e2e.test.ts
Normal file
88
turbo/generators/__tests__/core-package-trpc.e2e.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, onTestFinished } from "vitest";
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
cpSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { computeSnapshot } from "../lib/snapshot.js";
|
||||
import expectedSnapshot from "../__snapshots__/core-package/trpc.snapshot.json" with { type: "json" };
|
||||
|
||||
// Repo root is 2 levels up from turbo/generators/__tests__
|
||||
const REPO_ROOT = resolve(
|
||||
fileURLToPath(import.meta.url),
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
);
|
||||
|
||||
/**
|
||||
* Strip "@repo/core-trpc" from a package.json file in the tmp tree.
|
||||
* Required when simulating a fresh scaffold: core-trpc does not exist yet
|
||||
* but the snapshot was captured before removal. Apps in the current tree
|
||||
* still list it as a dependency.
|
||||
*/
|
||||
function stripCoreTrpcDep(pkgJsonPath: string): void {
|
||||
const raw = readFileSync(pkgJsonPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, Record<string, string>>;
|
||||
for (const section of [
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"peerDependencies",
|
||||
] as const) {
|
||||
if (parsed[section]?.["@repo/core-trpc"]) {
|
||||
delete parsed[section]["@repo/core-trpc"];
|
||||
}
|
||||
}
|
||||
writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + "\n");
|
||||
}
|
||||
|
||||
describe("e2e: core-package trpc", () => {
|
||||
it(
|
||||
"byte-identical reconstruction matches snapshot",
|
||||
{ timeout: 120_000 },
|
||||
() => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "e2e-trpc-"));
|
||||
// Each e2e run clones the repo into the OS temp dir; remove it when
|
||||
// the test finishes so sequential runs don't accumulate and fill disk.
|
||||
onTestFinished(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
cpSync(REPO_ROOT, tmp, {
|
||||
recursive: true,
|
||||
filter: (src) =>
|
||||
!src.includes("node_modules") &&
|
||||
!src.includes(".turbo") &&
|
||||
!src.includes(".pnpm-store") &&
|
||||
!src.includes("packages/core-trpc"),
|
||||
});
|
||||
|
||||
// Strip @repo/core-trpc from every package that references it so pnpm
|
||||
// install succeeds without the package being present (simulating the
|
||||
// post-removal state). Apps list it directly; the blog, marketing-pages,
|
||||
// and navigation features depend on it for their ./ui tRPC hooks.
|
||||
for (const pkgDir of [
|
||||
["apps", "web-next"],
|
||||
["apps", "web-tanstack"],
|
||||
["packages", "blog"],
|
||||
["packages", "marketing-pages"],
|
||||
["packages", "navigation"],
|
||||
]) {
|
||||
stripCoreTrpcDep(join(tmp, ...pkgDir, "package.json"));
|
||||
}
|
||||
|
||||
execSync(`cd ${tmp} && pnpm install --frozen-lockfile=false`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
execSync(`cd ${tmp} && pnpm turbo gen core-package --args trpc`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
const result = computeSnapshot(join(tmp, "packages/core-trpc"));
|
||||
expect(result).toEqual(expectedSnapshot);
|
||||
},
|
||||
);
|
||||
});
|
||||
80
turbo/generators/__tests__/core-package-ui.e2e.test.ts
Normal file
80
turbo/generators/__tests__/core-package-ui.e2e.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, onTestFinished } from "vitest";
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
cpSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { computeSnapshot } from "../lib/snapshot.js";
|
||||
import expectedSnapshot from "../__snapshots__/core-package/ui.snapshot.json" with { type: "json" };
|
||||
|
||||
// Repo root is 2 levels up from turbo/generators/__tests__
|
||||
const REPO_ROOT = resolve(
|
||||
fileURLToPath(import.meta.url),
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
);
|
||||
|
||||
/**
|
||||
* Strip "@repo/core-ui" from a package.json file in the tmp tree.
|
||||
* Required when simulating a fresh scaffold: core-ui does not exist yet
|
||||
* but the snapshot was captured before removal. Apps in the current tree
|
||||
* still list it as a dependency.
|
||||
*/
|
||||
function stripCoreUiDep(pkgJsonPath: string): void {
|
||||
const raw = readFileSync(pkgJsonPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, Record<string, string>>;
|
||||
for (const section of [
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"peerDependencies",
|
||||
] as const) {
|
||||
if (parsed[section]?.["@repo/core-ui"]) {
|
||||
delete parsed[section]["@repo/core-ui"];
|
||||
}
|
||||
}
|
||||
writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + "\n");
|
||||
}
|
||||
|
||||
describe("e2e: core-package ui", () => {
|
||||
it(
|
||||
"byte-identical reconstruction matches snapshot",
|
||||
{ timeout: 120_000 },
|
||||
() => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "e2e-ui-"));
|
||||
// Each e2e run clones the repo into the OS temp dir; remove it when
|
||||
// the test finishes so sequential runs don't accumulate and fill disk.
|
||||
onTestFinished(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
cpSync(REPO_ROOT, tmp, {
|
||||
recursive: true,
|
||||
filter: (src) =>
|
||||
!src.includes("node_modules") &&
|
||||
!src.includes(".turbo") &&
|
||||
!src.includes(".pnpm-store") &&
|
||||
!src.includes("packages/core-ui"),
|
||||
});
|
||||
|
||||
// Strip @repo/core-ui from app package.json files so pnpm install
|
||||
// succeeds without the package being present (simulating the post-removal state).
|
||||
stripCoreUiDep(join(tmp, "apps", "web-next", "package.json"));
|
||||
stripCoreUiDep(join(tmp, "apps", "web-tanstack", "package.json"));
|
||||
stripCoreUiDep(join(tmp, "apps", "storybook", "package.json"));
|
||||
|
||||
execSync(`cd ${tmp} && pnpm install --frozen-lockfile=false`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
execSync(`cd ${tmp} && pnpm turbo gen core-package --args ui`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
const result = computeSnapshot(join(tmp, "packages/core-ui"));
|
||||
expect(result).toEqual(expectedSnapshot);
|
||||
},
|
||||
);
|
||||
});
|
||||
76
turbo/generators/__tests__/feature.e2e.test.ts
Normal file
76
turbo/generators/__tests__/feature.e2e.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, onTestFinished } from "vitest";
|
||||
import { mkdtempSync, rmSync, cpSync, existsSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Repo root is 2 levels up from turbo/generators/__tests__
|
||||
const REPO_ROOT = resolve(
|
||||
fileURLToPath(import.meta.url),
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
);
|
||||
|
||||
/**
|
||||
* Generator-composition e2e: scaffold a feature, then run `gen job` against
|
||||
* the freshly-scaffolded feature.
|
||||
*
|
||||
* The `core-package` e2e tests verify each generator's output in isolation
|
||||
* (byte-identical snapshots). This one verifies generators run *in sequence*:
|
||||
* `gen job` asserts the consumer feature's `integrations/cms/index.ts` carries
|
||||
* a `<gen:job-tasks>` anchor, so this test fails loudly if the `feature`
|
||||
* template ever stops emitting that barrel — the regression that shipped once.
|
||||
*/
|
||||
describe("e2e: feature generator composition", () => {
|
||||
it(
|
||||
"scaffolds a feature that `gen job` can extend",
|
||||
{ timeout: 180_000 },
|
||||
() => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "e2e-feature-"));
|
||||
// Remove the temp clone when the test finishes so sequential runs
|
||||
// don't accumulate and fill disk.
|
||||
onTestFinished(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
cpSync(REPO_ROOT, tmp, {
|
||||
recursive: true,
|
||||
filter: (src) =>
|
||||
!src.includes("node_modules") &&
|
||||
!src.includes(".turbo") &&
|
||||
!src.includes(".pnpm-store"),
|
||||
});
|
||||
|
||||
execSync(`cd ${tmp} && pnpm install --frozen-lockfile=false`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
execSync(
|
||||
`cd ${tmp} && pnpm turbo gen feature --args demo-feature Widget widgets`,
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
// Regression guard: `gen job` throws at its anchor assertion if the
|
||||
// feature template did not emit src/integrations/cms/index.ts.
|
||||
execSync(
|
||||
`cd ${tmp} && pnpm turbo gen job --args demo-feature send-demo-email void`,
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
const featureRoot = join(tmp, "packages", "demo-feature");
|
||||
|
||||
const cmsIndex = join(featureRoot, "src/integrations/cms/index.ts");
|
||||
expect(existsSync(cmsIndex)).toBe(true);
|
||||
expect(readFileSync(cmsIndex, "utf8")).toContain(
|
||||
'export { sendDemoEmailTask } from "./jobs/send-demo-email.task"',
|
||||
);
|
||||
|
||||
expect(
|
||||
existsSync(join(featureRoot, "src/jobs/send-demo-email.job.ts")),
|
||||
).toBe(true);
|
||||
|
||||
const pkgJson = JSON.parse(
|
||||
readFileSync(join(featureRoot, "package.json"), "utf8"),
|
||||
) as { exports: Record<string, string> };
|
||||
expect(pkgJson.exports["./cms"]).toBe("./src/integrations/cms/index.ts");
|
||||
},
|
||||
);
|
||||
});
|
||||
150
turbo/generators/config.test.ts
Normal file
150
turbo/generators/config.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { PlopTypes } from "@turbo/gen";
|
||||
import generator from "./config.js";
|
||||
|
||||
describe("core-package generator", () => {
|
||||
it("is registered with realtime and events in choices list", () => {
|
||||
const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = [];
|
||||
const plopMock = {
|
||||
setHelper: () => {},
|
||||
setGenerator: (name: string, def: PlopTypes.PlopGeneratorConfig) =>
|
||||
captured.push({ name, def }),
|
||||
} as unknown as PlopTypes.NodePlopAPI;
|
||||
generator(plopMock);
|
||||
const corePkg = captured.find((c) => c.name === "core-package");
|
||||
expect(corePkg).toBeDefined();
|
||||
const prompts = corePkg!.def.prompts as Array<{ name: string; choices: unknown[] }>;
|
||||
expect(prompts[0]!.name).toBe("name");
|
||||
expect(prompts[0]!.choices).toContain("realtime");
|
||||
expect(prompts[0]!.choices).toContain("events");
|
||||
expect(prompts[0]!.choices).toContain("trpc");
|
||||
expect(prompts[0]!.choices).toContain("ui");
|
||||
});
|
||||
});
|
||||
|
||||
describe("core-package realtime", () => {
|
||||
it("emits actions covering package files, transpilePackages, and ESLint rules", () => {
|
||||
// Capture the actions returned by the realtime entry.
|
||||
const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = [];
|
||||
const plop = {
|
||||
setHelper: () => {},
|
||||
setGenerator: (n: string, d: unknown) => captured.push({ name: n, def: d as PlopTypes.PlopGeneratorConfig }),
|
||||
} as unknown as PlopTypes.NodePlopAPI;
|
||||
generator(plop);
|
||||
const corePkg = captured.find((c) => c.name === "core-package")!.def;
|
||||
const actions = (corePkg.actions as (a: { name: string }) => PlopTypes.ActionType[])(
|
||||
{ name: "realtime" },
|
||||
);
|
||||
// Expectations: at least one assertNotPresent guard, multiple `add` actions, and a transpilePackages action.
|
||||
expect(actions.length).toBeGreaterThan(20); // 28 files + extras
|
||||
});
|
||||
});
|
||||
|
||||
describe("core-package events", () => {
|
||||
it("emits actions covering package files, transpilePackages, and ESLint rule splice", () => {
|
||||
const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = [];
|
||||
const plop = {
|
||||
setHelper: () => {},
|
||||
setGenerator: (n: string, d: unknown) => captured.push({ name: n, def: d as PlopTypes.PlopGeneratorConfig }),
|
||||
} as unknown as PlopTypes.NodePlopAPI;
|
||||
generator(plop);
|
||||
const corePkg = captured.find((c) => c.name === "core-package")!.def;
|
||||
const actions = (corePkg.actions as (a: { name: string }) => PlopTypes.ActionType[])(
|
||||
{ name: "events" },
|
||||
);
|
||||
// 1 guard + 15 template files + transpilePackages + ESLint splice + printNextSteps = 19
|
||||
expect(actions.length).toBeGreaterThanOrEqual(18);
|
||||
});
|
||||
});
|
||||
|
||||
describe("core-ui-component generator", () => {
|
||||
it("is registered with tier and name prompts", () => {
|
||||
const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = [];
|
||||
const plopMock = {
|
||||
setHelper: () => {},
|
||||
setGenerator: (name: string, def: PlopTypes.PlopGeneratorConfig) =>
|
||||
captured.push({ name, def }),
|
||||
} as unknown as PlopTypes.NodePlopAPI;
|
||||
generator(plopMock);
|
||||
|
||||
const entry = captured.find((c) => c.name === "core-ui-component");
|
||||
expect(entry).toBeDefined();
|
||||
const prompts = entry!.def.prompts as Array<{ name: string; choices?: unknown[] }>;
|
||||
expect(prompts.map((p) => p.name)).toEqual(["tier", "name"]);
|
||||
expect(prompts[0]!.choices).toEqual(["atom", "molecule", "organism"]);
|
||||
});
|
||||
|
||||
it("for each tier, emits 4 add actions, 1 modify, plus guards and print", () => {
|
||||
const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = [];
|
||||
const plopMock = {
|
||||
setHelper: () => {},
|
||||
setGenerator: (name: string, def: PlopTypes.PlopGeneratorConfig) =>
|
||||
captured.push({ name, def }),
|
||||
} as unknown as PlopTypes.NodePlopAPI;
|
||||
generator(plopMock);
|
||||
|
||||
const corePkg = captured.find((c) => c.name === "core-ui-component")!.def;
|
||||
|
||||
for (const tier of ["atom", "molecule", "organism"] as const) {
|
||||
const actions = (corePkg.actions as (a: { tier: string; name: string }) => PlopTypes.ActionType[])(
|
||||
{ tier, name: "Spinner" },
|
||||
);
|
||||
|
||||
const tierPlural = `${tier}s`;
|
||||
|
||||
// 4 `add` actions, one per emitted file
|
||||
const adds = actions.filter(
|
||||
(a): a is PlopTypes.AddActionConfig =>
|
||||
typeof a === "object" && "type" in a && (a as { type: string }).type === "add",
|
||||
);
|
||||
expect(adds).toHaveLength(4);
|
||||
const addPaths = adds.map((a) => a.path);
|
||||
expect(addPaths).toContain(
|
||||
`packages/core-ui/src/${tierPlural}/{{kebabCase name}}/{{kebabCase name}}.tsx`,
|
||||
);
|
||||
expect(addPaths).toContain(
|
||||
`packages/core-ui/src/${tierPlural}/{{kebabCase name}}/{{kebabCase name}}.stories.tsx`,
|
||||
);
|
||||
expect(addPaths).toContain(
|
||||
`packages/core-ui/src/${tierPlural}/{{kebabCase name}}/{{kebabCase name}}.test.tsx`,
|
||||
);
|
||||
expect(addPaths).toContain(
|
||||
`packages/core-ui/src/${tierPlural}/{{kebabCase name}}/index.ts`,
|
||||
);
|
||||
|
||||
// 1 `modify` action targeting the tier barrel
|
||||
const modifies = actions.filter(
|
||||
(a): a is PlopTypes.ModifyActionConfig =>
|
||||
typeof a === "object" && "type" in a && (a as { type: string }).type === "modify",
|
||||
);
|
||||
expect(modifies).toHaveLength(1);
|
||||
expect(modifies[0]!.path).toBe(`packages/core-ui/src/${tierPlural}/index.ts`);
|
||||
expect(String(modifies[0]!.pattern)).toContain(`<gen:${tierPlural}>`);
|
||||
|
||||
// 3 function actions (2 guards + 1 print)
|
||||
const fns = actions.filter((a) => typeof a === "function");
|
||||
expect(fns).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("PascalCase validator rejects bad names", () => {
|
||||
const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = [];
|
||||
const plopMock = {
|
||||
setHelper: () => {},
|
||||
setGenerator: (name: string, def: PlopTypes.PlopGeneratorConfig) =>
|
||||
captured.push({ name, def }),
|
||||
} as unknown as PlopTypes.NodePlopAPI;
|
||||
generator(plopMock);
|
||||
|
||||
const corePkg = captured.find((c) => c.name === "core-ui-component")!.def;
|
||||
const nameValidate = (corePkg.prompts as Array<{ name: string; validate?: (i: string) => string | true }>)
|
||||
.find((p) => p.name === "name")!.validate!;
|
||||
|
||||
expect(nameValidate("")).toBe("Required");
|
||||
expect(nameValidate("spinner")).toContain("PascalCase");
|
||||
expect(nameValidate("123Foo")).toContain("PascalCase");
|
||||
expect(nameValidate("Foo-Bar")).toContain("PascalCase");
|
||||
expect(nameValidate("Spinner")).toBe(true);
|
||||
expect(nameValidate("IconButton")).toBe(true);
|
||||
});
|
||||
});
|
||||
1855
turbo/generators/config.ts
Normal file
1855
turbo/generators/config.ts
Normal file
File diff suppressed because it is too large
Load Diff
13
turbo/generators/eslint.config.js
Normal file
13
turbo/generators/eslint.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
// Templates are Handlebars partials emitted by the generator. They
|
||||
// contain {{...}} placeholders that aren't valid TypeScript on their
|
||||
// own, so they MUST NOT be linted as TS. The .hbs extension also
|
||||
// already isn't picked up by eslint by default — this is belt-and-
|
||||
// suspenders for IDE eslint daemons that scan by directory.
|
||||
ignores: ["templates/**"],
|
||||
},
|
||||
];
|
||||
26
turbo/generators/lib/anchor-validate.ts
Normal file
26
turbo/generators/lib/anchor-validate.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Throws with a clear message if any required anchor is missing from the target
|
||||
* file. Generator actions call this in their prompt validator so the run aborts
|
||||
* cleanly before any partial output lands on disk.
|
||||
*/
|
||||
export function assertAnchors(
|
||||
repoRoot: string,
|
||||
relativePath: string,
|
||||
anchors: string[],
|
||||
): void {
|
||||
const path = join(repoRoot, relativePath);
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`Required file does not exist: ${relativePath}`);
|
||||
}
|
||||
const content = readFileSync(path, "utf8");
|
||||
for (const anchor of anchors) {
|
||||
if (!content.includes(anchor)) {
|
||||
throw new Error(
|
||||
`Missing anchor "${anchor}" in ${relativePath}. Add it before running this generator.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
146
turbo/generators/lib/core-package-utils.test.ts
Normal file
146
turbo/generators/lib/core-package-utils.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
assertOptionalPackageNotPresent,
|
||||
addToTranspilePackages,
|
||||
splicePluginRulesAt,
|
||||
addBoundariesEntry,
|
||||
emitTemplateTree,
|
||||
} from "./core-package-utils.js";
|
||||
|
||||
describe("assertOptionalPackageNotPresent", () => {
|
||||
it("throws if packages/<name>/ exists in cwd", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
|
||||
mkdirSync(join(tmp, "packages", "core-foo"), { recursive: true });
|
||||
expect(() => assertOptionalPackageNotPresent("core-foo", tmp)).toThrow(/already exists/);
|
||||
});
|
||||
|
||||
it("returns silently if packages/<name>/ is absent", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
|
||||
expect(() => assertOptionalPackageNotPresent("core-foo", tmp)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addToTranspilePackages", () => {
|
||||
it("inserts package name alphabetically into transpilePackages array", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
|
||||
const cfgPath = join(tmp, "next.config.mjs");
|
||||
writeFileSync(
|
||||
cfgPath,
|
||||
`const nextConfig = {
|
||||
transpilePackages: [
|
||||
"@repo/core-cms",
|
||||
"@repo/core-shared",
|
||||
],
|
||||
};
|
||||
`,
|
||||
);
|
||||
addToTranspilePackages(cfgPath, "@repo/core-realtime");
|
||||
const result = readFileSync(cfgPath, "utf8");
|
||||
// Order should be: core-cms, core-realtime, core-shared
|
||||
const order = result.match(/@repo\/core-\w+/g) ?? [];
|
||||
expect(order).toEqual(["@repo/core-cms", "@repo/core-realtime", "@repo/core-shared"]);
|
||||
});
|
||||
|
||||
it("is idempotent — duplicate insertion is a no-op", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
|
||||
const cfgPath = join(tmp, "next.config.mjs");
|
||||
writeFileSync(
|
||||
cfgPath,
|
||||
`const nextConfig = {
|
||||
transpilePackages: ["@repo/core-realtime"],
|
||||
};
|
||||
`,
|
||||
);
|
||||
addToTranspilePackages(cfgPath, "@repo/core-realtime");
|
||||
const result = readFileSync(cfgPath, "utf8");
|
||||
expect(result.match(/@repo\/core-realtime/g)?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splicePluginRulesAt", () => {
|
||||
it("inserts rule block at the named anchor", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
|
||||
const path = join(tmp, "base.js");
|
||||
writeFileSync(
|
||||
path,
|
||||
`before\n// <gen:realtime-rules>\nafter\n`,
|
||||
);
|
||||
splicePluginRulesAt(path, "realtime-rules", "INSERTED_BLOCK");
|
||||
const result = readFileSync(path, "utf8");
|
||||
expect(result).toContain("// <gen:realtime-rules>\nINSERTED_BLOCK\nafter");
|
||||
});
|
||||
|
||||
it("is idempotent — re-inserting same block at anchor is a no-op", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
|
||||
const path = join(tmp, "base.js");
|
||||
writeFileSync(
|
||||
path,
|
||||
`// <gen:realtime-rules>\nINSERTED_BLOCK\nrest\n`,
|
||||
);
|
||||
splicePluginRulesAt(path, "realtime-rules", "INSERTED_BLOCK");
|
||||
const result = readFileSync(path, "utf8");
|
||||
expect(result.match(/INSERTED_BLOCK/g)?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addBoundariesEntry", () => {
|
||||
it("inserts entry before the packages/core-* wildcard", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
|
||||
const path = join(tmp, "base.js");
|
||||
writeFileSync(
|
||||
path,
|
||||
`"boundaries/elements": [
|
||||
{ type: "core-composition", pattern: "packages/core-cms" },
|
||||
{ type: "core", pattern: "packages/core-*" },
|
||||
{ type: "feature", pattern: "packages/!(core-*)" },
|
||||
],`,
|
||||
);
|
||||
addBoundariesEntry(path, "packages/core-realtime", { mode: "folder" });
|
||||
const result = readFileSync(path, "utf8");
|
||||
// The new entry must appear BEFORE the packages/core-* wildcard
|
||||
const newIdx = result.indexOf(`pattern: "packages/core-realtime"`);
|
||||
const wildcardIdx = result.indexOf(`pattern: "packages/core-*"`);
|
||||
expect(newIdx).toBeGreaterThan(0);
|
||||
expect(newIdx).toBeLessThan(wildcardIdx);
|
||||
});
|
||||
|
||||
it("is idempotent — re-inserting same entry is a no-op", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
|
||||
const path = join(tmp, "base.js");
|
||||
writeFileSync(
|
||||
path,
|
||||
`"boundaries/elements": [
|
||||
{ type: "core", pattern: "packages/core-realtime", mode: "folder" },
|
||||
{ type: "core", pattern: "packages/core-*" },
|
||||
],`,
|
||||
);
|
||||
addBoundariesEntry(path, "packages/core-realtime", { mode: "folder" });
|
||||
const result = readFileSync(path, "utf8");
|
||||
expect(result.match(/packages\/core-realtime/g)?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emitTemplateTree", () => {
|
||||
it("produces an `add` plop action per .hbs file in the template directory", () => {
|
||||
// emitTemplateTree reads from turbo/generators/templates/ — the test uses a
|
||||
// temp template directory injected via the `templatesRoot` arg.
|
||||
const tmpTemplates = mkdtempSync(join(tmpdir(), "tpl-"));
|
||||
mkdirSync(join(tmpTemplates, "core-package", "demo", "src"), { recursive: true });
|
||||
writeFileSync(join(tmpTemplates, "core-package", "demo", "package.json.hbs"), "{}");
|
||||
writeFileSync(join(tmpTemplates, "core-package", "demo", "src", "index.ts.hbs"), "export {};");
|
||||
const actions = emitTemplateTree("core-package/demo", "packages/demo", { templatesRoot: tmpTemplates });
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]!.type).toBe("add");
|
||||
const paths = actions.map((a) => (a as { path: string }).path);
|
||||
expect(paths).toContain("packages/demo/package.json");
|
||||
expect(paths).toContain("packages/demo/src/index.ts");
|
||||
});
|
||||
});
|
||||
176
turbo/generators/lib/core-package-utils.ts
Normal file
176
turbo/generators/lib/core-package-utils.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import type { PlopTypes } from "@turbo/gen";
|
||||
|
||||
/**
|
||||
* Throws if a core package directory already exists. Used as the first action
|
||||
* in every per-package generator so re-running is safe.
|
||||
*/
|
||||
export function assertOptionalPackageNotPresent(
|
||||
name: string,
|
||||
cwd: string = process.cwd(),
|
||||
): void {
|
||||
const pkgRoot = join(cwd, "packages", name);
|
||||
if (existsSync(pkgRoot)) {
|
||||
throw new Error(
|
||||
`packages/${name}/ already exists — refusing to scaffold (delete it first if intentional)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a package name into the transpilePackages array of a Next.js config
|
||||
* file, preserving alphabetical order. Idempotent.
|
||||
*/
|
||||
export function addToTranspilePackages(
|
||||
nextConfigPath: string,
|
||||
pkgName: string,
|
||||
): void {
|
||||
const source = readFileSync(nextConfigPath, "utf8");
|
||||
if (source.includes(`"${pkgName}"`)) return;
|
||||
const updated = source.replace(
|
||||
/(transpilePackages:\s*\[\s*)([\s\S]*?)(\s*\])/,
|
||||
(_match, open: string, body: string, close: string) => {
|
||||
const entries = body
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean);
|
||||
entries.push(`"${pkgName}"`);
|
||||
entries.sort();
|
||||
const formatted = entries.map((e) => ` ${e}`).join(",\n");
|
||||
return `${open}\n${formatted},${close}`;
|
||||
},
|
||||
);
|
||||
writeFileSync(nextConfigPath, updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a code block immediately after a `// <gen:NAME>` anchor in a file.
|
||||
* Idempotent: refuses to insert if the exact block already follows the anchor.
|
||||
*/
|
||||
export function splicePluginRulesAt(
|
||||
filePath: string,
|
||||
anchorName: string,
|
||||
block: string,
|
||||
): void {
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
const anchor = `// <gen:${anchorName}>`;
|
||||
const idx = source.indexOf(anchor);
|
||||
if (idx === -1) {
|
||||
throw new Error(`Anchor ${anchor} not found in ${filePath}`);
|
||||
}
|
||||
const after = source.slice(idx + anchor.length);
|
||||
if (after.trimStart().startsWith(block.trim())) return; // idempotent
|
||||
const updated = source.slice(0, idx + anchor.length) + "\n" + block + after;
|
||||
writeFileSync(filePath, updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts an import line immediately after the matching anchor. Idempotent.
|
||||
*/
|
||||
export function splicePluginImportsAt(
|
||||
filePath: string,
|
||||
anchorName: string,
|
||||
importLine: string,
|
||||
): void {
|
||||
splicePluginRulesAt(filePath, anchorName, importLine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a `{ type, pattern, ...opts }` entry into the boundaries/elements
|
||||
* array of `core-eslint/base.js`, placed immediately BEFORE the
|
||||
* `packages/core-*` wildcard so its more-specific match wins. Idempotent.
|
||||
*/
|
||||
export function addBoundariesEntry(
|
||||
baseJsPath: string,
|
||||
packagePath: string,
|
||||
opts: { mode?: "folder" } = {},
|
||||
): void {
|
||||
const source = readFileSync(baseJsPath, "utf8");
|
||||
if (source.includes(`pattern: "${packagePath}"`)) return; // idempotent
|
||||
const wildcardLine = source.match(
|
||||
/(\s*\{\s*type:\s*"core",\s*pattern:\s*"packages\/core-\*"[^}]*\},)/,
|
||||
);
|
||||
if (!wildcardLine) {
|
||||
throw new Error(`packages/core-* wildcard not found in ${baseJsPath}`);
|
||||
}
|
||||
const modeFragment = opts.mode ? `, mode: "${opts.mode}"` : "";
|
||||
const newEntry = ` { type: "core", pattern: "${packagePath}"${modeFragment} },\n`;
|
||||
const updated = source.replace(
|
||||
wildcardLine[0],
|
||||
`\n${newEntry}${wildcardLine[1]}`,
|
||||
);
|
||||
writeFileSync(baseJsPath, updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks turbo/generators/templates/<srcPrefix>/ recursively. For each .hbs
|
||||
* file, returns a plop `add` action that emits the file (without .hbs
|
||||
* extension) at <destPrefix>/<relative-path>. The actions are sorted so
|
||||
* directory creation is deterministic.
|
||||
*
|
||||
* Set `opts.force` to overwrite existing files (idempotent re-runs).
|
||||
*/
|
||||
export function emitTemplateTree(
|
||||
srcPrefix: string,
|
||||
destPrefix: string,
|
||||
opts: { templatesRoot?: string; force?: boolean } = {},
|
||||
): PlopTypes.AddActionConfig[] {
|
||||
// The templates directory is resolved in priority order:
|
||||
// 1. opts.templatesRoot — test injection (temp directory)
|
||||
// 2. cwd/turbo/generators/templates — turbo gen context (cwd = repo root)
|
||||
// 3. cwd/templates — vitest context (cwd = turbo/generators)
|
||||
let root: string;
|
||||
if (opts.templatesRoot) {
|
||||
root = opts.templatesRoot;
|
||||
} else {
|
||||
const fromRepoRoot = join(
|
||||
process.cwd(),
|
||||
"turbo",
|
||||
"generators",
|
||||
"templates",
|
||||
);
|
||||
const fromGeneratorsDir = join(process.cwd(), "templates");
|
||||
root = existsSync(fromRepoRoot) ? fromRepoRoot : fromGeneratorsDir;
|
||||
}
|
||||
const srcRoot = join(root, srcPrefix);
|
||||
const out: PlopTypes.AddActionConfig[] = [];
|
||||
walkHbs(srcRoot, srcRoot, srcPrefix, destPrefix, out, opts.force ?? false);
|
||||
out.sort((a, b) => (a.path ?? "").localeCompare(b.path ?? ""));
|
||||
return out;
|
||||
}
|
||||
|
||||
function walkHbs(
|
||||
topRoot: string,
|
||||
dir: string,
|
||||
srcPrefix: string,
|
||||
destPrefix: string,
|
||||
out: PlopTypes.AddActionConfig[],
|
||||
force: boolean,
|
||||
): void {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
if (statSync(full).isDirectory()) {
|
||||
walkHbs(topRoot, full, srcPrefix, destPrefix, out, force);
|
||||
continue;
|
||||
}
|
||||
if (!name.endsWith(".hbs")) continue;
|
||||
const rel = relative(topRoot, full).replace(/\.hbs$/, "");
|
||||
out.push({
|
||||
type: "add",
|
||||
path: join(destPrefix, rel).replace(/\\/g, "/"),
|
||||
templateFile: join(
|
||||
"templates",
|
||||
srcPrefix,
|
||||
relative(topRoot, full),
|
||||
).replace(/\\/g, "/"),
|
||||
force,
|
||||
} as PlopTypes.AddActionConfig);
|
||||
}
|
||||
}
|
||||
146
turbo/generators/lib/release-please-utils.test.mjs
Normal file
146
turbo/generators/lib/release-please-utils.test.mjs
Normal file
@@ -0,0 +1,146 @@
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { registerFeatureInReleasePlease } from "./release-please-utils.ts";
|
||||
|
||||
function setupRepo() {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "rp-utils-"));
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, ".release-please-manifest.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
".": "0.1.0",
|
||||
"packages/auth": "0.1.0",
|
||||
"packages/blog": "0.1.0",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, "release-please-config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
"release-type": "node",
|
||||
"include-component-in-tag": true,
|
||||
packages: {
|
||||
".": {
|
||||
"package-name": "template-vertical",
|
||||
component: "template",
|
||||
"changelog-path": "CHANGELOG.md",
|
||||
},
|
||||
"packages/auth": {
|
||||
"package-name": "@repo/auth",
|
||||
component: "auth",
|
||||
"changelog-path": "CHANGELOG.md",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
return tmp;
|
||||
}
|
||||
|
||||
describe("registerFeatureInReleasePlease", () => {
|
||||
test("adds a new package to both files", () => {
|
||||
const tmp = setupRepo();
|
||||
try {
|
||||
const { manifestChanged, configChanged } = registerFeatureInReleasePlease(
|
||||
tmp,
|
||||
"comments",
|
||||
);
|
||||
assert.equal(manifestChanged, true);
|
||||
assert.equal(configChanged, true);
|
||||
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(tmp, ".release-please-manifest.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
assert.equal(manifest["packages/comments"], "0.1.0");
|
||||
|
||||
const config = JSON.parse(
|
||||
fs.readFileSync(path.join(tmp, "release-please-config.json"), "utf8"),
|
||||
);
|
||||
assert.deepEqual(config.packages["packages/comments"], {
|
||||
"package-name": "@repo/comments",
|
||||
component: "comments",
|
||||
"changelog-path": "CHANGELOG.md",
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("is idempotent — re-running on an already-tracked feature is a no-op", () => {
|
||||
const tmp = setupRepo();
|
||||
try {
|
||||
const first = registerFeatureInReleasePlease(tmp, "comments");
|
||||
assert.equal(first.manifestChanged, true);
|
||||
const second = registerFeatureInReleasePlease(tmp, "comments");
|
||||
assert.equal(second.manifestChanged, false);
|
||||
assert.equal(second.configChanged, false);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps root ('.') entry first and sorts the rest alphabetically", () => {
|
||||
const tmp = setupRepo();
|
||||
try {
|
||||
registerFeatureInReleasePlease(tmp, "alphabetically-first-comments");
|
||||
|
||||
const manifestText = fs.readFileSync(
|
||||
path.join(tmp, ".release-please-manifest.json"),
|
||||
"utf8",
|
||||
);
|
||||
const keys = Object.keys(JSON.parse(manifestText));
|
||||
assert.equal(keys[0], ".", "root should always be first");
|
||||
// The rest are alphabetical
|
||||
const rest = keys.slice(1);
|
||||
const sorted = [...rest].sort();
|
||||
assert.deepEqual(rest, sorted);
|
||||
|
||||
const configText = fs.readFileSync(
|
||||
path.join(tmp, "release-please-config.json"),
|
||||
"utf8",
|
||||
);
|
||||
const packageKeys = Object.keys(JSON.parse(configText).packages);
|
||||
assert.equal(packageKeys[0], ".");
|
||||
assert.deepEqual(packageKeys.slice(1), [...packageKeys.slice(1)].sort());
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("throws if .release-please-manifest.json is missing", () => {
|
||||
const tmp = setupRepo();
|
||||
try {
|
||||
fs.unlinkSync(path.join(tmp, ".release-please-manifest.json"));
|
||||
assert.throws(
|
||||
() => registerFeatureInReleasePlease(tmp, "x"),
|
||||
/\.release-please-manifest\.json is missing/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("throws if release-please-config.json is missing", () => {
|
||||
const tmp = setupRepo();
|
||||
try {
|
||||
fs.unlinkSync(path.join(tmp, "release-please-config.json"));
|
||||
assert.throws(
|
||||
() => registerFeatureInReleasePlease(tmp, "x"),
|
||||
/release-please-config\.json is missing/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
101
turbo/generators/lib/release-please-utils.ts
Normal file
101
turbo/generators/lib/release-please-utils.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const MANIFEST_FILE = ".release-please-manifest.json";
|
||||
const CONFIG_FILE = "release-please-config.json";
|
||||
const INITIAL_VERSION = "0.1.0";
|
||||
|
||||
/**
|
||||
* Register a new feature package in release-please tracking (ADR-021).
|
||||
*
|
||||
* Mutates two files at the repo root:
|
||||
* - `.release-please-manifest.json` — adds `"packages/<name>": "0.1.0"`
|
||||
* - `release-please-config.json` — adds the per-package config block
|
||||
*
|
||||
* Idempotent: if the package is already tracked, both writes are no-ops.
|
||||
*
|
||||
* Throws if either file is missing — release-please MUST be set up before
|
||||
* a feature generator can register against it (ADR-021 land-down sequence).
|
||||
*/
|
||||
export function registerFeatureInReleasePlease(
|
||||
repoRoot: string,
|
||||
featureKebab: string,
|
||||
): { manifestChanged: boolean; configChanged: boolean } {
|
||||
const manifestPath = join(repoRoot, MANIFEST_FILE);
|
||||
const configPath = join(repoRoot, CONFIG_FILE);
|
||||
|
||||
if (!existsSync(manifestPath)) {
|
||||
throw new Error(
|
||||
`Cannot register feature in release-please: ${MANIFEST_FILE} is missing. ` +
|
||||
`release-please must be set up before scaffolding features that participate in tracking.`,
|
||||
);
|
||||
}
|
||||
if (!existsSync(configPath)) {
|
||||
throw new Error(
|
||||
`Cannot register feature in release-please: ${CONFIG_FILE} is missing.`,
|
||||
);
|
||||
}
|
||||
|
||||
const packagePath = `packages/${featureKebab}`;
|
||||
const manifestChanged = addToManifest(manifestPath, packagePath);
|
||||
const configChanged = addToConfig(configPath, packagePath, featureKebab);
|
||||
|
||||
return { manifestChanged, configChanged };
|
||||
}
|
||||
|
||||
function addToManifest(manifestPath: string, packagePath: string): boolean {
|
||||
const text = readFileSync(manifestPath, "utf8");
|
||||
const manifest = JSON.parse(text) as Record<string, string>;
|
||||
if (manifest[packagePath]) return false; // already tracked
|
||||
manifest[packagePath] = INITIAL_VERSION;
|
||||
// Sort keys: root (".") stays first if present; the rest alphabetically by
|
||||
// path so insertions are deterministic + future diffs stay minimal.
|
||||
const sorted: Record<string, string> = {};
|
||||
if (manifest["."] !== undefined) sorted["."] = manifest["."];
|
||||
for (const key of Object.keys(manifest).sort()) {
|
||||
if (key === ".") continue;
|
||||
sorted[key] = manifest[key];
|
||||
}
|
||||
writeFileSync(manifestPath, JSON.stringify(sorted, null, 2) + "\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
function addToConfig(
|
||||
configPath: string,
|
||||
packagePath: string,
|
||||
featureKebab: string,
|
||||
): boolean {
|
||||
const text = readFileSync(configPath, "utf8");
|
||||
const config = JSON.parse(text) as {
|
||||
packages?: Record<
|
||||
string,
|
||||
{
|
||||
"package-name": string;
|
||||
component: string;
|
||||
"changelog-path"?: string;
|
||||
}
|
||||
>;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
if (!config.packages) {
|
||||
throw new Error(
|
||||
`release-please-config.json has no "packages" key — config is malformed.`,
|
||||
);
|
||||
}
|
||||
if (config.packages[packagePath]) return false; // already tracked
|
||||
config.packages[packagePath] = {
|
||||
"package-name": `@repo/${featureKebab}`,
|
||||
component: featureKebab,
|
||||
"changelog-path": "CHANGELOG.md",
|
||||
};
|
||||
// Sort packages the same way as the manifest: root first, rest alphabetical.
|
||||
const sortedPackages: typeof config.packages = {};
|
||||
if (config.packages["."]) sortedPackages["."] = config.packages["."];
|
||||
for (const key of Object.keys(config.packages).sort()) {
|
||||
if (key === ".") continue;
|
||||
sortedPackages[key] = config.packages[key];
|
||||
}
|
||||
config.packages = sortedPackages;
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
||||
return true;
|
||||
}
|
||||
19
turbo/generators/lib/snapshot.test.ts
Normal file
19
turbo/generators/lib/snapshot.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { computeSnapshot } from "./snapshot.js";
|
||||
|
||||
describe("computeSnapshot", () => {
|
||||
it("returns sorted file paths + sha256 hashes", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "snapshot-"));
|
||||
mkdirSync(join(tmp, "src"));
|
||||
writeFileSync(join(tmp, "package.json"), `{ "name": "x" }\n`);
|
||||
writeFileSync(join(tmp, "src", "index.ts"), `export {};\n`);
|
||||
const snap = computeSnapshot(tmp);
|
||||
expect(snap).toEqual([
|
||||
{ path: "package.json", sha256: expect.any(String) },
|
||||
{ path: "src/index.ts", sha256: expect.any(String) },
|
||||
]);
|
||||
});
|
||||
});
|
||||
33
turbo/generators/lib/snapshot.ts
Normal file
33
turbo/generators/lib/snapshot.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
export type SnapshotEntry = { path: string; sha256: string };
|
||||
|
||||
/**
|
||||
* Recursively collect all files under root, sorted by relative path, with
|
||||
* sha256 of post-normalized contents (LF line endings, single trailing
|
||||
* newline). Used by the byte-identical reconstruction test.
|
||||
*/
|
||||
export function computeSnapshot(root: string): SnapshotEntry[] {
|
||||
const out: SnapshotEntry[] = [];
|
||||
walk(root, root, out);
|
||||
out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
||||
return out;
|
||||
}
|
||||
|
||||
function walk(root: string, dir: string, out: SnapshotEntry[]): void {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
if (name === "node_modules" || name === ".turbo") continue;
|
||||
walk(root, full, out);
|
||||
} else if (stat.isFile()) {
|
||||
const raw = readFileSync(full, "utf8");
|
||||
const normalized = raw.replace(/\r\n/g, "\n").replace(/\n*$/, "\n");
|
||||
const sha = createHash("sha256").update(normalized).digest("hex");
|
||||
out.push({ path: relative(root, full).replace(/\\/g, "/"), sha256: sha });
|
||||
}
|
||||
}
|
||||
}
|
||||
20
turbo/generators/package.json
Normal file
20
turbo/generators/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@repo/turbo-generators",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@turbo/gen": "^2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
0
turbo/generators/templates/core-package/.gitkeep
Normal file
0
turbo/generators/templates/core-package/.gitkeep
Normal file
@@ -0,0 +1,32 @@
|
||||
# @repo/core-analytics
|
||||
|
||||
Optional core package providing a vendor-neutral product analytics interface. Scaffold via `pnpm turbo gen core-package analytics`.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
analytics.interface.ts # IAnalytics — track, identify, pageView, flush
|
||||
noop-analytics.ts # NoopAnalytics (default no-op implementation)
|
||||
index.ts # Barrel export
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
`IAnalytics` exposes four methods:
|
||||
|
||||
- `track(event, attributes?)` — record a named event with optional attributes
|
||||
- `identify(user)` — associate subsequent events with a user
|
||||
- `pageView(path, attributes?)` — record a page-view event
|
||||
- `flush()` — drain any in-flight queued events (returns `Promise<void>`)
|
||||
|
||||
The interface is vendor-neutral: no third-party analytics SDK is bundled. Feature
|
||||
packages depend on `IAnalytics` only; concrete implementations (e.g. a PostHog
|
||||
or Segment adapter) are wired at DI bind time in `bind-production`.
|
||||
|
||||
`NoopAnalytics` is the default implementation — all methods are no-ops and
|
||||
`flush()` resolves immediately via `Promise.resolve()`. Use it in dev-seed
|
||||
bindings and unit tests.
|
||||
|
||||
See `docs/architecture/agent-first-workflow-and-conformance.md` for the
|
||||
dependency-injection conventions.
|
||||
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@repo/core-analytics",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// placeholder — populated by story 01-scaffold-core-analytics-package
|
||||
export {};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["core"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { mergeConfig } from "vitest/config";
|
||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
||||
|
||||
export default mergeConfig(nodeVitestConfig, {
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
});
|
||||
28
turbo/generators/templates/core-package/audit/AGENTS.md.hbs
Normal file
28
turbo/generators/templates/core-package/audit/AGENTS.md.hbs
Normal file
@@ -0,0 +1,28 @@
|
||||
# @repo/core-audit
|
||||
|
||||
Optional core package providing DPA-compliant audit logging. Scaffold via `pnpm turbo gen core-package audit`.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
audit-log.interface.ts # IAuditLog extends AuditLogProtocol
|
||||
audit-logs-collection.ts # Payload collection (append-only)
|
||||
noop-audit-log.ts # NoopAuditLog
|
||||
payload-audit-log.ts # PayloadAuditLog (local cache impl)
|
||||
stdout-json-audit-log.ts # StdoutJsonAuditLog (log-shipper sink)
|
||||
multi-sink-audit-log.ts # MultiSinkAuditLog (fan-out wrapper)
|
||||
trace-id-enriching-audit-log.ts # OTel correlation decorator
|
||||
pseudonymize.ts # sha256-with-salt for GDPR pseudonymization
|
||||
di/bind-audit.ts # bindAudit binder
|
||||
integrations/api/router.ts # admin tRPC procedure
|
||||
hooks/ # Payload hook factories
|
||||
```
|
||||
|
||||
## Compliance posture
|
||||
|
||||
- `AuditEntry` type (in `@repo/core-shared/audit`) has no `payload`/`body`/`oldValue`/`newValue` fields — type system enforces DPA "what NOT to log".
|
||||
- Append-only Payload collection (`update: () => false`); erasure uses `overrideAccess: true` for the privileged path.
|
||||
- `AUDIT_PSEUDONYM_SALT` env REQUIRED in production. Validated at bind time.
|
||||
|
||||
See `docs/guides/audit-and-compliance.md` for the full guide.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
package: "@trpc/server"
|
||||
version: "^11.0.0"
|
||||
tier: core
|
||||
decision: approved
|
||||
date: 2026-05-14
|
||||
deciders: [scaffolded]
|
||||
adr: adr-018
|
||||
filter-results:
|
||||
license: MIT
|
||||
types: native
|
||||
maintenance: active
|
||||
boundary-fit: pass
|
||||
shadow-check: pass
|
||||
eu-residency: n/a
|
||||
cve-scan: clean
|
||||
named-consumer: pass
|
||||
verification-commands:
|
||||
- pnpm audit --audit-level=moderate
|
||||
- npm view @trpc/server license
|
||||
accepted-cves: []
|
||||
---
|
||||
|
||||
## Filter: license
|
||||
|
||||
MIT — on the workspace allowlist.
|
||||
|
||||
## Filter: types
|
||||
|
||||
Ships first-party TypeScript types; fully type-safe by design.
|
||||
|
||||
## Filter: maintenance
|
||||
|
||||
Active. Maintained by the tRPC team; v11 is the current stable line.
|
||||
|
||||
## Filter: boundary-fit
|
||||
|
||||
Core package. `@trpc/server` is already present in `core-api` (workspace dependency). Using it in `core-audit` for the audit API router does not violate boundary rules.
|
||||
|
||||
## Filter: shadow-check
|
||||
|
||||
`@trpc/server` is workspace-present via `core-api`. Same major version; no shadow.
|
||||
|
||||
## Filter: eu-residency
|
||||
|
||||
Server-side RPC library; no vendor data transmission. n/a.
|
||||
|
||||
## Filter: cve-scan
|
||||
|
||||
No advisories at adoption time.
|
||||
|
||||
## Filter: named-consumer
|
||||
|
||||
`core-audit` exposes an audit-log tRPC router used by the `apps/web-next` API layer.
|
||||
|
||||
## Prompt: replaces
|
||||
|
||||
Nothing new — tRPC is already the API layer; this extends it to the audit surface.
|
||||
|
||||
## Prompt: migration-cost-out
|
||||
|
||||
Hard: router procedures, input schemas, and error-mapping middleware are tRPC-shaped. Removal would require replacing the audit API surface.
|
||||
|
||||
## Prompt: alternatives-considered
|
||||
|
||||
1. **REST endpoints** — loses end-to-end type safety already established via tRPC.
|
||||
2. **GraphQL** — much heavier; not aligned with existing workspace API shape.
|
||||
tRPC is the locked workspace API library; extending it to audit is the natural fit.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
package: zod
|
||||
version: "^3.23.0"
|
||||
tier: core
|
||||
decision: approved
|
||||
date: 2026-05-14
|
||||
deciders: [scaffolded]
|
||||
adr: adr-018
|
||||
filter-results:
|
||||
license: MIT
|
||||
types: native
|
||||
maintenance: active
|
||||
boundary-fit: pass
|
||||
shadow-check: pass
|
||||
eu-residency: n/a
|
||||
cve-scan: clean
|
||||
named-consumer: pass
|
||||
verification-commands:
|
||||
- pnpm audit --audit-level=moderate
|
||||
- npm view zod license
|
||||
accepted-cves: []
|
||||
---
|
||||
|
||||
## Filter: license
|
||||
|
||||
MIT — on the workspace allowlist.
|
||||
|
||||
## Filter: types
|
||||
|
||||
Ships first-party TypeScript types in its distribution (`.d.ts` included).
|
||||
|
||||
## Filter: maintenance
|
||||
|
||||
Active. Regular releases by Colin McDonnell; widely adopted.
|
||||
|
||||
## Filter: boundary-fit
|
||||
|
||||
Core package. Zod is the workspace-canonical validation library locked in `core-shared` (ADR-018).
|
||||
|
||||
## Filter: shadow-check
|
||||
|
||||
Zod is already the workspace-locked validation library. No shadow.
|
||||
|
||||
## Filter: eu-residency
|
||||
|
||||
Pure computation; no network calls or vendor data transmission. n/a.
|
||||
|
||||
## Filter: cve-scan
|
||||
|
||||
No advisories at adoption time.
|
||||
|
||||
## Filter: named-consumer
|
||||
|
||||
`core-audit` uses zod to validate audit-log record input schemas.
|
||||
|
||||
## Prompt: replaces
|
||||
|
||||
Nothing — zod is the pre-existing workspace validation library.
|
||||
|
||||
## Prompt: migration-cost-out
|
||||
|
||||
Mechanical: swap schema definitions at call sites. No data-format lock-in.
|
||||
|
||||
## Prompt: alternatives-considered
|
||||
|
||||
Zod is workspace-locked (see `core-shared`). A replacement would require a workspace-wide ADR; no alternative was evaluated here.
|
||||
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@repo/core-audit",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./collection": "./src/audit-logs-collection.ts",
|
||||
"./di": "./src/di/bind-audit.ts",
|
||||
"./hooks": "./src/hooks/index.ts",
|
||||
"./api": "./src/integrations/api/router.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"payload": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.55.0",
|
||||
"@opentelemetry/context-async-hooks": "^1.28.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.27.0",
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"inversify": "^6.2.0",
|
||||
"payload": "^3.14.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AuditLogProtocol } from "@repo/core-shared/di/bind-protocols";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
|
||||
/**
|
||||
* Full audit log interface. Extends the minimal `AuditLogProtocol` from
|
||||
* core-shared with the privileged `eraseSubject` op for GDPR erasure.
|
||||
*
|
||||
* Feature binders that receive `ctx.auditLog` see only `AuditLogProtocol`
|
||||
* (record). Admin-path code that needs erasure imports this full interface.
|
||||
*
|
||||
* The `extends` link forces typecheck failure if either side narrows below
|
||||
* the protocol surface — same safety net as IEventBus, IRealtimeBroadcaster,
|
||||
* IRealtimeHandlerRegistry, IMetrics.
|
||||
*/
|
||||
export interface IAuditLog extends AuditLogProtocol {
|
||||
// record(entry: AuditEntry): Promise<void> — inherited from protocol
|
||||
eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void>;
|
||||
}
|
||||
|
||||
// Re-export AuditEntry for convenience (so consumers don't always need
|
||||
// to dual-import from @repo/core-shared/audit).
|
||||
export type { AuditEntry };
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { auditLogsCollection } from "./audit-logs-collection";
|
||||
|
||||
describe("auditLogsCollection", () => {
|
||||
it("uses slug 'audit-logs'", () => {
|
||||
expect(auditLogsCollection.slug).toBe("audit-logs");
|
||||
});
|
||||
|
||||
it("is append-only (update: () => false)", () => {
|
||||
const access = auditLogsCollection.access as Record<string, (() => boolean) | undefined>;
|
||||
expect(access["update"]?.()).toBe(false);
|
||||
});
|
||||
|
||||
it("has the required fields", () => {
|
||||
const fieldNames = (auditLogsCollection.fields as Array<{ name: string }>).map((f) => f.name);
|
||||
// WHO
|
||||
expect(fieldNames).toContain("actorId");
|
||||
expect(fieldNames).toContain("actorType");
|
||||
expect(fieldNames).toContain("actorRoles");
|
||||
// WHAT
|
||||
expect(fieldNames).toContain("action");
|
||||
expect(fieldNames).toContain("resourceType");
|
||||
expect(fieldNames).toContain("resourceId");
|
||||
expect(fieldNames).toContain("changedFields");
|
||||
// SCOPE
|
||||
expect(fieldNames).toContain("scopeFeature");
|
||||
expect(fieldNames).toContain("scopeEnvironment");
|
||||
expect(fieldNames).toContain("scopeTenant");
|
||||
// WHY
|
||||
expect(fieldNames).toContain("reason");
|
||||
expect(fieldNames).toContain("correlationId");
|
||||
expect(fieldNames).toContain("requestId");
|
||||
// FROM
|
||||
expect(fieldNames).toContain("ipTruncated");
|
||||
expect(fieldNames).toContain("userAgent");
|
||||
// PII
|
||||
expect(fieldNames).toContain("containsPii");
|
||||
expect(fieldNames).toContain("piiCategories");
|
||||
// OUTCOME
|
||||
expect(fieldNames).toContain("outcome");
|
||||
expect(fieldNames).toContain("errorCode");
|
||||
});
|
||||
|
||||
it("enables timestamps so createdAt maps to AuditEntry.at", () => {
|
||||
expect(auditLogsCollection.timestamps).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
/**
|
||||
* Append-only Payload collection for audit entries. Mounted by core-cms
|
||||
* when this package is scaffolded (manual wiring step printed by generator).
|
||||
*
|
||||
* Access rules:
|
||||
* - read: admins only
|
||||
* - create: any authenticated context (filtered upstream by PayloadAuditLog)
|
||||
* - update: NEVER (compliance requires append-only)
|
||||
* - delete: admins only (used by the GDPR erasure path with overrideAccess)
|
||||
*
|
||||
* The `update: () => false` rule is the compliance backbone. The erasure
|
||||
* path uses `overrideAccess: true` to bypass for pseudonymization — that's
|
||||
* Payload's documented escape hatch for privileged operations.
|
||||
*/
|
||||
export const auditLogsCollection: CollectionConfig = {
|
||||
slug: "audit-logs",
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
const user = req.user as { roles?: string[] } | null | undefined;
|
||||
return Array.isArray(user?.roles) && user.roles.includes("admin");
|
||||
},
|
||||
create: () => true,
|
||||
update: () => false,
|
||||
delete: ({ req }) => {
|
||||
const user = req.user as { roles?: string[] } | null | undefined;
|
||||
return Array.isArray(user?.roles) && user.roles.includes("admin");
|
||||
},
|
||||
},
|
||||
timestamps: true,
|
||||
fields: [
|
||||
// WHO
|
||||
{ name: "actorId", type: "text", required: true, index: true },
|
||||
{
|
||||
name: "actorType",
|
||||
type: "select",
|
||||
options: ["user", "system", "service"],
|
||||
required: true,
|
||||
},
|
||||
{ name: "actorRoles", type: "json", required: true },
|
||||
|
||||
// WHAT
|
||||
{
|
||||
name: "action",
|
||||
type: "select",
|
||||
options: ["VIEW", "CREATE", "UPDATE", "DELETE", "EXPORT", "PERMISSION_CHANGE"],
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{ name: "resourceType", type: "text", required: true, index: true },
|
||||
{ name: "resourceId", type: "text" },
|
||||
{ name: "changedFields", type: "json" },
|
||||
|
||||
// SCOPE
|
||||
{ name: "scopeFeature", type: "text", required: true, index: true },
|
||||
{ name: "scopeEnvironment", type: "text", required: true },
|
||||
{ name: "scopeTenant", type: "text", required: true, index: true },
|
||||
|
||||
// WHY
|
||||
{ name: "reason", type: "text" },
|
||||
{ name: "correlationId", type: "text", index: true },
|
||||
{ name: "requestId", type: "text" },
|
||||
|
||||
// FROM
|
||||
{ name: "ipTruncated", type: "text", required: true },
|
||||
{ name: "userAgent", type: "text", required: true },
|
||||
|
||||
// PII
|
||||
{ name: "containsPii", type: "checkbox", required: true },
|
||||
{ name: "piiCategories", type: "json" },
|
||||
|
||||
// OUTCOME
|
||||
{
|
||||
name: "outcome",
|
||||
type: "select",
|
||||
options: ["success", "denied", "error"],
|
||||
required: true,
|
||||
},
|
||||
{ name: "errorCode", type: "text" },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import "reflect-metadata";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Container } from "inversify";
|
||||
import { bindAudit } from "./bind-audit";
|
||||
import { AUDIT_SYMBOLS } from "./symbols";
|
||||
import { NoopAuditLog } from "../noop-audit-log";
|
||||
import { StdoutJsonAuditLog } from "../stdout-json-audit-log";
|
||||
import { PayloadAuditLog } from "../payload-audit-log";
|
||||
import { MultiSinkAuditLog } from "../multi-sink-audit-log";
|
||||
import { TraceIdEnrichingAuditLog } from "../trace-id-enriching-audit-log";
|
||||
import type { IAuditLog } from "../audit-log.interface";
|
||||
|
||||
describe("bindAudit", () => {
|
||||
it("defaults to MultiSinkAuditLog([payload, stdout]) when payloadConfig is provided", () => {
|
||||
const container = new Container();
|
||||
bindAudit(container, { payloadConfig: {} as never });
|
||||
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
|
||||
expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog);
|
||||
expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(MultiSinkAuditLog);
|
||||
});
|
||||
|
||||
it("returns StdoutJsonAuditLog alone when payloadConfig omitted + default sinks", () => {
|
||||
const container = new Container();
|
||||
bindAudit(container, {});
|
||||
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
|
||||
expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog);
|
||||
expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(StdoutJsonAuditLog);
|
||||
});
|
||||
|
||||
it("returns NoopAuditLog when sinks=[]", () => {
|
||||
const container = new Container();
|
||||
bindAudit(container, { sinks: [] });
|
||||
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
|
||||
expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog);
|
||||
expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(NoopAuditLog);
|
||||
});
|
||||
|
||||
it("returns PayloadAuditLog when sinks=['payload'] only", () => {
|
||||
const container = new Container();
|
||||
bindAudit(container, { payloadConfig: {} as never, sinks: ["payload"] });
|
||||
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
|
||||
expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog);
|
||||
expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(PayloadAuditLog);
|
||||
});
|
||||
|
||||
it("validates AUDIT_PSEUDONYM_SALT in production", () => {
|
||||
const env = process.env as Record<string, string | undefined>;
|
||||
const oldEnv = env["NODE_ENV"];
|
||||
const oldSalt = env["AUDIT_PSEUDONYM_SALT"];
|
||||
env["NODE_ENV"] = "production";
|
||||
delete env["AUDIT_PSEUDONYM_SALT"];
|
||||
expect(() => bindAudit(new Container(), { sinks: ["stdout"] })).toThrow(
|
||||
/AUDIT_PSEUDONYM_SALT/,
|
||||
);
|
||||
env["NODE_ENV"] = oldEnv;
|
||||
if (oldSalt) env["AUDIT_PSEUDONYM_SALT"] = oldSalt;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import "reflect-metadata";
|
||||
import type { Container } from "inversify";
|
||||
import { getPayload as _getPayload, type SanitizedConfig } from "payload";
|
||||
import { NoopAuditLog } from "../noop-audit-log";
|
||||
import { PayloadAuditLog } from "../payload-audit-log";
|
||||
import { StdoutJsonAuditLog } from "../stdout-json-audit-log";
|
||||
import { MultiSinkAuditLog } from "../multi-sink-audit-log";
|
||||
import type { IAuditLog } from "../audit-log.interface";
|
||||
import { AUDIT_SYMBOLS } from "./symbols";
|
||||
import { TraceIdEnrichingAuditLog } from "../trace-id-enriching-audit-log";
|
||||
|
||||
export type BindAuditOpts = {
|
||||
/** Payload config; required if "payload" is in sinks. */
|
||||
payloadConfig?: SanitizedConfig;
|
||||
/** Sink selection. Default ["payload", "stdout"]. */
|
||||
sinks?: ("payload" | "stdout")[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Binds an `IAuditLog` impl to the container under `AUDIT_SYMBOLS.IAuditLog`.
|
||||
*
|
||||
* Default sink set: ["payload", "stdout"] — Payload local cache + structured
|
||||
* JSON to stdout (operator wires a log shipper to the centralized aggregator).
|
||||
*
|
||||
* In production, AUDIT_PSEUDONYM_SALT env var MUST be set. Boot fails fast
|
||||
* if not — better to refuse to start than to ship audit data with a dev-fallback
|
||||
* salt that an attacker could reverse.
|
||||
*
|
||||
* The returned auditLog is wrapped in TraceIdEnrichingAuditLog
|
||||
* so all sinks receive AuditEntry.correlationId auto-populated from the
|
||||
* active OTel span. The inner sink/fan-out is accessible via `.inner`.
|
||||
*/
|
||||
export function bindAudit(
|
||||
container: Container,
|
||||
opts: BindAuditOpts = {},
|
||||
): { auditLog: IAuditLog } {
|
||||
if (process.env.NODE_ENV === "production" && !process.env.AUDIT_PSEUDONYM_SALT) {
|
||||
throw new Error(
|
||||
"AUDIT_PSEUDONYM_SALT environment variable is required in production. " +
|
||||
"Generate via `openssl rand -hex 32` and store in your secrets manager.",
|
||||
);
|
||||
}
|
||||
|
||||
const sinkList = opts.sinks ?? ["payload", "stdout"];
|
||||
const sinks: IAuditLog[] = [];
|
||||
if (sinkList.includes("payload") && opts.payloadConfig) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
sinks.push(new PayloadAuditLog(opts.payloadConfig, _getPayload as any));
|
||||
}
|
||||
if (sinkList.includes("stdout")) {
|
||||
sinks.push(new StdoutJsonAuditLog());
|
||||
}
|
||||
|
||||
const inner: IAuditLog =
|
||||
sinks.length > 1 ? new MultiSinkAuditLog(sinks)
|
||||
: sinks.length === 1 ? sinks[0]!
|
||||
: new NoopAuditLog();
|
||||
const auditLog: IAuditLog = new TraceIdEnrichingAuditLog(inner);
|
||||
|
||||
if (container.isBound(AUDIT_SYMBOLS.IAuditLog)) {
|
||||
container.unbind(AUDIT_SYMBOLS.IAuditLog);
|
||||
}
|
||||
container.bind<IAuditLog>(AUDIT_SYMBOLS.IAuditLog).toConstantValue(auditLog);
|
||||
|
||||
return { auditLog };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const AUDIT_SYMBOLS = {
|
||||
IAuditLog: Symbol.for("core-audit:IAuditLog"),
|
||||
} as const;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createAuditAfterReadHook } from "./audit-after-read-hook";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "../audit-log.interface";
|
||||
|
||||
function makeAuditLog(): IAuditLog & { recorded: AuditEntry[] } {
|
||||
const recorded: AuditEntry[] = [];
|
||||
return {
|
||||
recorded,
|
||||
async record(e) { recorded.push(e); },
|
||||
eraseSubject: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function baseOpts(auditLog: IAuditLog) {
|
||||
return {
|
||||
auditLog,
|
||||
resourceType: "users",
|
||||
feature: "auth",
|
||||
environment: "test",
|
||||
resolveTenant: () => "default",
|
||||
containsPii: true,
|
||||
piiCategories: ["email"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("createAuditAfterReadHook", () => {
|
||||
it("emits a VIEW entry with the resource type + feature + tenant", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditAfterReadHook(baseOpts(auditLog));
|
||||
|
||||
const doc = { id: "abc", email: "x@y.com" };
|
||||
const req = { user: { id: "user_1", roles: ["user"] }, headers: { "user-agent": "Mozilla" }, ip: "10.0.0.5" };
|
||||
await hook({ doc, req } as never);
|
||||
|
||||
// Wait one tick for fire-and-forget to flush
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(auditLog.recorded).toHaveLength(1);
|
||||
const e = auditLog.recorded[0]!;
|
||||
expect(e.action).toBe("VIEW");
|
||||
expect(e.resource.type).toBe("users");
|
||||
expect(e.resource.id).toBe("abc");
|
||||
expect(e.actorId).toBe("user_1");
|
||||
expect(e.actorRoles).toEqual(["user"]);
|
||||
expect(e.scope.feature).toBe("auth");
|
||||
expect(e.scope.tenant).toBe("default");
|
||||
expect(e.containsPii).toBe(true);
|
||||
expect(e.piiCategories).toEqual(["email"]);
|
||||
expect(e.outcome).toBe("success");
|
||||
expect(e.from.ipTruncated).toBe("10.0.0.0"); // /24 truncation applied
|
||||
});
|
||||
|
||||
it("uses 'system' actor when req.user is null", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditAfterReadHook(baseOpts(auditLog));
|
||||
await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(auditLog.recorded[0]!.actorId).toBe("system");
|
||||
expect(auditLog.recorded[0]!.actorType).toBe("system");
|
||||
});
|
||||
|
||||
it("falls back to 'internal' / 'payload-internal' sentinels when no IP/UA", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditAfterReadHook(baseOpts(auditLog));
|
||||
await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(auditLog.recorded[0]!.from.ipTruncated).toBe("internal");
|
||||
expect(auditLog.recorded[0]!.from.userAgent).toBe("payload-internal");
|
||||
});
|
||||
|
||||
it("shouldSkip predicate prevents emission", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditAfterReadHook({ ...baseOpts(auditLog), shouldSkip: () => true });
|
||||
await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(auditLog.recorded).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns the doc unchanged (afterRead hook contract)", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditAfterReadHook(baseOpts(auditLog));
|
||||
const doc = { id: "abc", title: "Hello" };
|
||||
const result = await hook({ doc, req: { user: null, headers: {} } } as never);
|
||||
expect(result).toBe(doc);
|
||||
});
|
||||
|
||||
it("audit-sink failures do not propagate (fire-and-forget)", async () => {
|
||||
const auditLog: IAuditLog = {
|
||||
record: async () => { throw new Error("sink-failed"); },
|
||||
eraseSubject: vi.fn(),
|
||||
};
|
||||
const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
const hook = createAuditAfterReadHook(baseOpts(auditLog));
|
||||
await expect(
|
||||
hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never),
|
||||
).resolves.toBeDefined();
|
||||
// Give the microtask queue a moment to flush the catch handler
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { CollectionAfterReadHook } from "payload";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import { truncateIp } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "../audit-log.interface";
|
||||
|
||||
export type AuditAfterReadHookOpts = {
|
||||
auditLog: IAuditLog;
|
||||
/** Resource type for AuditEntry.resource.type (e.g., "users"). */
|
||||
resourceType: string;
|
||||
/** Feature attribution for AuditEntry.scope.feature. */
|
||||
feature: string;
|
||||
/** Deployment environment. */
|
||||
environment: string;
|
||||
/** Tenant resolver — single-tenant projects return "default". */
|
||||
resolveTenant: (req: { user?: { id: string; tenantId?: string } | null }) => string;
|
||||
/** Whether this collection contains PII. Propagates to every entry. */
|
||||
containsPii: boolean;
|
||||
/** Optional PII categories applicable to all entries from this collection. */
|
||||
piiCategories?: string[];
|
||||
/** Optional predicate; return true to skip emitting an entry. */
|
||||
shouldSkip?: (args: { req: unknown; doc: { id: string | number } }) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Payload afterRead hook factory. Emits a VIEW AuditEntry per document read.
|
||||
* Per-collection opt-in: install via `hooks.afterRead: [createAuditAfterReadHook(...)]`
|
||||
* on the collection config.
|
||||
*
|
||||
* Fire-and-forget: a failing audit sink does NOT propagate up to break the
|
||||
* user-facing read. Failures emit a structured error to stderr (visible to
|
||||
* the same log shipper as audit entries themselves).
|
||||
*
|
||||
* Combine with use-case-level record() calls for app-facing reads; this hook
|
||||
* covers direct CMS/admin/programmatic reads. The use-case path captures
|
||||
* "why" (reason); this hook captures "the system saw this doc".
|
||||
*/
|
||||
export function createAuditAfterReadHook(
|
||||
opts: AuditAfterReadHookOpts,
|
||||
): CollectionAfterReadHook {
|
||||
return async ({ doc, req }) => {
|
||||
if (opts.shouldSkip?.({ req, doc: doc as { id: string | number } })) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
const actor = (req as { user?: { id: string; roles?: string[]; tenantId?: string } | null }).user;
|
||||
const entry: AuditEntry = {
|
||||
actorId: actor?.id ?? "system",
|
||||
actorType: actor ? "user" : "system",
|
||||
actorRoles: actor?.roles ?? [],
|
||||
action: "VIEW",
|
||||
resource: {
|
||||
type: opts.resourceType,
|
||||
id: typeof doc.id === "string" || typeof doc.id === "number" ? String(doc.id) : undefined,
|
||||
},
|
||||
at: new Date(),
|
||||
scope: {
|
||||
feature: opts.feature,
|
||||
environment: opts.environment,
|
||||
tenant: opts.resolveTenant(req as { user?: { id: string; tenantId?: string } | null }),
|
||||
},
|
||||
reason: "payload-afterRead-hook",
|
||||
from: {
|
||||
ipTruncated: extractIpTruncated(req) ?? "internal",
|
||||
userAgent: extractUserAgent(req) ?? "payload-internal",
|
||||
},
|
||||
containsPii: opts.containsPii,
|
||||
piiCategories: opts.piiCategories,
|
||||
outcome: "success",
|
||||
};
|
||||
|
||||
// Fire-and-forget — never break the read.
|
||||
void opts.auditLog.record(entry).catch((err: unknown) => {
|
||||
process.stderr.write(
|
||||
JSON.stringify({
|
||||
_type: "audit-hook-error",
|
||||
hook: "afterRead",
|
||||
resourceType: opts.resourceType,
|
||||
error: String(err),
|
||||
at: new Date().toISOString(),
|
||||
}) + "\n",
|
||||
);
|
||||
});
|
||||
|
||||
return doc;
|
||||
};
|
||||
}
|
||||
|
||||
function extractIpTruncated(req: unknown): string | undefined {
|
||||
const r = req as { ip?: string; headers?: Record<string, string | string[] | undefined> };
|
||||
const rawIp = r.ip ?? r.headers?.["x-forwarded-for"];
|
||||
if (!rawIp) return undefined;
|
||||
const candidate = Array.isArray(rawIp) ? rawIp[0]! : rawIp.split(",")[0]!.trim();
|
||||
try {
|
||||
return truncateIp(candidate);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUserAgent(req: unknown): string | undefined {
|
||||
const r = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
const ua = r.headers?.["user-agent"];
|
||||
if (!ua) return undefined;
|
||||
return Array.isArray(ua) ? ua[0] : ua;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createAuditErasureHook } from "./audit-erasure-hook";
|
||||
import type { IAuditLog } from "../audit-log.interface";
|
||||
|
||||
function makeAuditLog(): IAuditLog {
|
||||
return {
|
||||
record: vi.fn().mockResolvedValue(undefined),
|
||||
eraseSubject: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal CollectionAfterDeleteHook args shape (only `doc` matters here). */
|
||||
function hookArgs(id: unknown) {
|
||||
return {
|
||||
doc: { id },
|
||||
req: {} as never,
|
||||
id: String(id),
|
||||
collection: {} as never,
|
||||
context: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createAuditErasureHook", () => {
|
||||
it("defaults to 'pseudonymize' mode", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditErasureHook({ auditLog });
|
||||
await hook(hookArgs("user_1") as never);
|
||||
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_1", "pseudonymize");
|
||||
});
|
||||
|
||||
it("respects explicit mode='delete'", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditErasureHook({ auditLog, mode: "delete" });
|
||||
await hook(hookArgs("user_2") as never);
|
||||
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_2", "delete");
|
||||
});
|
||||
|
||||
it("coerces numeric id to string", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditErasureHook({ auditLog });
|
||||
await hook(hookArgs(42) as never);
|
||||
expect(auditLog.eraseSubject).toHaveBeenCalledWith("42", "pseudonymize");
|
||||
});
|
||||
|
||||
it("skips when doc.id is undefined", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditErasureHook({ auditLog });
|
||||
await hook(hookArgs(undefined) as never);
|
||||
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips when doc.id is null", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditErasureHook({ auditLog });
|
||||
await hook(hookArgs(null) as never);
|
||||
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips when doc.id is an object", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditErasureHook({ auditLog });
|
||||
await hook(hookArgs({ nested: true }) as never);
|
||||
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CollectionAfterDeleteHook } from "payload";
|
||||
import type { IAuditLog } from "../audit-log.interface";
|
||||
|
||||
export type AuditErasureHookOpts = {
|
||||
/** The audit log impl that will perform the erasure. */
|
||||
auditLog: IAuditLog;
|
||||
/**
|
||||
* Erasure mode. Defaults to "pseudonymize" — the softer option that
|
||||
* retains the audit trail shape while removing PII linkage. Use
|
||||
* "delete" only when the data-subject specifically requests hard removal.
|
||||
*/
|
||||
mode?: "pseudonymize" | "delete";
|
||||
};
|
||||
|
||||
/**
|
||||
* Payload `afterDelete` hook factory for GDPR erasure.
|
||||
*
|
||||
* Wire this on any collection whose `id` doubles as an audit subject
|
||||
* (e.g., the users collection). When Payload deletes a document, the
|
||||
* hook calls `auditLog.eraseSubject(String(doc.id), mode)`, removing
|
||||
* or pseudonymizing all audit entries recorded for that actor.
|
||||
*
|
||||
* The hook has no schema-specific knowledge — it works on any collection
|
||||
* that stores the subject identifier as its document `id`.
|
||||
*
|
||||
* Non-string, non-numeric ids are silently skipped (safe guard against
|
||||
* undefined/null that Payload may produce in edge cases).
|
||||
*/
|
||||
export function createAuditErasureHook(
|
||||
opts: AuditErasureHookOpts,
|
||||
): CollectionAfterDeleteHook {
|
||||
const mode = opts.mode ?? "pseudonymize";
|
||||
return async ({ doc }) => {
|
||||
if (typeof doc.id === "string" || typeof doc.id === "number") {
|
||||
await opts.auditLog.eraseSubject(String(doc.id), mode);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createAuditErasureHook,
|
||||
type AuditErasureHookOpts,
|
||||
} from "./audit-erasure-hook";
|
||||
export {
|
||||
createAuditAfterReadHook,
|
||||
type AuditAfterReadHookOpts,
|
||||
} from "./audit-after-read-hook";
|
||||
@@ -0,0 +1,30 @@
|
||||
export type { IAuditLog } from "./audit-log.interface";
|
||||
export type { AuditEntry, AuditAction, AuditFrom } from "@repo/core-shared/audit";
|
||||
export { NoopAuditLog } from "./noop-audit-log";
|
||||
export { StdoutJsonAuditLog } from "./stdout-json-audit-log";
|
||||
export { PayloadAuditLog } from "./payload-audit-log";
|
||||
export { MultiSinkAuditLog } from "./multi-sink-audit-log";
|
||||
export { auditLogsCollection } from "./audit-logs-collection";
|
||||
export { bindAudit, type BindAuditOpts } from "./di/bind-audit";
|
||||
export { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
|
||||
export { AUDIT_SYMBOLS } from "./di/symbols";
|
||||
// GDPR erasure
|
||||
export { pseudonymize } from "./pseudonymize";
|
||||
export {
|
||||
createAuditErasureHook,
|
||||
type AuditErasureHookOpts,
|
||||
} from "./hooks/audit-erasure-hook";
|
||||
// VIEW capture
|
||||
export {
|
||||
createAuditAfterReadHook,
|
||||
type AuditAfterReadHookOpts,
|
||||
} from "./hooks";
|
||||
export {
|
||||
createAuditRouter,
|
||||
auditRouter,
|
||||
type AuditRouter,
|
||||
} from "./integrations/api/router";
|
||||
export {
|
||||
auditProcedure,
|
||||
type AdminTrpcUser,
|
||||
} from "./integrations/api/procedures";
|
||||
@@ -0,0 +1,43 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { t } from "@repo/core-shared/trpc/init";
|
||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||
|
||||
/**
|
||||
* The minimum user shape that the adminOnly middleware expects to find on `ctx`.
|
||||
* Apps must include this field when creating their tRPC context for requests
|
||||
* that may reach admin procedures. Unauthenticated requests leave `user`
|
||||
* undefined, which the middleware treats as non-admin.
|
||||
*/
|
||||
export type AdminTrpcUser = {
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware that blocks non-admin callers.
|
||||
*
|
||||
* Reads `ctx.user?.roles` from the tRPC context. Throws FORBIDDEN if the
|
||||
* user is absent or lacks the "admin" role. Apps that mount the auditRouter
|
||||
* must set `ctx.user` with the authenticated user's roles.
|
||||
*/
|
||||
const adminOnly = t.middleware(({ ctx, next }) => {
|
||||
const user = (ctx as { user?: AdminTrpcUser }).user;
|
||||
if (!user?.roles.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Admin role required",
|
||||
});
|
||||
}
|
||||
return next({ ctx: { ...ctx, user } });
|
||||
});
|
||||
|
||||
/**
|
||||
* Base procedure for all audit admin routes.
|
||||
*
|
||||
* - `adminOnly` middleware gates every mutation/query.
|
||||
* - `defineErrorMiddleware([])` — no audit-specific domain errors need tRPC
|
||||
* mapping; the FORBIDDEN thrown by `adminOnly` is a plain TRPCError and
|
||||
* propagates unchanged.
|
||||
*/
|
||||
export const auditProcedure = t.procedure
|
||||
.use(adminOnly)
|
||||
.use(defineErrorMiddleware([]));
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createAuditRouter } from "./router";
|
||||
import type { IAuditLog } from "../../audit-log.interface";
|
||||
import type { AdminTrpcUser } from "./procedures";
|
||||
|
||||
/**
|
||||
* Minimal harness: create a tRPC caller directly from the router so we
|
||||
* don't need a real HTTP layer.
|
||||
*/
|
||||
function makeCallerWithUser(
|
||||
auditLog: IAuditLog,
|
||||
user?: AdminTrpcUser,
|
||||
) {
|
||||
const router = createAuditRouter(auditLog);
|
||||
// Use the tRPC caller factory to invoke mutations directly in tests.
|
||||
return router.createCaller({ user } as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function makeAuditLog(): IAuditLog {
|
||||
return {
|
||||
record: vi.fn().mockResolvedValue(undefined),
|
||||
eraseSubject: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe("auditRouter.eraseSubject", () => {
|
||||
it("throws FORBIDDEN when ctx.user is absent", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const caller = makeCallerWithUser(auditLog, undefined);
|
||||
|
||||
await expect(
|
||||
caller.eraseSubject({ actorId: "user_1", mode: "pseudonymize" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
|
||||
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws FORBIDDEN when user lacks admin role", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const caller = makeCallerWithUser(auditLog, { roles: ["editor", "viewer"] });
|
||||
|
||||
await expect(
|
||||
caller.eraseSubject({ actorId: "user_1", mode: "pseudonymize" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
|
||||
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls eraseSubject with pseudonymize mode for an admin user", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const caller = makeCallerWithUser(auditLog, { roles: ["admin"] });
|
||||
|
||||
const result = await caller.eraseSubject({
|
||||
actorId: "user_1",
|
||||
mode: "pseudonymize",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_1", "pseudonymize");
|
||||
});
|
||||
|
||||
it("calls eraseSubject with delete mode for an admin user", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const caller = makeCallerWithUser(auditLog, { roles: ["admin"] });
|
||||
|
||||
const result = await caller.eraseSubject({
|
||||
actorId: "user_2",
|
||||
mode: "delete",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_2", "delete");
|
||||
});
|
||||
|
||||
it("defaults mode to 'pseudonymize' when not provided", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const caller = makeCallerWithUser(auditLog, { roles: ["admin"] });
|
||||
|
||||
// mode has a .default("pseudonymize") in the schema
|
||||
await caller.eraseSubject({ actorId: "user_3", mode: "pseudonymize" });
|
||||
|
||||
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_3", "pseudonymize");
|
||||
});
|
||||
|
||||
it("rejects empty actorId (schema validation)", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const caller = makeCallerWithUser(auditLog, { roles: ["admin"] });
|
||||
|
||||
await expect(
|
||||
caller.eraseSubject({ actorId: "", mode: "pseudonymize" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod";
|
||||
import { t } from "@repo/core-shared/trpc/init";
|
||||
import type { IAuditLog } from "../../audit-log.interface";
|
||||
import { auditProcedure } from "./procedures";
|
||||
|
||||
/**
|
||||
* Creates the audit admin tRPC router.
|
||||
*
|
||||
* The `auditLog` parameter is captured at router-creation time. Apps that
|
||||
* mount this router must pass the `IAuditLog` impl returned by `bindAudit`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { auditLog } = bindAudit(container, { payloadConfig, sinks: ["payload", "stdout"] });
|
||||
* const appRouter = t.router({ ..., audit: createAuditRouter(auditLog) });
|
||||
* ```
|
||||
*/
|
||||
export function createAuditRouter(auditLog: IAuditLog) {
|
||||
return t.router({
|
||||
eraseSubject: auditProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
actorId: z.string().min(1),
|
||||
mode: z.enum(["pseudonymize", "delete"]).default("pseudonymize"),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
await auditLog.eraseSubject(input.actorId, input.mode);
|
||||
return { ok: true as const };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience singleton for projects that have a single audit log instance.
|
||||
* Most callers should use `createAuditRouter` and pass the IAuditLog explicitly.
|
||||
* This export is a stub that throws at call time if auditLog has not been
|
||||
* provided — it exists for type inference purposes (`AuditRouter`).
|
||||
*/
|
||||
export const auditRouter = createAuditRouter(
|
||||
new Proxy({} as IAuditLog, {
|
||||
get(_target, prop) {
|
||||
if (prop === "then") return undefined; // not a Promise
|
||||
throw new Error(
|
||||
`auditRouter singleton used without providing an IAuditLog. ` +
|
||||
`Use createAuditRouter(auditLog) instead.`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export type AuditRouter = ReturnType<typeof createAuditRouter>;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { MultiSinkAuditLog } from "./multi-sink-audit-log";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "./audit-log.interface";
|
||||
|
||||
const sample: AuditEntry = {
|
||||
actorId: "user_1",
|
||||
actorType: "user",
|
||||
actorRoles: [],
|
||||
action: "VIEW",
|
||||
resource: { type: "articles" },
|
||||
at: new Date(),
|
||||
scope: { feature: "blog", environment: "test", tenant: "default" },
|
||||
from: { ipTruncated: "10.0.0.0", userAgent: "test" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
};
|
||||
|
||||
function makeRecorder(): IAuditLog & { records: AuditEntry[]; erasures: string[] } {
|
||||
const records: AuditEntry[] = [];
|
||||
const erasures: string[] = [];
|
||||
return {
|
||||
records,
|
||||
erasures,
|
||||
async record(e) { records.push(e); },
|
||||
async eraseSubject(actorId) { erasures.push(actorId); },
|
||||
};
|
||||
}
|
||||
|
||||
describe("MultiSinkAuditLog", () => {
|
||||
it("record() fans out to every sink", async () => {
|
||||
const a = makeRecorder();
|
||||
const b = makeRecorder();
|
||||
const m = new MultiSinkAuditLog([a, b]);
|
||||
await m.record(sample);
|
||||
expect(a.records).toHaveLength(1);
|
||||
expect(b.records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("settle-all: one sink failing does not skip others", async () => {
|
||||
const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
const a: IAuditLog = { record: async () => { throw new Error("a-fail"); }, eraseSubject: async () => {} };
|
||||
const b = makeRecorder();
|
||||
const m = new MultiSinkAuditLog([a, b]);
|
||||
|
||||
await m.record(sample);
|
||||
|
||||
expect(b.records).toHaveLength(1); // b still received the entry
|
||||
expect(errSpy).toHaveBeenCalledOnce();
|
||||
const written = errSpy.mock.calls[0]![0] as string;
|
||||
const parsed = JSON.parse(written.trimEnd());
|
||||
expect(parsed._type).toBe("audit-sink-error");
|
||||
expect(parsed.error).toContain("a-fail");
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("eraseSubject() fans out to every sink", async () => {
|
||||
const a = makeRecorder();
|
||||
const b = makeRecorder();
|
||||
const m = new MultiSinkAuditLog([a, b]);
|
||||
await m.eraseSubject("user_1", "delete");
|
||||
expect(a.erasures).toEqual(["user_1"]);
|
||||
expect(b.erasures).toEqual(["user_1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "./audit-log.interface";
|
||||
|
||||
/**
|
||||
* Fan-out wrapper. Delivers each entry to every inner sink with settle-all
|
||||
* semantics — one failing sink doesn't drop the audit entry from others.
|
||||
*
|
||||
* Failures emit a structured `audit-sink-error` JSON line to stderr.
|
||||
* Stderr (not via OTel/Sentry) avoids recursion: if Sentry is one of the
|
||||
* sinks failing and we routed the error back through Sentry's reporter,
|
||||
* we'd loop. Stderr is consumed by the same log shipper as audit entries
|
||||
* themselves, so the operator sees the failure in their aggregator.
|
||||
*/
|
||||
export class MultiSinkAuditLog implements IAuditLog {
|
||||
constructor(private readonly sinks: IAuditLog[]) {}
|
||||
|
||||
async record(entry: AuditEntry): Promise<void> {
|
||||
const results = await Promise.allSettled(this.sinks.map((s) => s.record(entry)));
|
||||
for (const r of results) {
|
||||
if (r.status === "rejected") {
|
||||
this.reportSinkError(r.reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
this.sinks.map((s) => s.eraseSubject(actorId, mode)),
|
||||
);
|
||||
for (const r of results) {
|
||||
if (r.status === "rejected") {
|
||||
this.reportSinkError(r.reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private reportSinkError(reason: unknown): void {
|
||||
const line = JSON.stringify({
|
||||
_type: "audit-sink-error",
|
||||
error: String(reason),
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
process.stderr.write(line + "\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { NoopAuditLog } from "./noop-audit-log";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
|
||||
describe("NoopAuditLog", () => {
|
||||
const sample: AuditEntry = {
|
||||
actorId: "user_1",
|
||||
actorType: "user",
|
||||
actorRoles: [],
|
||||
action: "VIEW",
|
||||
resource: { type: "articles", id: "1" },
|
||||
at: new Date(),
|
||||
scope: { feature: "blog", environment: "test", tenant: "default" },
|
||||
from: { ipTruncated: "10.0.0.0", userAgent: "test" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
};
|
||||
|
||||
it("record() is a no-op that does not throw", async () => {
|
||||
const log = new NoopAuditLog();
|
||||
await expect(log.record(sample)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("eraseSubject() is a no-op that does not throw", async () => {
|
||||
const log = new NoopAuditLog();
|
||||
await expect(log.eraseSubject("user_1", "pseudonymize")).resolves.toBeUndefined();
|
||||
await expect(log.eraseSubject("user_1", "delete")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "./audit-log.interface";
|
||||
|
||||
export class NoopAuditLog implements IAuditLog {
|
||||
async record(_entry: AuditEntry): Promise<void> {
|
||||
// intentional no-op
|
||||
}
|
||||
async eraseSubject(_actorId: string, _mode: "pseudonymize" | "delete"): Promise<void> {
|
||||
// intentional no-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { PayloadAuditLog } from "./payload-audit-log";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
|
||||
const sample: AuditEntry = {
|
||||
actorId: "user_1",
|
||||
actorType: "user",
|
||||
actorRoles: ["admin"],
|
||||
action: "UPDATE",
|
||||
resource: { type: "articles", id: "abc" },
|
||||
changedFields: ["title", "body"],
|
||||
at: new Date("2026-05-11T10:00:00.000Z"),
|
||||
scope: { feature: "blog", environment: "production", tenant: "default" },
|
||||
from: { ipTruncated: "10.0.0.0", userAgent: "Mozilla/5.0" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
};
|
||||
|
||||
describe("PayloadAuditLog.record", () => {
|
||||
it("maps AuditEntry → flat collection doc + calls payload.create", async () => {
|
||||
const mockCreate = vi.fn().mockResolvedValue({ id: "doc_1" });
|
||||
const mockGetPayload = vi.fn().mockResolvedValue({ create: mockCreate });
|
||||
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
||||
|
||||
await log.record(sample);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledOnce();
|
||||
const call = mockCreate.mock.calls[0]![0] as { collection: string; data: Record<string, unknown> };
|
||||
expect(call.collection).toBe("audit-logs");
|
||||
expect(call.data.actorId).toBe("user_1");
|
||||
expect(call.data.action).toBe("UPDATE");
|
||||
expect(call.data.resourceType).toBe("articles");
|
||||
expect(call.data.resourceId).toBe("abc");
|
||||
expect(call.data.changedFields).toEqual(["title", "body"]);
|
||||
expect(call.data.scopeFeature).toBe("blog");
|
||||
expect(call.data.scopeTenant).toBe("default");
|
||||
expect(call.data.ipTruncated).toBe("10.0.0.0");
|
||||
expect(call.data.containsPii).toBe(false);
|
||||
expect(call.data.outcome).toBe("success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayloadAuditLog.eraseSubject", () => {
|
||||
const originalSalt = process.env["AUDIT_PSEUDONYM_SALT"];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env["AUDIT_PSEUDONYM_SALT"] = "test-salt-erase";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalSalt === undefined) {
|
||||
delete process.env["AUDIT_PSEUDONYM_SALT"];
|
||||
} else {
|
||||
process.env["AUDIT_PSEUDONYM_SALT"] = originalSalt;
|
||||
}
|
||||
});
|
||||
|
||||
it("mode='delete' calls payload.delete with the correct where clause + overrideAccess", async () => {
|
||||
const mockDelete = vi.fn().mockResolvedValue({});
|
||||
const mockGetPayload = vi.fn().mockResolvedValue({ delete: mockDelete });
|
||||
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
||||
|
||||
await log.eraseSubject("user_1", "delete");
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledOnce();
|
||||
const call = mockDelete.mock.calls[0]![0] as {
|
||||
collection: string;
|
||||
where: Record<string, unknown>;
|
||||
overrideAccess: boolean;
|
||||
};
|
||||
expect(call.collection).toBe("audit-logs");
|
||||
expect(call.where).toEqual({ actorId: { equals: "user_1" } });
|
||||
expect(call.overrideAccess).toBe(true);
|
||||
});
|
||||
|
||||
it("mode='pseudonymize' finds matching docs and updates each actorId to the pseudonym", async () => {
|
||||
const mockFind = vi.fn().mockResolvedValue({
|
||||
docs: [{ id: "doc_a" }, { id: "doc_b" }],
|
||||
});
|
||||
const mockUpdate = vi.fn().mockResolvedValue({});
|
||||
const mockGetPayload = vi.fn().mockResolvedValue({
|
||||
find: mockFind,
|
||||
update: mockUpdate,
|
||||
});
|
||||
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
||||
|
||||
await log.eraseSubject("user_1", "pseudonymize");
|
||||
|
||||
// find must use overrideAccess + limit=10_000
|
||||
const findCall = mockFind.mock.calls[0]![0] as {
|
||||
collection: string;
|
||||
where: Record<string, unknown>;
|
||||
limit: number;
|
||||
overrideAccess: boolean;
|
||||
};
|
||||
expect(findCall.collection).toBe("audit-logs");
|
||||
expect(findCall.where).toEqual({ actorId: { equals: "user_1" } });
|
||||
expect(findCall.limit).toBe(10_000);
|
||||
expect(findCall.overrideAccess).toBe(true);
|
||||
|
||||
// update called for each doc
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(2);
|
||||
const updateCalls = mockUpdate.mock.calls as Array<
|
||||
[{ collection: string; id: string; data: Record<string, unknown>; overrideAccess: boolean }]
|
||||
>;
|
||||
expect(updateCalls[0]![0].id).toBe("doc_a");
|
||||
expect(updateCalls[1]![0].id).toBe("doc_b");
|
||||
|
||||
// both updates replace actorId with the same pseudonym
|
||||
const pseudonym = updateCalls[0]![0].data["actorId"] as string;
|
||||
expect(pseudonym).toMatch(/^erased-[0-9a-f]{16}$/);
|
||||
expect(updateCalls[1]![0].data["actorId"]).toBe(pseudonym);
|
||||
|
||||
// overrideAccess bypasses the append-only rule
|
||||
expect(updateCalls[0]![0].overrideAccess).toBe(true);
|
||||
});
|
||||
|
||||
it("mode='pseudonymize' with no matching docs does not call update", async () => {
|
||||
const mockFind = vi.fn().mockResolvedValue({ docs: [] });
|
||||
const mockUpdate = vi.fn();
|
||||
const mockGetPayload = vi.fn().mockResolvedValue({ find: mockFind, update: mockUpdate });
|
||||
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
||||
|
||||
await log.eraseSubject("unknown_user", "pseudonymize");
|
||||
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "./audit-log.interface";
|
||||
import { pseudonymize } from "./pseudonymize";
|
||||
|
||||
type GetPayload = (args: { config: SanitizedConfig }) => Promise<{
|
||||
create: (args: { collection: string; data: Record<string, unknown> }) => Promise<unknown>;
|
||||
find: (args: {
|
||||
collection: string;
|
||||
where: Record<string, unknown>;
|
||||
limit: number;
|
||||
overrideAccess: true;
|
||||
}) => Promise<{ docs: Array<{ id: string | number }> }>;
|
||||
update: (args: {
|
||||
collection: string;
|
||||
id: string | number;
|
||||
data: Record<string, unknown>;
|
||||
overrideAccess: true;
|
||||
}) => Promise<unknown>;
|
||||
delete: (args: {
|
||||
collection: string;
|
||||
where: Record<string, unknown>;
|
||||
overrideAccess: true;
|
||||
}) => Promise<unknown>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Local-cache audit sink: writes entries to the `audit-logs` Payload
|
||||
* collection. The collection is append-only by access-rule
|
||||
* (`update: () => false`); the eraseSubject path uses `overrideAccess: true`
|
||||
* to bypass for the privileged GDPR pseudonymization op.
|
||||
*
|
||||
* The getPayload param is injectable for tests; production callers pass
|
||||
* the real `getPayload` from `payload`.
|
||||
*/
|
||||
export class PayloadAuditLog implements IAuditLog {
|
||||
constructor(
|
||||
private readonly config: SanitizedConfig,
|
||||
private readonly getPayload: GetPayload,
|
||||
) {}
|
||||
|
||||
async record(entry: AuditEntry): Promise<void> {
|
||||
const payload = await this.getPayload({ config: this.config });
|
||||
await payload.create({
|
||||
collection: "audit-logs",
|
||||
data: {
|
||||
actorId: entry.actorId,
|
||||
actorType: entry.actorType,
|
||||
actorRoles: entry.actorRoles,
|
||||
action: entry.action,
|
||||
resourceType: entry.resource.type,
|
||||
resourceId: entry.resource.id ?? null,
|
||||
changedFields: entry.changedFields ?? null,
|
||||
scopeFeature: entry.scope.feature,
|
||||
scopeEnvironment: entry.scope.environment,
|
||||
scopeTenant: entry.scope.tenant,
|
||||
reason: entry.reason ?? null,
|
||||
correlationId: entry.correlationId ?? null,
|
||||
requestId: entry.requestId ?? null,
|
||||
ipTruncated: entry.from.ipTruncated,
|
||||
userAgent: entry.from.userAgent,
|
||||
containsPii: entry.containsPii,
|
||||
piiCategories: entry.piiCategories ?? null,
|
||||
outcome: entry.outcome,
|
||||
errorCode: entry.errorCode ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
||||
const payload = await this.getPayload({ config: this.config });
|
||||
|
||||
if (mode === "delete") {
|
||||
await payload.delete({
|
||||
collection: "audit-logs",
|
||||
where: { actorId: { equals: actorId } },
|
||||
overrideAccess: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// mode === "pseudonymize"
|
||||
// Fetch all matching docs. Limit is 10_000 — a subject with more than
|
||||
// 10k audit entries will not have all entries pseudonymized in one call.
|
||||
// This is an accepted v1 limitation; callers may loop if needed.
|
||||
const { docs } = await payload.find({
|
||||
collection: "audit-logs",
|
||||
where: { actorId: { equals: actorId } },
|
||||
limit: 10_000,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const pseudonym = pseudonymize(actorId);
|
||||
for (const doc of docs) {
|
||||
await payload.update({
|
||||
collection: "audit-logs",
|
||||
id: doc.id,
|
||||
data: { actorId: pseudonym },
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { pseudonymize } from "./pseudonymize";
|
||||
|
||||
describe("pseudonymize", () => {
|
||||
const originalSalt = process.env["AUDIT_PSEUDONYM_SALT"];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env["AUDIT_PSEUDONYM_SALT"] = "test-salt-1";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalSalt === undefined) {
|
||||
delete process.env["AUDIT_PSEUDONYM_SALT"];
|
||||
} else {
|
||||
process.env["AUDIT_PSEUDONYM_SALT"] = originalSalt;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a string prefixed with 'erased-'", () => {
|
||||
const result = pseudonymize("user_42");
|
||||
expect(result).toMatch(/^erased-/);
|
||||
});
|
||||
|
||||
it("produces exactly 16 hex chars after the prefix", () => {
|
||||
const result = pseudonymize("user_42");
|
||||
const hex = result.slice("erased-".length);
|
||||
expect(hex).toHaveLength(16);
|
||||
expect(hex).toMatch(/^[0-9a-f]+$/);
|
||||
});
|
||||
|
||||
it("is deterministic — same salt + actorId always yields the same token", () => {
|
||||
const a = pseudonymize("user_42");
|
||||
const b = pseudonymize("user_42");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("differs when actorId differs (same salt)", () => {
|
||||
const a = pseudonymize("user_42");
|
||||
const b = pseudonymize("user_99");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("differs when the salt changes", () => {
|
||||
const withSalt1 = pseudonymize("user_42");
|
||||
|
||||
process.env["AUDIT_PSEUDONYM_SALT"] = "test-salt-2";
|
||||
const withSalt2 = pseudonymize("user_42");
|
||||
|
||||
expect(withSalt1).not.toBe(withSalt2);
|
||||
});
|
||||
|
||||
it("uses the fallback salt when env var is absent", () => {
|
||||
delete process.env["AUDIT_PSEUDONYM_SALT"];
|
||||
// Should not throw; just use the fallback.
|
||||
const result = pseudonymize("user_1");
|
||||
expect(result).toMatch(/^erased-[0-9a-f]{16}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Produces a stable, irreversible token for a GDPR-erased actorId.
|
||||
*
|
||||
* Format: `erased-<first-16-hex-chars-of-sha256(salt:actorId)>`
|
||||
*
|
||||
* The salt is read from `AUDIT_PSEUDONYM_SALT` env at call time so that
|
||||
* production binding can pre-validate the var at boot (see `bindAudit`)
|
||||
* while tests can override it per-test via `process.env`.
|
||||
*
|
||||
* Fallback salt is intentionally weak and labelled so that any token
|
||||
* produced with it is recognisable as a dev/test artefact.
|
||||
*/
|
||||
export function pseudonymize(actorId: string): string {
|
||||
const salt =
|
||||
process.env["AUDIT_PSEUDONYM_SALT"] ?? "dev-fallback-salt-replace-in-prod";
|
||||
const hash = createHash("sha256")
|
||||
.update(`${salt}:${actorId}`)
|
||||
.digest("hex");
|
||||
return `erased-${hash.slice(0, 16)}`;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { StdoutJsonAuditLog } from "./stdout-json-audit-log";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
|
||||
const sample: AuditEntry = {
|
||||
actorId: "user_1",
|
||||
actorType: "user",
|
||||
actorRoles: ["admin"],
|
||||
action: "CREATE",
|
||||
resource: { type: "articles", id: "abc" },
|
||||
at: new Date("2026-05-11T10:00:00.000Z"),
|
||||
scope: { feature: "blog", environment: "production", tenant: "default" },
|
||||
from: { ipTruncated: "10.0.0.0", userAgent: "Mozilla/5.0" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
};
|
||||
|
||||
describe("StdoutJsonAuditLog", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let writeSpy: ReturnType<typeof vi.spyOn<any, any>>;
|
||||
beforeEach(() => {
|
||||
writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
});
|
||||
|
||||
it("record() writes one JSON line per entry to stdout", async () => {
|
||||
const log = new StdoutJsonAuditLog();
|
||||
await log.record(sample);
|
||||
expect(writeSpy).toHaveBeenCalledOnce();
|
||||
const written = writeSpy.mock.calls[0]![0] as string;
|
||||
expect(written.endsWith("\n")).toBe(true);
|
||||
const parsed = JSON.parse(written.trimEnd());
|
||||
expect(parsed._type).toBe("audit");
|
||||
expect(parsed.actorId).toBe("user_1");
|
||||
expect(parsed.action).toBe("CREATE");
|
||||
expect(parsed.at).toBe("2026-05-11T10:00:00.000Z"); // ISO 8601 serialization
|
||||
});
|
||||
|
||||
it("eraseSubject() emits a tombstone with mode + actorId", async () => {
|
||||
const log = new StdoutJsonAuditLog();
|
||||
await log.eraseSubject("user_1", "pseudonymize");
|
||||
expect(writeSpy).toHaveBeenCalledOnce();
|
||||
const written = writeSpy.mock.calls[0]![0] as string;
|
||||
const parsed = JSON.parse(written.trimEnd());
|
||||
expect(parsed._type).toBe("audit-erasure");
|
||||
expect(parsed.actorId).toBe("user_1");
|
||||
expect(parsed.mode).toBe("pseudonymize");
|
||||
expect(typeof parsed.at).toBe("string"); // ISO 8601 timestamp
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "./audit-log.interface";
|
||||
|
||||
/**
|
||||
* Writes one structured JSON line per audit entry to stdout. A log shipper
|
||||
* (Vector, Fluent Bit) picks these up and forwards to the centralized
|
||||
* aggregator (Grafana Cloud, Datadog, Loki EU, etc.).
|
||||
*
|
||||
* Lines include a `_type` discriminator so the shipper can route:
|
||||
* "audit" → audit entry
|
||||
* "audit-erasure" → GDPR erasure tombstone
|
||||
*
|
||||
* `eraseSubject` is best-effort: past stdout lines can't be retroactively
|
||||
* removed. The tombstone informs the downstream aggregator to filter/delete.
|
||||
*/
|
||||
export class StdoutJsonAuditLog implements IAuditLog {
|
||||
async record(entry: AuditEntry): Promise<void> {
|
||||
const serialized = JSON.stringify({
|
||||
_type: "audit",
|
||||
...entry,
|
||||
at: entry.at.toISOString(),
|
||||
});
|
||||
process.stdout.write(serialized + "\n");
|
||||
}
|
||||
|
||||
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
||||
const tombstone = {
|
||||
_type: "audit-erasure",
|
||||
actorId,
|
||||
mode,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(tombstone) + "\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { context, trace } from "@opentelemetry/api";
|
||||
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
|
||||
import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
||||
import { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "./audit-log.interface";
|
||||
|
||||
// Register async context manager so startActiveSpan propagates context.
|
||||
const ctxManager = new AsyncLocalStorageContextManager();
|
||||
ctxManager.enable();
|
||||
context.setGlobalContextManager(ctxManager);
|
||||
|
||||
function setupProvider(): { exporter: InMemorySpanExporter; provider: BasicTracerProvider } {
|
||||
const exporter = new InMemorySpanExporter();
|
||||
const provider = new BasicTracerProvider({
|
||||
spanProcessors: [new SimpleSpanProcessor(exporter)],
|
||||
});
|
||||
trace.setGlobalTracerProvider(provider);
|
||||
return { exporter, provider };
|
||||
}
|
||||
|
||||
const sample: AuditEntry = {
|
||||
actorId: "user_1",
|
||||
actorType: "user",
|
||||
actorRoles: [],
|
||||
action: "VIEW",
|
||||
resource: { type: "articles" },
|
||||
at: new Date(),
|
||||
scope: { feature: "blog", environment: "test", tenant: "default" },
|
||||
from: { ipTruncated: "10.0.0.0", userAgent: "test" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
};
|
||||
|
||||
function makeInner(): IAuditLog & { records: AuditEntry[] } {
|
||||
const records: AuditEntry[] = [];
|
||||
return {
|
||||
records,
|
||||
async record(e) {
|
||||
records.push(e);
|
||||
},
|
||||
eraseSubject: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("TraceIdEnrichingAuditLog", () => {
|
||||
let exporter: InMemorySpanExporter;
|
||||
let provider: BasicTracerProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
({ exporter, provider } = setupProvider());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await provider.shutdown();
|
||||
trace.disable();
|
||||
void exporter;
|
||||
});
|
||||
|
||||
it("passes through when no active span", async () => {
|
||||
const inner = makeInner();
|
||||
const wrapper = new TraceIdEnrichingAuditLog(inner);
|
||||
await wrapper.record(sample);
|
||||
expect(inner.records[0]!.correlationId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("auto-populates correlationId from active span", async () => {
|
||||
const inner = makeInner();
|
||||
const wrapper = new TraceIdEnrichingAuditLog(inner);
|
||||
const tracer = trace.getTracer("test");
|
||||
await new Promise<void>((resolve) => {
|
||||
tracer.startActiveSpan("test", async (span) => {
|
||||
await wrapper.record(sample);
|
||||
const expected = span.spanContext().traceId;
|
||||
expect(inner.records[0]!.correlationId).toBe(expected);
|
||||
span.end();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("explicit correlationId wins over auto-populated", async () => {
|
||||
const inner = makeInner();
|
||||
const wrapper = new TraceIdEnrichingAuditLog(inner);
|
||||
const tracer = trace.getTracer("test");
|
||||
await new Promise<void>((resolve) => {
|
||||
tracer.startActiveSpan("test", async (span) => {
|
||||
await wrapper.record({ ...sample, correlationId: "explicit-trace-id" });
|
||||
expect(inner.records[0]!.correlationId).toBe("explicit-trace-id");
|
||||
span.end();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("eraseSubject passes through unchanged", async () => {
|
||||
const eraseSpy = vi.fn();
|
||||
const inner: IAuditLog = { record: vi.fn(), eraseSubject: eraseSpy };
|
||||
const wrapper = new TraceIdEnrichingAuditLog(inner);
|
||||
await wrapper.eraseSubject("user_1", "delete");
|
||||
expect(eraseSpy).toHaveBeenCalledWith("user_1", "delete");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import { currentTraceId } from "@repo/core-shared/instrumentation";
|
||||
import type { IAuditLog } from "./audit-log.interface";
|
||||
|
||||
/**
|
||||
* Decorates any IAuditLog by auto-populating AuditEntry.correlationId from
|
||||
* the active OTel span (when present and the caller didn't supply a value).
|
||||
* Caller-supplied correlationId always wins — explicit > implicit.
|
||||
*
|
||||
* Applied at bind time by bindAudit so all sinks see entries with
|
||||
* correlationId already set. Single source of truth for the OTel-audit bridge.
|
||||
*/
|
||||
export class TraceIdEnrichingAuditLog implements IAuditLog {
|
||||
constructor(readonly inner: IAuditLog) {}
|
||||
|
||||
async record(entry: AuditEntry): Promise<void> {
|
||||
if (entry.correlationId) {
|
||||
return this.inner.record(entry);
|
||||
}
|
||||
const traceId = currentTraceId();
|
||||
if (!traceId) {
|
||||
return this.inner.record(entry);
|
||||
}
|
||||
return this.inner.record({ ...entry, correlationId: traceId });
|
||||
}
|
||||
|
||||
eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
||||
return this.inner.eraseSubject(actorId, mode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["core"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { mergeConfig } from "vitest/config";
|
||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
||||
|
||||
export default mergeConfig(nodeVitestConfig, {
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
# @repo/core-consent
|
||||
|
||||
Optional core package providing a vendor-neutral consent management interface. Scaffold via `pnpm turbo gen core-package consent`.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
consent-types.ts # ConsentCategory, ConsentState, UserConsentState
|
||||
consent.interface.ts # IConsent — isGranted, grant, withdraw, getCategories
|
||||
with-consent.ts # withConsent wrapper attaching ConsentChecked brand
|
||||
index.ts # Barrel export
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
`IConsent` exposes four methods:
|
||||
|
||||
- `isGranted(category)` — synchronous check whether consent is granted
|
||||
- `grant(category)` — record consent grant for a category
|
||||
- `withdraw(category)` — record consent withdrawal for a category
|
||||
- `getCategories()` — list all known consent states
|
||||
|
||||
The interface is vendor-neutral: no storage implementation is bundled here. Concrete implementations (e.g. a Payload-backed store) are wired at DI bind time in `bind-production`.
|
||||
|
||||
`withConsent` wraps a use-case factory at bind time, attaches the `__consentChecked` brand, and is the innermost wrapper in the composition chain:
|
||||
`withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps)`
|
||||
|
||||
See `docs/architecture/agent-first-workflow-and-conformance.md` for the dependency-injection conventions.
|
||||
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@repo/core-consent",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/core-shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// placeholder — populated by story 03-core-consent-foundation
|
||||
export {};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["core"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { mergeConfig } from "vitest/config";
|
||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
||||
|
||||
export default mergeConfig(nodeVitestConfig, {
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
});
|
||||
32
turbo/generators/templates/core-package/dsr/AGENTS.md.hbs
Normal file
32
turbo/generators/templates/core-package/dsr/AGENTS.md.hbs
Normal file
@@ -0,0 +1,32 @@
|
||||
# @repo/core-dsr
|
||||
|
||||
Optional core package providing GDPR Data Subject Rights (DSR) interfaces and implementations. Scaffold via `pnpm turbo gen core-package dsr`.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
data-export.interface.ts # IDataExport — exportSubjectData
|
||||
data-delete.interface.ts # IDataDelete — deleteSubjectData
|
||||
data-rectify.interface.ts # IDataRectify — updateSubjectField
|
||||
processing-restriction.interface.ts # IProcessingRestriction — setRestriction, isRestricted
|
||||
dsr-types.ts # UserDataBundle, DeletionCertificate, DSR value types
|
||||
contexts/
|
||||
user-data.jsonld # schema.org JSON-LD @context
|
||||
index.ts # Barrel export
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
Four interfaces map directly to GDPR Articles 15–18 + 20:
|
||||
|
||||
- `IDataExport` (Art. 15/20) — export a subject's data as `UserDataBundle`
|
||||
- `IDataDelete` (Art. 17) — soft-delete or cascade-hard-delete subject data; returns `DeletionCertificate`
|
||||
- `IDataRectify` (Art. 16) — update a specific field for a subject
|
||||
- `IProcessingRestriction` (Art. 18) — toggle and read the processing restriction flag
|
||||
|
||||
Implementations walk `custom.pii`-tagged fields and `custom.subject`-linked collections. Row semantics:
|
||||
- `kind: "self" | "owner"` → directly owned by the subject (export full; delete hard or soft)
|
||||
- `kind: "reference"` → references the subject from another entity (export redacted; redact link on delete)
|
||||
|
||||
See `docs/architecture/agent-first-workflow-and-conformance.md` for the DI conventions.
|
||||
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
27
turbo/generators/templates/core-package/dsr/package.json.hbs
Normal file
27
turbo/generators/templates/core-package/dsr/package.json.hbs
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@repo/core-dsr",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// placeholder — populated by story 06-core-dsr
|
||||
export {};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["core"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { mergeConfig } from "vitest/config";
|
||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
||||
|
||||
export default mergeConfig(nodeVitestConfig, {
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
# @repo/core-events
|
||||
|
||||
Owns the cross-feature event bus: `IEventBus`, `defineEvent`, and two implementations (`InMemoryEventBus`, `PayloadJobsEventBus`).
|
||||
|
||||
**Boundary tag:** core. May be imported by feature, core, core-composition, app. May import from core-shared, tooling.
|
||||
|
||||
**Public surface:** `IEventBus`, `EventDescriptor`, `defineEvent`, `EventHandler`, `CORE_EVENTS_SYMBOLS`, both implementations.
|
||||
|
||||
**See:** `docs/decisions/adr-015-events-and-jobs.md` (pending), `docs/guides/events-and-jobs.md` (pending), `docs/superpowers/specs/2026-05-08-events-and-jobs-design.md`.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
package: zod
|
||||
version: "^3.23.0"
|
||||
tier: core
|
||||
decision: approved
|
||||
date: 2026-05-14
|
||||
deciders: [scaffolded]
|
||||
adr: adr-015
|
||||
filter-results:
|
||||
license: MIT
|
||||
types: native
|
||||
maintenance: active
|
||||
boundary-fit: pass
|
||||
shadow-check: pass
|
||||
eu-residency: n/a
|
||||
cve-scan: clean
|
||||
named-consumer: pass
|
||||
verification-commands:
|
||||
- pnpm audit --audit-level=moderate
|
||||
- npm view zod license
|
||||
accepted-cves: []
|
||||
---
|
||||
|
||||
## Filter: license
|
||||
|
||||
MIT — on the workspace allowlist.
|
||||
|
||||
## Filter: types
|
||||
|
||||
Ships first-party TypeScript types in its distribution (`.d.ts` included).
|
||||
|
||||
## Filter: maintenance
|
||||
|
||||
Active. Regular releases by Colin McDonnell; widely adopted.
|
||||
|
||||
## Filter: boundary-fit
|
||||
|
||||
Core package. Zod is the workspace-canonical validation library locked in `core-shared` (ADR-015).
|
||||
|
||||
## Filter: shadow-check
|
||||
|
||||
Zod is already the workspace-locked validation library. No shadow.
|
||||
|
||||
## Filter: eu-residency
|
||||
|
||||
Pure computation; no network calls or vendor data transmission. n/a.
|
||||
|
||||
## Filter: cve-scan
|
||||
|
||||
No advisories at adoption time.
|
||||
|
||||
## Filter: named-consumer
|
||||
|
||||
`core-events` uses zod for event-descriptor payload schemas.
|
||||
|
||||
## Prompt: replaces
|
||||
|
||||
Nothing — zod is the pre-existing workspace validation library.
|
||||
|
||||
## Prompt: migration-cost-out
|
||||
|
||||
Mechanical: swap schema definitions at call sites. No data-format lock-in.
|
||||
|
||||
## Prompt: alternatives-considered
|
||||
|
||||
Zod is workspace-locked (see `core-shared`). A replacement would require a workspace-wide ADR; no alternative was evaluated here.
|
||||
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@repo/core-events",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"payload": { "optional": true }
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { z } from "zod";
|
||||
import type { EventBusProtocol } from "@repo/core-shared/di/bind-protocols";
|
||||
import type { EventDescriptor } from "./event-descriptor";
|
||||
|
||||
export type EventHandler<T> = (event: T) => Promise<void>;
|
||||
|
||||
export interface IEventBus extends EventBusProtocol {
|
||||
publish<T>(
|
||||
descriptor: EventDescriptor<string, z.ZodType<T>>,
|
||||
payload: T,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Subscribe a handler. `consumerFeature` is the kebab-case name of the
|
||||
* subscribing feature (e.g., "marketing-pages"). It is unused by
|
||||
* InMemoryEventBus; PayloadJobsEventBus uses it to name the fan-out task
|
||||
* slug deterministically (`__events.<event>.<consumerFeature>`).
|
||||
*/
|
||||
subscribe<T>(
|
||||
descriptor: EventDescriptor<string, z.ZodType<T>>,
|
||||
consumerFeature: string,
|
||||
handler: EventHandler<T>,
|
||||
): void;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { defineEvent } from "@/event-descriptor";
|
||||
|
||||
describe("defineEvent", () => {
|
||||
it("returns a descriptor with name and schema", () => {
|
||||
const schema = z.object({ id: z.string() }).strict();
|
||||
const descriptor = defineEvent("test.thing.happened", schema);
|
||||
expect(descriptor.name).toBe("test.thing.happened");
|
||||
expect(descriptor.schema).toBe(schema);
|
||||
});
|
||||
|
||||
it("descriptor.schema parses valid payloads", () => {
|
||||
const schema = z.object({ id: z.string() }).strict();
|
||||
const d = defineEvent("test.evt", schema);
|
||||
expect(() => d.schema.parse({ id: "abc" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("descriptor.schema rejects invalid payloads", () => {
|
||||
const schema = z.object({ id: z.string() }).strict();
|
||||
const d = defineEvent("test.evt", schema);
|
||||
expect(() => d.schema.parse({ id: 123 })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
export type EventDescriptor<TName extends string, TSchema extends z.ZodType> = {
|
||||
readonly name: TName;
|
||||
readonly schema: TSchema;
|
||||
};
|
||||
|
||||
export function defineEvent<TName extends string, TSchema extends z.ZodType>(
|
||||
name: TName,
|
||||
schema: TSchema,
|
||||
): EventDescriptor<TName, TSchema> {
|
||||
return { name, schema };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { defineEvent } from "@/event-descriptor";
|
||||
import { InMemoryEventBus } from "@/in-memory-event-bus";
|
||||
|
||||
const evt = defineEvent("test.thing", z.object({ id: z.string() }).strict());
|
||||
|
||||
describe("InMemoryEventBus", () => {
|
||||
it("validates the payload via the descriptor's schema before fanout", async () => {
|
||||
const bus = new InMemoryEventBus();
|
||||
const handler = vi.fn();
|
||||
bus.subscribe(evt, "test-consumer", handler);
|
||||
await expect(bus.publish(evt, { id: 123 } as unknown as { id: string })).rejects.toThrow();
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers to all registered handlers in parallel", async () => {
|
||||
const bus = new InMemoryEventBus();
|
||||
const a = vi.fn();
|
||||
const b = vi.fn();
|
||||
bus.subscribe(evt, "consumer-a", a);
|
||||
bus.subscribe(evt, "consumer-b", b);
|
||||
await bus.publish(evt, { id: "x" });
|
||||
expect(a).toHaveBeenCalledWith({ id: "x" });
|
||||
expect(b).toHaveBeenCalledWith({ id: "x" });
|
||||
});
|
||||
|
||||
it("swallows handler errors by default (publisher's publish does not throw)", async () => {
|
||||
const bus = new InMemoryEventBus();
|
||||
bus.subscribe(evt, "boom", async () => {
|
||||
throw new Error("subscriber blew up");
|
||||
});
|
||||
await expect(bus.publish(evt, { id: "x" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rethrows the first handler error when failFast is true", async () => {
|
||||
const bus = new InMemoryEventBus({ failFast: true });
|
||||
bus.subscribe(evt, "first", async () => {
|
||||
throw new Error("first failure");
|
||||
});
|
||||
bus.subscribe(evt, "second", vi.fn());
|
||||
await expect(bus.publish(evt, { id: "x" })).rejects.toThrow("first failure");
|
||||
});
|
||||
|
||||
it("delivers nothing when no handlers are registered", async () => {
|
||||
const bus = new InMemoryEventBus();
|
||||
await expect(bus.publish(evt, { id: "x" })).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { z } from "zod";
|
||||
import type { EventDescriptor } from "./event-descriptor";
|
||||
import type { EventHandler, IEventBus } from "./event-bus.interface";
|
||||
|
||||
export type InMemoryEventBusOptions = {
|
||||
/** When true, rethrow the first handler error (default: false — errors swallowed). */
|
||||
failFast?: boolean;
|
||||
};
|
||||
|
||||
export class InMemoryEventBus implements IEventBus {
|
||||
private readonly handlers = new Map<string, EventHandler<unknown>[]>();
|
||||
|
||||
constructor(private readonly options: InMemoryEventBusOptions = {}) {}
|
||||
|
||||
async publish<T>(
|
||||
descriptor: EventDescriptor<string, z.ZodType<T>>,
|
||||
payload: T,
|
||||
): Promise<void> {
|
||||
descriptor.schema.parse(payload);
|
||||
const subscribers = this.handlers.get(descriptor.name) ?? [];
|
||||
if (subscribers.length === 0) return;
|
||||
const settled = await Promise.allSettled(
|
||||
subscribers.map((h) => h(payload)),
|
||||
);
|
||||
if (this.options.failFast) {
|
||||
const failure = settled.find((s) => s.status === "rejected");
|
||||
// Only the first rejection is rethrown. Other failures are intentionally
|
||||
// dropped — `failFast` is a test-affordance, not a fault-tolerance design.
|
||||
if (failure && failure.status === "rejected") throw failure.reason;
|
||||
}
|
||||
}
|
||||
|
||||
subscribe<T>(
|
||||
descriptor: EventDescriptor<string, z.ZodType<T>>,
|
||||
_consumerFeature: string,
|
||||
handler: EventHandler<T>,
|
||||
): void {
|
||||
const arr = this.handlers.get(descriptor.name) ?? [];
|
||||
arr.push(handler as EventHandler<unknown>);
|
||||
this.handlers.set(descriptor.name, arr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { EventDescriptor } from "./event-descriptor";
|
||||
export { defineEvent } from "./event-descriptor";
|
||||
export type { IEventBus, EventHandler } from "./event-bus.interface";
|
||||
export { CORE_EVENTS_SYMBOLS } from "./symbols";
|
||||
export { InMemoryEventBus, type InMemoryEventBusOptions } from "./in-memory-event-bus";
|
||||
export { PayloadJobsEventBus } from "./payload-jobs-event-bus";
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { defineEvent } from "@/event-descriptor";
|
||||
import { PayloadJobsEventBus } from "@/payload-jobs-event-bus";
|
||||
import type { IJobQueue } from "@repo/core-shared/jobs";
|
||||
|
||||
const evt = defineEvent("auth.user.signed-up", z.object({ userId: z.string() }).strict());
|
||||
|
||||
function recordingQueue(): IJobQueue & { enqueued: { taskSlug: string; input: unknown }[] } {
|
||||
const enqueued: { taskSlug: string; input: unknown }[] = [];
|
||||
const q: IJobQueue = {
|
||||
async enqueue(taskSlug, input) {
|
||||
enqueued.push({ taskSlug, input });
|
||||
return { jobId: `recording-${enqueued.length}` };
|
||||
},
|
||||
};
|
||||
return Object.assign(q, { enqueued });
|
||||
}
|
||||
|
||||
describe("PayloadJobsEventBus", () => {
|
||||
it("validates the payload before enqueueing", async () => {
|
||||
const queue = recordingQueue();
|
||||
const bus = new PayloadJobsEventBus(queue);
|
||||
bus.subscribe(evt, "marketing-pages", vi.fn());
|
||||
await expect(
|
||||
bus.publish(evt, { userId: 42 } as unknown as { userId: string }),
|
||||
).rejects.toThrow();
|
||||
expect(queue.enqueued).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("enqueues one task per subscriber, naming `__events.<event>.<consumer>`", async () => {
|
||||
const queue = recordingQueue();
|
||||
const bus = new PayloadJobsEventBus(queue);
|
||||
bus.subscribe(evt, "marketing-pages", vi.fn());
|
||||
bus.subscribe(evt, "blog", vi.fn());
|
||||
await bus.publish(evt, { userId: "u1" });
|
||||
expect(queue.enqueued).toHaveLength(2);
|
||||
expect(queue.enqueued.map((e) => e.taskSlug).sort()).toEqual([
|
||||
"__events.auth.user.signed-up.blog",
|
||||
"__events.auth.user.signed-up.marketing-pages",
|
||||
]);
|
||||
expect(queue.enqueued[0]!.input).toEqual({ userId: "u1" });
|
||||
});
|
||||
|
||||
it("enqueues nothing when no subscribers are registered", async () => {
|
||||
const queue = recordingQueue();
|
||||
const bus = new PayloadJobsEventBus(queue);
|
||||
await bus.publish(evt, { userId: "u1" });
|
||||
expect(queue.enqueued).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { z } from "zod";
|
||||
import type { IJobQueue } from "@repo/core-shared/jobs";
|
||||
import type { EventDescriptor } from "./event-descriptor";
|
||||
import type { EventHandler, IEventBus } from "./event-bus.interface";
|
||||
|
||||
/**
|
||||
* Production-grade bus: for each subscriber, enqueues one Payload task per
|
||||
* `publish()` call. Subscribers register with their consumer-feature name so
|
||||
* fan-out tasks are named deterministically: `__events.<event>.<consumer>`.
|
||||
* The actual handler invocation happens inside Payload's job runner — see the
|
||||
* matching task config generated by `gen event consume` (Task 39).
|
||||
*/
|
||||
export class PayloadJobsEventBus implements IEventBus {
|
||||
private readonly subscribers = new Map<string, string[]>();
|
||||
|
||||
constructor(private readonly queue: IJobQueue) {}
|
||||
|
||||
async publish<T>(
|
||||
descriptor: EventDescriptor<string, z.ZodType<T>>,
|
||||
payload: T,
|
||||
): Promise<void> {
|
||||
descriptor.schema.parse(payload);
|
||||
const consumers = this.subscribers.get(descriptor.name) ?? [];
|
||||
await Promise.all(
|
||||
consumers.map((consumerFeature) =>
|
||||
this.queue.enqueue(
|
||||
`__events.${descriptor.name}.${consumerFeature}`,
|
||||
payload,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
subscribe<T>(
|
||||
descriptor: EventDescriptor<string, z.ZodType<T>>,
|
||||
consumerFeature: string,
|
||||
_handler: EventHandler<T>,
|
||||
): void {
|
||||
const arr = this.subscribers.get(descriptor.name) ?? [];
|
||||
if (!arr.includes(consumerFeature)) arr.push(consumerFeature);
|
||||
this.subscribers.set(descriptor.name, arr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const CORE_EVENTS_SYMBOLS = {
|
||||
IEventBus: Symbol.for("@repo/core-events/IEventBus"),
|
||||
} as const;
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["core"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user