Skills Celuga
celuga-astro-payload-cache
Cache à tags, invalidation durable, SWR, déduplication, globals et 404 partagés, preview hors cache.
Quand : Cache à tags, invalidation durable, SWR, déduplication, globals et 404 partagés, preview hors cache.
Portée : machine (~/.agents/skills) · Fichiers : 9
Tout installer
Section intitulée « Tout installer »Copier le bloc et le coller dans un terminal. Il recrée tous les fichiers du skill.
dir="$HOME/.agents/skills/celuga-astro-payload-cache"mkdir -p "$dir"cat > "$dir/SKILL.md" <<'CELUGA_SKILL_EOF'---name: celuga-astro-payload-cachedescription: Concevoir, porter ou diagnostiquer le cache à tags Astro SSR avec Payload séparé — tags dérivés du registre, invalidation durable, SWR, déduplication par requête, globals et 404 partagés, page builder SSR, preview hors cache, compression.---
# Cache et rendu Astro + Payload
Chaque sortie publique porte les causes Payload qui peuvent changer son HTML ; les hooks émettent ces mêmes tags. Un hit du cache de route évite le fetch et le SSR ; la déduplication par requête évite seulement les fetchs identiques pendant un miss.
Vérifier les API de la version d'Astro installée et le graphe réel du projet avant d'adapter. Un audit ne demande ni déploiement ni mutation de base.
| Besoin | Lire || --- | --- || responsabilités et fichiers | [architecture](references/architecture.md) || tags, hooks, secret, endpoint | [contrat de cache](references/cache-contract.md) || blocs, îlots et preview | [rendu et preview](references/rendering-preview.md) || mono-instance, CDN, compression | [runtime](references/runtime.md) || adaptation et preuves | [portage](references/porting-checklist.md) |
## Auditer avant d'adapter
```bashbun <skill-dir>/scripts/audit-astro-payload.mjs <project-root>```
Ses fixtures négatives : `bun <skill-dir>/scripts/test-audit.mjs <projet-conforme>`. L'audit vise la disposition cible et échoue si une pièce manque ; il ne prouve pas l'exécution. Pour une autre arborescence, adapter sa carte de fichiers après avoir trouvé les consommateurs réels.
Relever : fournisseur et nombre d'instances, query params qui changent le HTML, routes publiques/privées/preview, statut publié, anciens chemins, relations et dépendances secondaires des blocs.
## Implémenter le contrat causal
- Tags dérivés du **registre partagé** : un contenu public déclare `identity` et `aggregate`, et `tagsFor` donne l'invalidation, ancien et nouveau slug compris. Ajouter tags consommés **et** émetteurs ensemble. Une source inconnue lève une erreur plutôt que de passer en silence.- Dépendance d'un bloc déclarée au catalogue (`cache: [...]`) ; le chargeur l'ajoute aux tags de la page.- Secret : une clé `cache` dérivée de `SITE_SECRET`, jamais `PAYLOAD_SECRET`. Endpoint : POST bearer comparé à temps constant **avant** de lire le corps, liste de tags validée, réponses `no-store`.- `cache.set({ ...pageCache, tags })` dans le fichier de `pages/`, après résolution du document. Dans un composant enfant, l'appel n'a pas d'effet fiable. Pas de règle globale qui capturerait une erreur.- Durées : quelques minutes de `maxAge` pour couvrir les parutions programmées, `swr` d'un jour pour ne jamais faire attendre le CMS à un visiteur.- Invalidation **durable** : une tâche Payload dans la transaction du contenu, plus un envoi direct non attendu pour les lecteurs actifs. Un site arrêté ne ralentit pas une sauvegarde.- Un autosave n'invalide rien ; une dépublication invalide. Un bloc en échec rend la réponse non cachable.- Panne : page d'erreur autonome 503 `no-store`, `noindex`, `Retry-After`, sans dépendance au CMS ni détail technique.
## Vérifier le résultat
Tests purs du graphe, du secret et de la déduplication, puis contrôles du dépôt. Sur un build de production autorisé : `MISS → HIT`, invalidation ciblée, page sans tag commun toujours HIT, rename, dépublication, preview hors cache, panne sur page froide. Une sauvegarde relance l'invalidation environ 2 s après : attendre avant de mesurer un second scénario. Tester aussi depuis l'administration : un test unitaire peut passer alors que le bug persiste.
`memoryCache` suppose une seule instance ; plusieurs réplicas ou un CDN HTML exigent une purge coordonnée ou un fournisseur partagé.CELUGA_SKILL_EOFmkdir -p "$dir/agents"cat > "$dir/agents/openai.yaml" <<'CELUGA_SKILL_EOF'interface: display_name: "Celuga Astro + Payload Cache" short_description: "Cache Astro à tags, invalidation Payload, rendu SSR-first et preview" default_prompt: "Utilise $celuga-astro-payload-cache pour auditer puis implémenter le contrat de cache et de rendu Astro SSR + Payload de ce projet."CELUGA_SKILL_EOFmkdir -p "$dir/references"cat > "$dir/references/architecture.md" <<'CELUGA_SKILL_EOF'# Architecture et sources
## Frontières
`apps/cms` porte Payload, PostgreSQL, les médias, l'administration et les hooks. `apps/web` porte Astro SSR, le HTML public, la preview, les proxys et les endpoints front. `packages/site-contracts` contient le registre et les fonctions partagées ; le code Node y reste sous `server/`.
## Où regarder dans la disposition cible
| Responsabilité | Fichier || --- | --- || registre des contenus publics, capacités de cache | `packages/site-contracts/src/collections.ts`, `public-content.ts` || tags autorisés, tags par source | `packages/site-contracts/src/cache.ts` (`validTags`, `tagsFor`) || dépendances de cache des blocs | `packages/site-contracts/src/content.ts` (`blockCatalog[…].cache`) || dérivation des clés | `packages/site-contracts/src/server/site-secret.ts` || secret et URL côté CMS | `apps/cms/src/lib/env.ts` || causes de publication, tâche durable | `apps/cms/src/hooks/content.ts` || fournisseur et clés de query | `apps/web/astro.config.mjs` || durées | `apps/web/src/lib/page-cache.ts` || chargement document + chrome, tags de page | `apps/web/src/lib/content-page.ts` || transport, déduplication | `apps/web/src/lib/cms.ts` || globals et 404 partagés entre requêtes | `apps/web/src/lib/shared-globals.server.ts`, `not-found-page.server.ts` || préparation des blocs | `apps/web/src/lib/page-builder.ts` || rendu public / preview | `apps/web/src/components/RenderBlocks/index.astro`, `pages/preview.astro` || endpoint d'invalidation | `apps/web/src/pages/api/cache/invalidate.ts` || portée de requête, panne, en-têtes, compression | `apps/web/src/middleware.ts` |
## Requête publique
1. Le fournisseur cherche la clé route + query retenues + encodage (`Vary`). Un hit ne refait ni SSR ni fetch.2. Sur miss, le middleware ouvre la Map de Promises de la requête. Redirection, document et chrome sont lus en parallèle ; les URL identiques sont dédupliquées.3. Les blocs sont préparés. Un échec reste limité au bloc et rend la réponse non cachable.4. La route pose le cache et rend le HTML. Une exception remonte au middleware, qui répond 503 `no-store`.
## Publication
Le hook ignore les autosaves, calcule les tags, met en file la tâche durable dans la transaction, tente aussi un envoi direct non attendu, puis réveille la boucle de fond. Le prochain GET d'une entrée portant un tag commun est un miss. Comme l'envoi direct peut précéder le commit, l'endpoint vide aussi les caches internes et ouvre une fenêtre de contournement de quelques secondes, et la tâche repasse après le commit.
Le graphe lie une **sortie cachée** à ses **sources**. Une collection publique sans consommateur caché n'exige pas d'invalidation par principe ; mais `tagsFor` doit la connaître si elle porte le hook.CELUGA_SKILL_EOFmkdir -p "$dir/references"cat > "$dir/references/cache-contract.md" <<'CELUGA_SKILL_EOF'# Contrat de cache
## Graphe type
Les tags représentent une cause de changement. L'invalidation utilise l'union : un seul tag commun suffit. Lire `cache.ts` du projet pour la liste réelle.
| Sortie | Tags || --- | --- || accueil (global) | `<identité>`, `globals`, `media`, `forms`, `redirects`, dépendances des blocs || document | `<identité>:<locale/>slug`, `globals`, `media`, `forms`, `redirects`, dépendances des blocs || liste d'un module | `<agrégat>`, `globals`, `media` || sitemap, llms.txt | `sitemap` (et `globals` pour llms.txt) |
| Mutation | Tags émis || --- | --- || collection publique | `sitemap`, `globals`, `<agrégat>`, ancien et nouveau `<identité>:<slug>` || global public | `<identité>`, `sitemap` || configuration, en-tête, pied de page | `globals`, agrégats, `sitemap` || média, document | `media`, `sitemap` || formulaire, redirection | `forms`, `redirects` || taxonomie liée | `globals`, agrégats concernés, `sitemap` || collection lue par un bloc | son tag, plus `globals` si le chrome la lit || index de recherche | aucun : lu à la requête, jamais caché |
Le tag porte la locale : `page:en/contact`, `page:contact` pour la langue principale. Une locale manquante purge la mauvaise entrée.
## Fournisseur et décision de route
```jscache: { provider: memoryCache({ max: 1000, query: { include: ["page", "<filtre>"] } }),}```
`max` borne la mémoire. Tout paramètre qui modifie le HTML entre dans `include` ; les autres sont ignorés, donc un scan d'URL ne remplit pas le cache. Une 404 n'est pas stockée par route : voir plus bas.
```tsexport const pageCache = { maxAge: 300, swr: 86_400 };```
`maxAge` couvre les parutions programmées et sert de filet si une invalidation se perd. Passé ce délai, le visiteur reçoit la version en cache et la suivante se prépare en arrière-plan ; une page sans visite un jour entier est de nouveau rendue à la demande.
Les miss et renouvellements simultanés ne sont pas regroupés : `memoryCache` appelle `next()` à chaque visite, et regrouper les appels au CMS lirait des réponses antérieures à une publication. C'est acceptable à trafic faible ; le mesurer avant de prétendre tenir une charge.
## Caches internes au Web
- **Globals publiés** (`shared-globals.server.ts`) : le corps JSON se partage entre requêtes 60 s, jamais les brouillons. Stocker le texte et non l'objet : chaque requête parse sa copie et peut la modifier.- **Page 404** (`not-found-page.server.ts`) : une entrée par langue, une heure, conservée par le middleware après rendu. Le cache de route créerait une entrée par adresse scannée. Un rendu dégradé est abandonné.- Tous deux sont vidés par l'endpoint, avec une fenêtre de contournement de 10 s, car le CMS peut invalider avant son commit.
## Secret et endpoint
`deriveSiteSecret(master, purpose)` : HMAC-SHA-256 de `celuga:<usage>:v1`, 32 caractères minimum, clés distinctes pour `cache`, `preview`, `form`. Une clé ne permet pas d'en déduire une autre. Aucun repli sur `PAYLOAD_SECRET`.
Ordre de `POST /api/cache/invalidate` :
1. `cache.set(false)` ;2. comparaison à temps constant du bearer **avant** la lecture du corps ; 401 sinon ;3. JSON invalide ou `validTags` refusé : 400 (tableau non vide, 100 tags au plus, tags statiques ou préfixes d'identité connus) ;4. vidage des caches internes, `await cache.invalidate({ tags })` ;5. réponses `no-store`.
Pas de secret en query ni de CORS sur ce canal serveur à serveur.
## Hooks
- `leavesPublicContentUntouched` : `_status === "draft"` et `String(req.query?.draft) === "true"` ne touche pas au public (autosave). Une dépublication arrive sans ce paramètre et invalide.- Changement de slug publié : redirection créée ou mise à jour dans la transaction, en supprimant toute redirection dont la source devient la nouvelle adresse (pas de boucle).- La tâche `invalidateCache` est mise en file avec `req` : seule une écriture validée devient visible par la boucle de fond. Reprises bornées avec backoff exponentiel.- L'envoi direct n'est pas attendu et un échec est seulement journalisé : un site arrêté ne bloque ni ne ralentit une publication.- Un drapeau de contexte (`skipInvalidation`) coupe tout pour les scripts.
## Déduplication par requête
`AsyncLocalStorage<Map<string, Promise<unknown>>>` ouvert par le middleware. Le transport met la Promise en cache dès son lancement, clé URL complète. Ne pas déplacer la Map au niveau module. Ne pas appliquer cette clé à des appels privés ou à des mutations.
## Preuves ciblées
Tester les ensembles exacts de tags, les deux identités, draft seul, suppression, opt-out, secrets et 401/400/200, déduplication concurrente et isolation entre requêtes. En production de test : `MISS → HIT`, purge ciblée, page sans tag commun encore HIT, preview hors cache, page froide 503 puis retour correct après reprise. Pour forcer un miss sans secret, envoyer un `Accept-Encoding` unique (`gzip, br;q=0.<n>`). Le script statique ne prouve pas ces transitions.CELUGA_SKILL_EOFmkdir -p "$dir/references"cat > "$dir/references/porting-checklist.md" <<'CELUGA_SKILL_EOF'# Porter et vérifier
## Cartographie
Identifier versions installées, routes, statuts, locales, fournisseur, réplicas et éventuel CDN. Pour chaque sortie, noter sources, paramètres qui changent le HTML, personnalisation, cache et indexation. Pour chaque source consommée, retrouver son événement d'invalidation.
Les slugs et domaines varient selon les projets : adapter descripteurs, consommateurs et hooks ensemble. Le template du générateur `create-celuga` et un projet récent sont de meilleures sources à copier qu'un extrait figé : lire leurs fichiers listés dans [architecture](architecture.md).
## Adaptation
1. Poser le registre et `cache.ts` dans le package de contrats, avec la dérivation du secret.2. Laisser chaque runtime lire son environnement : `lib/env.ts` côté CMS, `lib/env.server.ts` côté Web.3. Garder les lectures publiques publiées, sans credentials ; passer par le transport commun avec déduplication. Une URL seule n'identifie pas une mutation ou une requête authentifiée.4. Choisir les clés de query et le fournisseur selon la topologie. Poser le cache après validation des données, dans le fichier de `pages/`.5. Couvrir ancienne et nouvelle identité, autosave, dépublication, suppression et relations. Mettre l'invalidation en tâche durable dans la transaction.6. Réutiliser préparation et rendu de blocs côté public et preview. Déclarer chaque source secondaire au catalogue.7. Preview : opt-out du cache, en-têtes, URL navigateur publique, jeton limité à un document.
## Recette proportionnée
Contrôles locaux : `bun test`, `bun run verify` ou équivalent. Ne pas lancer de migration simplement pour documenter un projet.
| Scénario | Preuve attendue dans un environnement autorisé || --- | --- || page publique | première requête MISS, seconde HIT, aucun fetch sur hit || dépendance ciblée | consommateur MISS, page sans tag commun toujours HIT || rename | ancienne et nouvelle identité purgées ; 301 || dépublication, suppression | fiche purgée ; liste et sitemap purgés || autosave de brouillon | aucune invalidation, depuis l'administration réelle || brouillon jamais publié | aucune invalidation publique || bloc en échec | bloc dégradé, page exploitable, réponse non stockée || global, média | consommateurs concernés purgés || preview | jamais HIT, `no-store`, `noindex`, mise à jour live || mauvais bearer, JSON ou tag invalide | 401, 400, 400 et aucune invalidation || Payload arrêté | page froide 503 non stockée ; premier GET correct après reprise || site arrêté pendant une publication | publication réussie ; invalidation reprise par la tâche || plusieurs processus | purge prouvée sur chacun, ou fournisseur partagé |
Pour couper le CMS, viser le port (`lsof -iTCP:<port> -sTCP:LISTEN`) : un motif trop large tue aussi le serveur Web connecté.
Une lecture de code et un audit statique sont des preuves limitées : indiquer ce qui a effectivement été exécuté. Une preuve de publication en production exige l'autorisation correspondant à cette mutation.
## Incertitudes
Continuer les parties indépendantes quand un nom ou un chemin diffère. Si le fournisseur cible ne fournit pas l'API requise ou si un test dépend d'identifiants indisponibles, livrer le vérifié et nommer le blocage précis.CELUGA_SKILL_EOFmkdir -p "$dir/references"cat > "$dir/references/rendering-preview.md" <<'CELUGA_SKILL_EOF'# Rendu SSR et live preview
## Blocs
- `blockCatalog` des contrats définit slugs, libellés, groupes et dépendances de cache ; les modules y ajoutent les leurs.- `prepareBlocks(layout, load, locale, report)` charge les données annexes en dédupliquant les routes. Un bloc inconnu ou une lecture en échec donne `unavailable: true` : la page reste exploitable et n'est pas mise en cache.- `ContentBlock` choisit le rendu dans une table par slug, puis dans les rendus de modules. `RenderBlocks/index.astro` prépare ce qui exige le serveur (médias embarqués) et pose les îlots explicites.- Le composant de corps de page sert la page publique et la preview signée : pas de seconde logique de blocs.
## Îlots
| Surface | Rendu || --- | --- || blocs éditoriaux, FAQ native, chrome | SSR sans hydratation || formulaire, graphique | `client:visible` || consentement, si une mesure d'audience est configurée | `client:load` || live preview | `client:load` |
Les directives visent des composants statiquement importés dans un `.astro`. Un composant React SSR sans directive n'impose aucun JavaScript. HTML natif et CSS suffisent souvent aux FAQ, à la navigation et aux effets. Préserver un état initial lisible et `prefers-reduced-motion`.
## Preview
Une seule route `pages/preview.astro`, hors cache (`cache.set(false)`, `no-store`, `noindex`, `no-referrer`), sans JSON-LD ni consentement.
- **Avec jeton signé** (clé `preview` dérivée) : `verifyPreview` limite le jeton à un document et une langue ; le CMS sert le brouillon par un endpoint dédié, et le rendu est identique à la page publiée. Un jeton ne doit ouvrir aucun autre document.- **Sans jeton** : coquille non signée qui ne lit jamais de brouillon. Le document arrive seulement par le `postMessage` authentifié de l'administration, contrôlé sur l'origine, la source, la collection, la langue et l'identifiant.- `@payloadcms/live-preview` chargé à la demande. Les champs localisés sont convertis pour la population côté CMS : une nouvelle feuille localisée doit y être ajoutée. Requêtes périmées annulées, écouteur retiré au démontage.- URL publique du CMS dans le navigateur, jamais l'alias Docker ; vérifier CORS et CSRF. `frame-ancestors` autorise le CMS.- En preview, les formulaires n'envoient rien et le CAPTCHA est désactivé.
## Vérification
Inspecter le HTML initial : texte, titres, liens, chrome et médias avant JS. Un test de contrat prouve que chaque bloc du catalogue a une fixture et rend sans `astro-island`. Tester chaque interaction et la mise à jour live, y compris un document non enregistré.
Pour les médias, le SEO et l'ajout d'un bloc, appliquer les skills dédiés.CELUGA_SKILL_EOFmkdir -p "$dir/references"cat > "$dir/references/runtime.md" <<'CELUGA_SKILL_EOF'# Runtime et topologie
## Origines
| Nom | Consommateur || --- | --- || `PUBLIC_SITE_URL` | site public ; repli de l'appel CMS → Web || `PUBLIC_PAYLOAD_URL` | navigateur, live preview ; repli de l'appel Web → CMS || `PAYLOAD_INTERNAL_URL` | Astro serveur → CMS, prioritaire || `WEB_INTERNAL_URL` | CMS → Web pour l'invalidation, prioritaire || `SITE_SECRET` | secret partagé, dont dérive la clé d'invalidation |
Les `PUBLIC_*` sont incorporées au build ; les secrets restent serveur. Un alias privé propre au projet évite les collisions DNS d'un réseau partagé. Jamais d'alias privé dans le HTML, un média, la canonical ou l'iframe de preview.
## Niveaux de cache
- cache de route Astro : réponse complète et tags ; hit sans nouveau SSR ;- globals et 404 partagés dans le processus Web : durée courte, vidés à l'invalidation ;- cache de requête : Promises de fetch, durée d'un rendu ;- cache navigateur/CDN : directives HTTP et assets, indépendant des hooks Astro.
Une purge Astro ne purge pas un CDN HTML. Les réponses privées et la preview restent explicitement hors cache. Toute réponse d'état ≥ 400 et toute route privée reçoivent `no-store` dans le middleware.
## Compression
La réponse rendue est compressée **après** le cache de route par le middleware, avec `Vary: Accept-Encoding` : une entrée par encodage. Brotli qualité 6 sur le HTML : `CompressionStream("brotli")` force la qualité 11, bien plus coûteuse pour un gain minime. JS et CSS sont précompressés au build et servis par `server.mjs`, en immuable pour les fichiers versionnés. Les fichiers de `public/` reçoivent un cache d'un jour, sinon l'adaptateur les sert en `max-age=0`.
## Mono-instance et reprise
Le fournisseur mémoire est local à un processus ; un redémarrage le vide. Plusieurs réplicas, un déploiement progressif avec deux processus actifs ou du serverless exigent un fournisseur partagé ou une diffusion à toutes les instances. Le CMS aussi est prévu en instance unique, à cause de ses tâches de fond.
Pendant une panne Payload, une réponse chaude reste servie ; une page froide renvoie 503 `no-store`, jamais du contenu vide enregistré comme succès. Un mode maintenance lu dans le CMS ne bascule pas si le CMS est injoignable.
## Serveur
`server.mjs` démarre le gestionnaire de l'adaptateur Node sous Bun, précédé des fichiers précompressés. Avec `ASTRO_BUNDLE_DEPS=true` au build, l'image ne contient pas de `node_modules`. Dans un conteneur, `init: true` est nécessaire : sans lui, le serveur en processus 1 ignore SIGTERM.
## Vérifications
Depuis l'extérieur : état du cache, absence d'URL interne, règles HTML/CDN, preview et erreurs non stockées. Depuis la configuration de déploiement : nombre réel de réplicas, réseau et origines résolues. Le script d'audit ne connaît ni l'état de la plateforme ni les secrets effectifs.CELUGA_SKILL_EOFmkdir -p "$dir/scripts"cat > "$dir/scripts/audit-astro-payload.mjs" <<'CELUGA_SKILL_EOF'#!/usr/bin/env bun// Static evidence for the target Celuga layout, not a runtime/cache-correctness proof.import fs from 'node:fs'import path from 'node:path'
const args = process.argv.slice(2)const root = path.resolve(args.find((arg) => arg !== '--json') ?? '.')const json = args.includes('--json')const paths = { webPackage: 'apps/web/package.json', cmsPackage: 'apps/cms/package.json', config: 'apps/web/astro.config.mjs', tags: 'packages/site-contracts/src/cache.ts', registry: 'packages/site-contracts/src/collections.ts', secret: 'packages/site-contracts/src/server/site-secret.ts', env: 'apps/cms/src/lib/env.ts', hooks: 'apps/cms/src/hooks/content.ts', cms: 'apps/web/src/lib/cms.ts', loader: 'apps/web/src/lib/content-page.ts', durations: 'apps/web/src/lib/page-cache.ts', builder: 'apps/web/src/lib/page-builder.ts', middleware: 'apps/web/src/middleware.ts', endpoint: 'apps/web/src/pages/api/cache/invalidate.ts', preview: 'apps/web/src/pages/preview.astro', renderer: 'apps/web/src/components/RenderBlocks/index.astro',}const findings = []const source = {}const add = (level, id, message, evidence = []) => findings.push({ level, id, message, evidence })// Components may live in a folder (components/X/index.astro) or flat (components/X.astro).if (!fs.existsSync(path.join(root, paths.renderer))) paths.renderer = 'apps/web/src/components/RenderBlocks.astro'for (const [key, relative] of Object.entries(paths)) { const file = path.join(root, relative) if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { add('fail', `file-${key}`, 'Required target-layout file missing; map the actual project before auditing', [relative]) source[key] = '' } else source[key] = fs.readFileSync(file, 'utf8')}const check = (id, key, patterns, message) => { const ok = Boolean(source[key]) && patterns.every((pattern) => pattern.test(source[key])) add(ok ? 'ok' : 'fail', id, message, [paths[key]])}for (const [key, dependency] of [['webPackage', 'astro'], ['cmsPackage', 'payload']]) { try { const pkg = JSON.parse(source[key]) const version = pkg.dependencies?.[dependency] ?? pkg.devDependencies?.[dependency] add(version ? 'ok' : 'fail', `${dependency}-version`, version ? `${dependency}: ${version}` : `${dependency} dependency missing`, [paths[key]]) } catch { add('fail', `${dependency}-version`, 'Missing or invalid package manifest', [paths[key]]) }}check('ssr', 'config', [/output\s*:\s*['"]server['"]/, /provider\s*:\s*memoryCache\s*\(/], 'SSR + memory provider declarations (verify one live instance)')check('query', 'config', [/query\s*:\s*\{\s*include\s*:/], 'Explicit query key policy; review actual filters separately')check('registry', 'registry', [/capabilities\s*:/, /cache\s*:\s*\{\s*identity\s*:/], 'Public content declares its cache identity')check('tags', 'tags', [/export function validTags/, /export function tagsFor/, /doc,\s*old|\[doc,\s*old\]/], 'Tag allowlist and old + new document identities')check('tag-limit', 'tags', [/value\.length\s*<=\s*\d+/], 'Invalidation accepts a bounded number of tags')check('derived-secret', 'secret', [/createHmac/, /value\.length\s*<\s*32/], 'Purpose-specific keys derived from a checked master secret')if (/process\.env|dotenv|PAYLOAD_SECRET/.test(source.secret)) add('fail', 'secret-purity', 'Secret derivation reads environment or references Payload secret', [paths.secret])check('cms-env', 'env', [/siteSecret/, /SITE_SECRET/], 'CMS owns runtime configuration of the shared secret')check('durable-job', 'hooks', [/jobs\.queue\(/, /\breq,/, /retries\s*:/], 'Invalidation is queued in the content transaction, with retries')check('autosave', 'hooks', [/_status\s*===\s*["']draft["']/, /String\(req\.query\?\.draft\)/], 'Draft autosaves leave the public cache untouched')check('hook-lifecycle', 'hooks', [/previousDoc/, /skipInvalidation/, /afterDelete|CollectionAfterDeleteHook/, /siteSecret\(["']cache["']\)/], 'Publication lifecycle, opt-out and dedicated key')check('durations', 'durations', [/maxAge\s*:/, /swr\s*:/], 'Route cache duration with stale-while-revalidate')check('page-tags', 'loader', [/identity/, /tags\s*:/, /\.cache\b/], 'Page tags come from the registry and block dependencies')check('unavailable', 'loader', [/unavailable/], 'A failed block makes the response uncacheable')check('request-scope', 'cms', [/AsyncLocalStorage/, /store\?\.set\(key/], 'Promise map scoped to the request')check('fetch-timeout', 'cms', [/AbortSignal\.timeout/, /fallback-locale/], 'Bounded CMS fetch and no locale fallback')check('middleware', 'middleware', [/requests\.run\(new Map\(\)/, /no-store/, /noarchive/, /cache\.set\(false\)/], 'Request scope, private routes and error responses')check('builder', 'builder', [/export async function prepareBlocks/, /unavailable\s*=\s*true/], 'Common block preparation degrades one block at a time')check('endpoint', 'endpoint', [/cache\.set\(false\)/, /siteSecret\(["']cache["']\)/, /validTags/, /cache\.invalidate/, /status:\s*401/, /status:\s*400/, /no-store/], 'Invalidation endpoint guards declared')const auth = source.endpoint.indexOf('authorization'), body = source.endpoint.indexOf('request.json')add(auth >= 0 && body > auth ? 'ok' : 'fail', 'auth-before-body', 'Bearer checked before the request body is read', [paths.endpoint])check('preview', 'preview', [/cache\.set\(false\)/, /no-store/, /verifyPreview/, /client:load/], 'Preview opts out, verifies its token and hydrates explicitly')check('renderer', 'renderer', [/client:visible/], 'Explicit public islands; justify each one')add('info', 'limits', 'Static declarations do not prove tag symmetry, live configuration, cache transitions or browser behavior.')const counts = Object.fromEntries(['ok', 'fail', 'info'].map((level) => [level, findings.filter((f) => f.level === level).length]))if (json) console.log(JSON.stringify({ root, counts, findings }, null, 2))else { for (const finding of findings) console.log(`${finding.level.toUpperCase()} ${finding.id}: ${finding.message}${finding.evidence.length ? ` [${finding.evidence.join(', ')}]` : ''}`) console.log(`${counts.ok} OK, ${counts.fail} FAIL, ${counts.info} INFO`)}process.exitCode = counts.fail ? 1 : 0CELUGA_SKILL_EOFmkdir -p "$dir/scripts"cat > "$dir/scripts/test-audit.mjs" <<'CELUGA_SKILL_EOF'#!/usr/bin/env bun// Use a conforming checkout as fixture input; only a temporary copy is mutated.import assert from 'node:assert/strict'import fs from 'node:fs'import os from 'node:os'import path from 'node:path'import { spawnSync } from 'node:child_process'import { fileURLToPath } from 'node:url'const root = path.resolve(process.argv[2] ?? '.')const audit = fileURLToPath(new URL('./audit-astro-payload.mjs', import.meta.url))const run = (directory) => { const result = spawnSync(process.execPath, [audit, directory, '--json'], { encoding: 'utf8' }) return { code: result.status, report: JSON.parse(result.stdout) }}const baseline = run(root)assert.equal(baseline.code, 0, 'conforming source baseline should satisfy declarations')const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'celuga-cache-audit-'))try { assert.equal(run(temporary).code, 1, 'empty repo must fail') for (const relative of new Set(baseline.report.findings.flatMap((f) => f.evidence))) { const target = path.join(temporary, relative) fs.mkdirSync(path.dirname(target), { recursive: true }) fs.copyFileSync(path.join(root, relative), target) } assert.equal(run(temporary).code, 0, 'isolated source fixture baseline') const broken = (relative, transform, finding) => { const file = path.join(temporary, relative) const original = fs.readFileSync(file, 'utf8') const changed = transform(original) assert.notEqual(changed, original, `fixture did not change: ${finding}`) fs.writeFileSync(file, changed) const result = run(temporary) assert.equal(result.code, 1, finding) assert.ok(result.report.findings.some((f) => f.id === finding && f.level === 'fail'), finding) fs.writeFileSync(file, original) } broken('packages/site-contracts/src/server/site-secret.ts', (s) => `${s}\nconst leaked = process.env.PAYLOAD_SECRET\n`, 'secret-purity') broken('apps/cms/src/hooks/content.ts', (s) => s.replace('String(req.query?.draft)', 'req.query?.draft'), 'autosave') broken('apps/cms/src/hooks/content.ts', (s) => s.replace('jobs.queue(', 'jobs.ignore('), 'durable-job') broken('apps/web/src/lib/page-cache.ts', (s) => s.replace(/swr\s*:/, 'stale:'), 'durations') broken('apps/web/src/pages/api/cache/invalidate.ts', (s) => s.replace('request.json', 'request.clone().json').replace(/^/, '// request.json\n'), 'auth-before-body') fs.unlinkSync(path.join(temporary, 'apps/web/astro.config.mjs')) const missing = run(temporary) assert.equal(missing.code, 1) assert.ok(missing.report.findings.some((f) => f.id === 'file-config' && f.level === 'fail')) console.log('PASS: baseline, empty/missing files, secret purity, autosave guard, durable job, SWR, auth before body')} finally { fs.rmSync(temporary, { recursive: true, force: true }) }CELUGA_SKILL_EOFmkdir -p "$HOME/.claude/skills" "$HOME/.codex/skills"[ -e "$HOME/.claude/skills/celuga-astro-payload-cache" ] || ln -s "$dir" "$HOME/.claude/skills/celuga-astro-payload-cache"[ -e "$HOME/.codex/skills/celuga-astro-payload-cache" ] || ln -s "$dir" "$HOME/.codex/skills/celuga-astro-payload-cache"SKILL.md
Section intitulée « SKILL.md »---name: celuga-astro-payload-cachedescription: Concevoir, porter ou diagnostiquer le cache à tags Astro SSR avec Payload séparé — tags dérivés du registre, invalidation durable, SWR, déduplication par requête, globals et 404 partagés, page builder SSR, preview hors cache, compression.---
# Cache et rendu Astro + Payload
Chaque sortie publique porte les causes Payload qui peuvent changer son HTML ; les hooks émettent ces mêmes tags. Un hit du cache de route évite le fetch et le SSR ; la déduplication par requête évite seulement les fetchs identiques pendant un miss.
Vérifier les API de la version d'Astro installée et le graphe réel du projet avant d'adapter. Un audit ne demande ni déploiement ni mutation de base.
| Besoin | Lire || --- | --- || responsabilités et fichiers | [architecture](references/architecture.md) || tags, hooks, secret, endpoint | [contrat de cache](references/cache-contract.md) || blocs, îlots et preview | [rendu et preview](references/rendering-preview.md) || mono-instance, CDN, compression | [runtime](references/runtime.md) || adaptation et preuves | [portage](references/porting-checklist.md) |
## Auditer avant d'adapter
```bashbun <skill-dir>/scripts/audit-astro-payload.mjs <project-root>```
Ses fixtures négatives : `bun <skill-dir>/scripts/test-audit.mjs <projet-conforme>`. L'audit vise la disposition cible et échoue si une pièce manque ; il ne prouve pas l'exécution. Pour une autre arborescence, adapter sa carte de fichiers après avoir trouvé les consommateurs réels.
Relever : fournisseur et nombre d'instances, query params qui changent le HTML, routes publiques/privées/preview, statut publié, anciens chemins, relations et dépendances secondaires des blocs.
## Implémenter le contrat causal
- Tags dérivés du **registre partagé** : un contenu public déclare `identity` et `aggregate`, et `tagsFor` donne l'invalidation, ancien et nouveau slug compris. Ajouter tags consommés **et** émetteurs ensemble. Une source inconnue lève une erreur plutôt que de passer en silence.- Dépendance d'un bloc déclarée au catalogue (`cache: [...]`) ; le chargeur l'ajoute aux tags de la page.- Secret : une clé `cache` dérivée de `SITE_SECRET`, jamais `PAYLOAD_SECRET`. Endpoint : POST bearer comparé à temps constant **avant** de lire le corps, liste de tags validée, réponses `no-store`.- `cache.set({ ...pageCache, tags })` dans le fichier de `pages/`, après résolution du document. Dans un composant enfant, l'appel n'a pas d'effet fiable. Pas de règle globale qui capturerait une erreur.- Durées : quelques minutes de `maxAge` pour couvrir les parutions programmées, `swr` d'un jour pour ne jamais faire attendre le CMS à un visiteur.- Invalidation **durable** : une tâche Payload dans la transaction du contenu, plus un envoi direct non attendu pour les lecteurs actifs. Un site arrêté ne ralentit pas une sauvegarde.- Un autosave n'invalide rien ; une dépublication invalide. Un bloc en échec rend la réponse non cachable.- Panne : page d'erreur autonome 503 `no-store`, `noindex`, `Retry-After`, sans dépendance au CMS ni détail technique.
## Vérifier le résultat
Tests purs du graphe, du secret et de la déduplication, puis contrôles du dépôt. Sur un build de production autorisé : `MISS → HIT`, invalidation ciblée, page sans tag commun toujours HIT, rename, dépublication, preview hors cache, panne sur page froide. Une sauvegarde relance l'invalidation environ 2 s après : attendre avant de mesurer un second scénario. Tester aussi depuis l'administration : un test unitaire peut passer alors que le bug persiste.
`memoryCache` suppose une seule instance ; plusieurs réplicas ou un CDN HTML exigent une purge coordonnée ou un fournisseur partagé.Fichiers annexes
Section intitulée « Fichiers annexes »agents/openai.yaml
Section intitulée « agents/openai.yaml »interface: display_name: "Celuga Astro + Payload Cache" short_description: "Cache Astro à tags, invalidation Payload, rendu SSR-first et preview" default_prompt: "Utilise $celuga-astro-payload-cache pour auditer puis implémenter le contrat de cache et de rendu Astro SSR + Payload de ce projet."references/architecture.md
Section intitulée « references/architecture.md »# Architecture et sources
## Frontières
`apps/cms` porte Payload, PostgreSQL, les médias, l'administration et les hooks. `apps/web` porte Astro SSR, le HTML public, la preview, les proxys et les endpoints front. `packages/site-contracts` contient le registre et les fonctions partagées ; le code Node y reste sous `server/`.
## Où regarder dans la disposition cible
| Responsabilité | Fichier || --- | --- || registre des contenus publics, capacités de cache | `packages/site-contracts/src/collections.ts`, `public-content.ts` || tags autorisés, tags par source | `packages/site-contracts/src/cache.ts` (`validTags`, `tagsFor`) || dépendances de cache des blocs | `packages/site-contracts/src/content.ts` (`blockCatalog[…].cache`) || dérivation des clés | `packages/site-contracts/src/server/site-secret.ts` || secret et URL côté CMS | `apps/cms/src/lib/env.ts` || causes de publication, tâche durable | `apps/cms/src/hooks/content.ts` || fournisseur et clés de query | `apps/web/astro.config.mjs` || durées | `apps/web/src/lib/page-cache.ts` || chargement document + chrome, tags de page | `apps/web/src/lib/content-page.ts` || transport, déduplication | `apps/web/src/lib/cms.ts` || globals et 404 partagés entre requêtes | `apps/web/src/lib/shared-globals.server.ts`, `not-found-page.server.ts` || préparation des blocs | `apps/web/src/lib/page-builder.ts` || rendu public / preview | `apps/web/src/components/RenderBlocks/index.astro`, `pages/preview.astro` || endpoint d'invalidation | `apps/web/src/pages/api/cache/invalidate.ts` || portée de requête, panne, en-têtes, compression | `apps/web/src/middleware.ts` |
## Requête publique
1. Le fournisseur cherche la clé route + query retenues + encodage (`Vary`). Un hit ne refait ni SSR ni fetch.2. Sur miss, le middleware ouvre la Map de Promises de la requête. Redirection, document et chrome sont lus en parallèle ; les URL identiques sont dédupliquées.3. Les blocs sont préparés. Un échec reste limité au bloc et rend la réponse non cachable.4. La route pose le cache et rend le HTML. Une exception remonte au middleware, qui répond 503 `no-store`.
## Publication
Le hook ignore les autosaves, calcule les tags, met en file la tâche durable dans la transaction, tente aussi un envoi direct non attendu, puis réveille la boucle de fond. Le prochain GET d'une entrée portant un tag commun est un miss. Comme l'envoi direct peut précéder le commit, l'endpoint vide aussi les caches internes et ouvre une fenêtre de contournement de quelques secondes, et la tâche repasse après le commit.
Le graphe lie une **sortie cachée** à ses **sources**. Une collection publique sans consommateur caché n'exige pas d'invalidation par principe ; mais `tagsFor` doit la connaître si elle porte le hook.references/cache-contract.md
Section intitulée « references/cache-contract.md »# Contrat de cache
## Graphe type
Les tags représentent une cause de changement. L'invalidation utilise l'union : un seul tag commun suffit. Lire `cache.ts` du projet pour la liste réelle.
| Sortie | Tags || --- | --- || accueil (global) | `<identité>`, `globals`, `media`, `forms`, `redirects`, dépendances des blocs || document | `<identité>:<locale/>slug`, `globals`, `media`, `forms`, `redirects`, dépendances des blocs || liste d'un module | `<agrégat>`, `globals`, `media` || sitemap, llms.txt | `sitemap` (et `globals` pour llms.txt) |
| Mutation | Tags émis || --- | --- || collection publique | `sitemap`, `globals`, `<agrégat>`, ancien et nouveau `<identité>:<slug>` || global public | `<identité>`, `sitemap` || configuration, en-tête, pied de page | `globals`, agrégats, `sitemap` || média, document | `media`, `sitemap` || formulaire, redirection | `forms`, `redirects` || taxonomie liée | `globals`, agrégats concernés, `sitemap` || collection lue par un bloc | son tag, plus `globals` si le chrome la lit || index de recherche | aucun : lu à la requête, jamais caché |
Le tag porte la locale : `page:en/contact`, `page:contact` pour la langue principale. Une locale manquante purge la mauvaise entrée.
## Fournisseur et décision de route
```jscache: { provider: memoryCache({ max: 1000, query: { include: ["page", "<filtre>"] } }),}```
`max` borne la mémoire. Tout paramètre qui modifie le HTML entre dans `include` ; les autres sont ignorés, donc un scan d'URL ne remplit pas le cache. Une 404 n'est pas stockée par route : voir plus bas.
```tsexport const pageCache = { maxAge: 300, swr: 86_400 };```
`maxAge` couvre les parutions programmées et sert de filet si une invalidation se perd. Passé ce délai, le visiteur reçoit la version en cache et la suivante se prépare en arrière-plan ; une page sans visite un jour entier est de nouveau rendue à la demande.
Les miss et renouvellements simultanés ne sont pas regroupés : `memoryCache` appelle `next()` à chaque visite, et regrouper les appels au CMS lirait des réponses antérieures à une publication. C'est acceptable à trafic faible ; le mesurer avant de prétendre tenir une charge.
## Caches internes au Web
- **Globals publiés** (`shared-globals.server.ts`) : le corps JSON se partage entre requêtes 60 s, jamais les brouillons. Stocker le texte et non l'objet : chaque requête parse sa copie et peut la modifier.- **Page 404** (`not-found-page.server.ts`) : une entrée par langue, une heure, conservée par le middleware après rendu. Le cache de route créerait une entrée par adresse scannée. Un rendu dégradé est abandonné.- Tous deux sont vidés par l'endpoint, avec une fenêtre de contournement de 10 s, car le CMS peut invalider avant son commit.
## Secret et endpoint
`deriveSiteSecret(master, purpose)` : HMAC-SHA-256 de `celuga:<usage>:v1`, 32 caractères minimum, clés distinctes pour `cache`, `preview`, `form`. Une clé ne permet pas d'en déduire une autre. Aucun repli sur `PAYLOAD_SECRET`.
Ordre de `POST /api/cache/invalidate` :
1. `cache.set(false)` ;2. comparaison à temps constant du bearer **avant** la lecture du corps ; 401 sinon ;3. JSON invalide ou `validTags` refusé : 400 (tableau non vide, 100 tags au plus, tags statiques ou préfixes d'identité connus) ;4. vidage des caches internes, `await cache.invalidate({ tags })` ;5. réponses `no-store`.
Pas de secret en query ni de CORS sur ce canal serveur à serveur.
## Hooks
- `leavesPublicContentUntouched` : `_status === "draft"` et `String(req.query?.draft) === "true"` ne touche pas au public (autosave). Une dépublication arrive sans ce paramètre et invalide.- Changement de slug publié : redirection créée ou mise à jour dans la transaction, en supprimant toute redirection dont la source devient la nouvelle adresse (pas de boucle).- La tâche `invalidateCache` est mise en file avec `req` : seule une écriture validée devient visible par la boucle de fond. Reprises bornées avec backoff exponentiel.- L'envoi direct n'est pas attendu et un échec est seulement journalisé : un site arrêté ne bloque ni ne ralentit une publication.- Un drapeau de contexte (`skipInvalidation`) coupe tout pour les scripts.
## Déduplication par requête
`AsyncLocalStorage<Map<string, Promise<unknown>>>` ouvert par le middleware. Le transport met la Promise en cache dès son lancement, clé URL complète. Ne pas déplacer la Map au niveau module. Ne pas appliquer cette clé à des appels privés ou à des mutations.
## Preuves ciblées
Tester les ensembles exacts de tags, les deux identités, draft seul, suppression, opt-out, secrets et 401/400/200, déduplication concurrente et isolation entre requêtes. En production de test : `MISS → HIT`, purge ciblée, page sans tag commun encore HIT, preview hors cache, page froide 503 puis retour correct après reprise. Pour forcer un miss sans secret, envoyer un `Accept-Encoding` unique (`gzip, br;q=0.<n>`). Le script statique ne prouve pas ces transitions.references/porting-checklist.md
Section intitulée « references/porting-checklist.md »# Porter et vérifier
## Cartographie
Identifier versions installées, routes, statuts, locales, fournisseur, réplicas et éventuel CDN. Pour chaque sortie, noter sources, paramètres qui changent le HTML, personnalisation, cache et indexation. Pour chaque source consommée, retrouver son événement d'invalidation.
Les slugs et domaines varient selon les projets : adapter descripteurs, consommateurs et hooks ensemble. Le template du générateur `create-celuga` et un projet récent sont de meilleures sources à copier qu'un extrait figé : lire leurs fichiers listés dans [architecture](architecture.md).
## Adaptation
1. Poser le registre et `cache.ts` dans le package de contrats, avec la dérivation du secret.2. Laisser chaque runtime lire son environnement : `lib/env.ts` côté CMS, `lib/env.server.ts` côté Web.3. Garder les lectures publiques publiées, sans credentials ; passer par le transport commun avec déduplication. Une URL seule n'identifie pas une mutation ou une requête authentifiée.4. Choisir les clés de query et le fournisseur selon la topologie. Poser le cache après validation des données, dans le fichier de `pages/`.5. Couvrir ancienne et nouvelle identité, autosave, dépublication, suppression et relations. Mettre l'invalidation en tâche durable dans la transaction.6. Réutiliser préparation et rendu de blocs côté public et preview. Déclarer chaque source secondaire au catalogue.7. Preview : opt-out du cache, en-têtes, URL navigateur publique, jeton limité à un document.
## Recette proportionnée
Contrôles locaux : `bun test`, `bun run verify` ou équivalent. Ne pas lancer de migration simplement pour documenter un projet.
| Scénario | Preuve attendue dans un environnement autorisé || --- | --- || page publique | première requête MISS, seconde HIT, aucun fetch sur hit || dépendance ciblée | consommateur MISS, page sans tag commun toujours HIT || rename | ancienne et nouvelle identité purgées ; 301 || dépublication, suppression | fiche purgée ; liste et sitemap purgés || autosave de brouillon | aucune invalidation, depuis l'administration réelle || brouillon jamais publié | aucune invalidation publique || bloc en échec | bloc dégradé, page exploitable, réponse non stockée || global, média | consommateurs concernés purgés || preview | jamais HIT, `no-store`, `noindex`, mise à jour live || mauvais bearer, JSON ou tag invalide | 401, 400, 400 et aucune invalidation || Payload arrêté | page froide 503 non stockée ; premier GET correct après reprise || site arrêté pendant une publication | publication réussie ; invalidation reprise par la tâche || plusieurs processus | purge prouvée sur chacun, ou fournisseur partagé |
Pour couper le CMS, viser le port (`lsof -iTCP:<port> -sTCP:LISTEN`) : un motif trop large tue aussi le serveur Web connecté.
Une lecture de code et un audit statique sont des preuves limitées : indiquer ce qui a effectivement été exécuté. Une preuve de publication en production exige l'autorisation correspondant à cette mutation.
## Incertitudes
Continuer les parties indépendantes quand un nom ou un chemin diffère. Si le fournisseur cible ne fournit pas l'API requise ou si un test dépend d'identifiants indisponibles, livrer le vérifié et nommer le blocage précis.references/rendering-preview.md
Section intitulée « references/rendering-preview.md »# Rendu SSR et live preview
## Blocs
- `blockCatalog` des contrats définit slugs, libellés, groupes et dépendances de cache ; les modules y ajoutent les leurs.- `prepareBlocks(layout, load, locale, report)` charge les données annexes en dédupliquant les routes. Un bloc inconnu ou une lecture en échec donne `unavailable: true` : la page reste exploitable et n'est pas mise en cache.- `ContentBlock` choisit le rendu dans une table par slug, puis dans les rendus de modules. `RenderBlocks/index.astro` prépare ce qui exige le serveur (médias embarqués) et pose les îlots explicites.- Le composant de corps de page sert la page publique et la preview signée : pas de seconde logique de blocs.
## Îlots
| Surface | Rendu || --- | --- || blocs éditoriaux, FAQ native, chrome | SSR sans hydratation || formulaire, graphique | `client:visible` || consentement, si une mesure d'audience est configurée | `client:load` || live preview | `client:load` |
Les directives visent des composants statiquement importés dans un `.astro`. Un composant React SSR sans directive n'impose aucun JavaScript. HTML natif et CSS suffisent souvent aux FAQ, à la navigation et aux effets. Préserver un état initial lisible et `prefers-reduced-motion`.
## Preview
Une seule route `pages/preview.astro`, hors cache (`cache.set(false)`, `no-store`, `noindex`, `no-referrer`), sans JSON-LD ni consentement.
- **Avec jeton signé** (clé `preview` dérivée) : `verifyPreview` limite le jeton à un document et une langue ; le CMS sert le brouillon par un endpoint dédié, et le rendu est identique à la page publiée. Un jeton ne doit ouvrir aucun autre document.- **Sans jeton** : coquille non signée qui ne lit jamais de brouillon. Le document arrive seulement par le `postMessage` authentifié de l'administration, contrôlé sur l'origine, la source, la collection, la langue et l'identifiant.- `@payloadcms/live-preview` chargé à la demande. Les champs localisés sont convertis pour la population côté CMS : une nouvelle feuille localisée doit y être ajoutée. Requêtes périmées annulées, écouteur retiré au démontage.- URL publique du CMS dans le navigateur, jamais l'alias Docker ; vérifier CORS et CSRF. `frame-ancestors` autorise le CMS.- En preview, les formulaires n'envoient rien et le CAPTCHA est désactivé.
## Vérification
Inspecter le HTML initial : texte, titres, liens, chrome et médias avant JS. Un test de contrat prouve que chaque bloc du catalogue a une fixture et rend sans `astro-island`. Tester chaque interaction et la mise à jour live, y compris un document non enregistré.
Pour les médias, le SEO et l'ajout d'un bloc, appliquer les skills dédiés.references/runtime.md
Section intitulée « references/runtime.md »# Runtime et topologie
## Origines
| Nom | Consommateur || --- | --- || `PUBLIC_SITE_URL` | site public ; repli de l'appel CMS → Web || `PUBLIC_PAYLOAD_URL` | navigateur, live preview ; repli de l'appel Web → CMS || `PAYLOAD_INTERNAL_URL` | Astro serveur → CMS, prioritaire || `WEB_INTERNAL_URL` | CMS → Web pour l'invalidation, prioritaire || `SITE_SECRET` | secret partagé, dont dérive la clé d'invalidation |
Les `PUBLIC_*` sont incorporées au build ; les secrets restent serveur. Un alias privé propre au projet évite les collisions DNS d'un réseau partagé. Jamais d'alias privé dans le HTML, un média, la canonical ou l'iframe de preview.
## Niveaux de cache
- cache de route Astro : réponse complète et tags ; hit sans nouveau SSR ;- globals et 404 partagés dans le processus Web : durée courte, vidés à l'invalidation ;- cache de requête : Promises de fetch, durée d'un rendu ;- cache navigateur/CDN : directives HTTP et assets, indépendant des hooks Astro.
Une purge Astro ne purge pas un CDN HTML. Les réponses privées et la preview restent explicitement hors cache. Toute réponse d'état ≥ 400 et toute route privée reçoivent `no-store` dans le middleware.
## Compression
La réponse rendue est compressée **après** le cache de route par le middleware, avec `Vary: Accept-Encoding` : une entrée par encodage. Brotli qualité 6 sur le HTML : `CompressionStream("brotli")` force la qualité 11, bien plus coûteuse pour un gain minime. JS et CSS sont précompressés au build et servis par `server.mjs`, en immuable pour les fichiers versionnés. Les fichiers de `public/` reçoivent un cache d'un jour, sinon l'adaptateur les sert en `max-age=0`.
## Mono-instance et reprise
Le fournisseur mémoire est local à un processus ; un redémarrage le vide. Plusieurs réplicas, un déploiement progressif avec deux processus actifs ou du serverless exigent un fournisseur partagé ou une diffusion à toutes les instances. Le CMS aussi est prévu en instance unique, à cause de ses tâches de fond.
Pendant une panne Payload, une réponse chaude reste servie ; une page froide renvoie 503 `no-store`, jamais du contenu vide enregistré comme succès. Un mode maintenance lu dans le CMS ne bascule pas si le CMS est injoignable.
## Serveur
`server.mjs` démarre le gestionnaire de l'adaptateur Node sous Bun, précédé des fichiers précompressés. Avec `ASTRO_BUNDLE_DEPS=true` au build, l'image ne contient pas de `node_modules`. Dans un conteneur, `init: true` est nécessaire : sans lui, le serveur en processus 1 ignore SIGTERM.
## Vérifications
Depuis l'extérieur : état du cache, absence d'URL interne, règles HTML/CDN, preview et erreurs non stockées. Depuis la configuration de déploiement : nombre réel de réplicas, réseau et origines résolues. Le script d'audit ne connaît ni l'état de la plateforme ni les secrets effectifs.scripts/audit-astro-payload.mjs
Section intitulée « scripts/audit-astro-payload.mjs »#!/usr/bin/env bun// Static evidence for the target Celuga layout, not a runtime/cache-correctness proof.import fs from 'node:fs'import path from 'node:path'
const args = process.argv.slice(2)const root = path.resolve(args.find((arg) => arg !== '--json') ?? '.')const json = args.includes('--json')const paths = { webPackage: 'apps/web/package.json', cmsPackage: 'apps/cms/package.json', config: 'apps/web/astro.config.mjs', tags: 'packages/site-contracts/src/cache.ts', registry: 'packages/site-contracts/src/collections.ts', secret: 'packages/site-contracts/src/server/site-secret.ts', env: 'apps/cms/src/lib/env.ts', hooks: 'apps/cms/src/hooks/content.ts', cms: 'apps/web/src/lib/cms.ts', loader: 'apps/web/src/lib/content-page.ts', durations: 'apps/web/src/lib/page-cache.ts', builder: 'apps/web/src/lib/page-builder.ts', middleware: 'apps/web/src/middleware.ts', endpoint: 'apps/web/src/pages/api/cache/invalidate.ts', preview: 'apps/web/src/pages/preview.astro', renderer: 'apps/web/src/components/RenderBlocks/index.astro',}const findings = []const source = {}const add = (level, id, message, evidence = []) => findings.push({ level, id, message, evidence })// Components may live in a folder (components/X/index.astro) or flat (components/X.astro).if (!fs.existsSync(path.join(root, paths.renderer))) paths.renderer = 'apps/web/src/components/RenderBlocks.astro'for (const [key, relative] of Object.entries(paths)) { const file = path.join(root, relative) if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { add('fail', `file-${key}`, 'Required target-layout file missing; map the actual project before auditing', [relative]) source[key] = '' } else source[key] = fs.readFileSync(file, 'utf8')}const check = (id, key, patterns, message) => { const ok = Boolean(source[key]) && patterns.every((pattern) => pattern.test(source[key])) add(ok ? 'ok' : 'fail', id, message, [paths[key]])}for (const [key, dependency] of [['webPackage', 'astro'], ['cmsPackage', 'payload']]) { try { const pkg = JSON.parse(source[key]) const version = pkg.dependencies?.[dependency] ?? pkg.devDependencies?.[dependency] add(version ? 'ok' : 'fail', `${dependency}-version`, version ? `${dependency}: ${version}` : `${dependency} dependency missing`, [paths[key]]) } catch { add('fail', `${dependency}-version`, 'Missing or invalid package manifest', [paths[key]]) }}check('ssr', 'config', [/output\s*:\s*['"]server['"]/, /provider\s*:\s*memoryCache\s*\(/], 'SSR + memory provider declarations (verify one live instance)')check('query', 'config', [/query\s*:\s*\{\s*include\s*:/], 'Explicit query key policy; review actual filters separately')check('registry', 'registry', [/capabilities\s*:/, /cache\s*:\s*\{\s*identity\s*:/], 'Public content declares its cache identity')check('tags', 'tags', [/export function validTags/, /export function tagsFor/, /doc,\s*old|\[doc,\s*old\]/], 'Tag allowlist and old + new document identities')check('tag-limit', 'tags', [/value\.length\s*<=\s*\d+/], 'Invalidation accepts a bounded number of tags')check('derived-secret', 'secret', [/createHmac/, /value\.length\s*<\s*32/], 'Purpose-specific keys derived from a checked master secret')if (/process\.env|dotenv|PAYLOAD_SECRET/.test(source.secret)) add('fail', 'secret-purity', 'Secret derivation reads environment or references Payload secret', [paths.secret])check('cms-env', 'env', [/siteSecret/, /SITE_SECRET/], 'CMS owns runtime configuration of the shared secret')check('durable-job', 'hooks', [/jobs\.queue\(/, /\breq,/, /retries\s*:/], 'Invalidation is queued in the content transaction, with retries')check('autosave', 'hooks', [/_status\s*===\s*["']draft["']/, /String\(req\.query\?\.draft\)/], 'Draft autosaves leave the public cache untouched')check('hook-lifecycle', 'hooks', [/previousDoc/, /skipInvalidation/, /afterDelete|CollectionAfterDeleteHook/, /siteSecret\(["']cache["']\)/], 'Publication lifecycle, opt-out and dedicated key')check('durations', 'durations', [/maxAge\s*:/, /swr\s*:/], 'Route cache duration with stale-while-revalidate')check('page-tags', 'loader', [/identity/, /tags\s*:/, /\.cache\b/], 'Page tags come from the registry and block dependencies')check('unavailable', 'loader', [/unavailable/], 'A failed block makes the response uncacheable')check('request-scope', 'cms', [/AsyncLocalStorage/, /store\?\.set\(key/], 'Promise map scoped to the request')check('fetch-timeout', 'cms', [/AbortSignal\.timeout/, /fallback-locale/], 'Bounded CMS fetch and no locale fallback')check('middleware', 'middleware', [/requests\.run\(new Map\(\)/, /no-store/, /noarchive/, /cache\.set\(false\)/], 'Request scope, private routes and error responses')check('builder', 'builder', [/export async function prepareBlocks/, /unavailable\s*=\s*true/], 'Common block preparation degrades one block at a time')check('endpoint', 'endpoint', [/cache\.set\(false\)/, /siteSecret\(["']cache["']\)/, /validTags/, /cache\.invalidate/, /status:\s*401/, /status:\s*400/, /no-store/], 'Invalidation endpoint guards declared')const auth = source.endpoint.indexOf('authorization'), body = source.endpoint.indexOf('request.json')add(auth >= 0 && body > auth ? 'ok' : 'fail', 'auth-before-body', 'Bearer checked before the request body is read', [paths.endpoint])check('preview', 'preview', [/cache\.set\(false\)/, /no-store/, /verifyPreview/, /client:load/], 'Preview opts out, verifies its token and hydrates explicitly')check('renderer', 'renderer', [/client:visible/], 'Explicit public islands; justify each one')add('info', 'limits', 'Static declarations do not prove tag symmetry, live configuration, cache transitions or browser behavior.')const counts = Object.fromEntries(['ok', 'fail', 'info'].map((level) => [level, findings.filter((f) => f.level === level).length]))if (json) console.log(JSON.stringify({ root, counts, findings }, null, 2))else { for (const finding of findings) console.log(`${finding.level.toUpperCase()} ${finding.id}: ${finding.message}${finding.evidence.length ? ` [${finding.evidence.join(', ')}]` : ''}`) console.log(`${counts.ok} OK, ${counts.fail} FAIL, ${counts.info} INFO`)}process.exitCode = counts.fail ? 1 : 0scripts/test-audit.mjs
Section intitulée « scripts/test-audit.mjs »#!/usr/bin/env bun// Use a conforming checkout as fixture input; only a temporary copy is mutated.import assert from 'node:assert/strict'import fs from 'node:fs'import os from 'node:os'import path from 'node:path'import { spawnSync } from 'node:child_process'import { fileURLToPath } from 'node:url'const root = path.resolve(process.argv[2] ?? '.')const audit = fileURLToPath(new URL('./audit-astro-payload.mjs', import.meta.url))const run = (directory) => { const result = spawnSync(process.execPath, [audit, directory, '--json'], { encoding: 'utf8' }) return { code: result.status, report: JSON.parse(result.stdout) }}const baseline = run(root)assert.equal(baseline.code, 0, 'conforming source baseline should satisfy declarations')const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'celuga-cache-audit-'))try { assert.equal(run(temporary).code, 1, 'empty repo must fail') for (const relative of new Set(baseline.report.findings.flatMap((f) => f.evidence))) { const target = path.join(temporary, relative) fs.mkdirSync(path.dirname(target), { recursive: true }) fs.copyFileSync(path.join(root, relative), target) } assert.equal(run(temporary).code, 0, 'isolated source fixture baseline') const broken = (relative, transform, finding) => { const file = path.join(temporary, relative) const original = fs.readFileSync(file, 'utf8') const changed = transform(original) assert.notEqual(changed, original, `fixture did not change: ${finding}`) fs.writeFileSync(file, changed) const result = run(temporary) assert.equal(result.code, 1, finding) assert.ok(result.report.findings.some((f) => f.id === finding && f.level === 'fail'), finding) fs.writeFileSync(file, original) } broken('packages/site-contracts/src/server/site-secret.ts', (s) => `${s}\nconst leaked = process.env.PAYLOAD_SECRET\n`, 'secret-purity') broken('apps/cms/src/hooks/content.ts', (s) => s.replace('String(req.query?.draft)', 'req.query?.draft'), 'autosave') broken('apps/cms/src/hooks/content.ts', (s) => s.replace('jobs.queue(', 'jobs.ignore('), 'durable-job') broken('apps/web/src/lib/page-cache.ts', (s) => s.replace(/swr\s*:/, 'stale:'), 'durations') broken('apps/web/src/pages/api/cache/invalidate.ts', (s) => s.replace('request.json', 'request.clone().json').replace(/^/, '// request.json\n'), 'auth-before-body') fs.unlinkSync(path.join(temporary, 'apps/web/astro.config.mjs')) const missing = run(temporary) assert.equal(missing.code, 1) assert.ok(missing.report.findings.some((f) => f.id === 'file-config' && f.level === 'fail')) console.log('PASS: baseline, empty/missing files, secret purity, autosave guard, durable job, SWR, auth before body')} finally { fs.rmSync(temporary, { recursive: true, force: true }) }

