Acces delegue et frontiere Studio
NOTE POST-NETTOYAGE (8 octobre 2026) : ce texte documente une architecture ou un audit historique, avant les PR #1717–#1724 et #1728. Les anciennes interfaces Growth/Cinema et l'ancien Studio marchand dans boostecom.app…
NOTE POST-NETTOYAGE (8 octobre 2026) : ce texte documente une architecture ou un audit historique, avant les PR #1717–#1724 et #1728. Les anciennes interfaces Growth/Cinema et l'ancien Studio marchand dans
boostecom.appont été retirés. Le contrat CURRENT estdocs/architecture/shopify-media-boundary.md; les capacités média Shopify et Orbit Studio sont distinctes. La validation des builds/tests/E2E reste déléguée à un autre agent.
Note d'architecture. Elle expose un defaut verifie, la primitive proposee pour le reparer, et un phasage en six etapes.
Les phases 1 et 3 sont LIVREES, actees par
0013-agency-et-studio-sont-deux-namespaces.md: la scissionagency.*/studio.*, la fermeture du court-circuit owner, puis le modeleAccessGrantet les quatre portes du Studio qui l'honorent. Les sections 1.1 et 1.3 decrivent donc l'etat AVANT, gardees comme diagnostic.Plan C retire le 2026-09-26 (ADR 0043). Tout ce que cette note dit de
~/studio/**, des prospects, des gates client, de la cadence, de/drop/[token], destudio.drop.*,studio.gate.read,agency.prospect.*,agency.client.*,agency.cadence.manageet des presetssales/client_opsdecrit du code supprime.AccessGrant,studio.concept.*,studio.qc.read,studio.economics.read,agency.qc.reviewetagency.economics.readrestent vivants.Quatre choses que l'implementation a dementies, corrigees ci-dessous plutot que laissees comme des affirmations perimees :
credential-storage.test.tsne bouge PAS. Il derive les fichiers qui exportent unresolve*/validate*et nomment unkeyHash/tokenHash;access-grants.tsn'a pas de jeton en phase 3, donc il n'entre pas dans l'echantillon.- Les deux gardes d'orphelins ne demandent AUCUN traitement manuel dans les handlers de suppression :
AccessGrantporte de vraies FKonDelete: CascadeversOrganizationetStore, donc Postgres garantit la purge. Une garantie de base bat un handler qu'on peut oublier de mettre a jour.lastUsedAt,acceptedAt,dpaAcceptedAtetmonthlyCreditCapUsdne sont PAS dans la table. Une colonne que rien n'ecrit est le defautstudioRolessous un autre nom ; elles arriveront avec leur ecrivain, et une colonne nullable ajoutee plus tard est libre.- Le cliquet
grant-is-opt-in.test.tsn'a pas ete ecrit sous forme de liste figee. La propriete est prouvee par le COMPORTEMENT (access-grants.test.tscompte les lectures de la table et exige zero sansrequire), ce qui la mesure la ou elle vit au lieu de geler une liste de ~94 appelants qui bougerait a chaque PR.Les phases 2, 4, 5 et 6 restent proposees. Sept items
type: "decision"de la derniere section engagent un prix, un contrat ou un risque juridique.
Trois questions ont ete posees ensemble parce qu'elles se ressemblent. Elles ont trois reponses differentes, et une seule cause commune.
- Le Studio agence est melange au Studio marchand.
- Il n'existe aucun moyen de donner un acces partiel a un interne, un partenaire ou un reviewer.
- Un freelance ou une agence ne peut pas operer la marque d'un client sans posseder le compte.
1. Diagnostic
1.1 Le Studio agence n'a pas de frontiere — il a un court-circuit, et il en a DEUX
Qui accede aujourd'hui au back-office d'agence : tout proprietaire de toute organisation. C'est-a-dire tout utilisateur ayant clique « creer une organisation ». Chaque marchand solo de la plateforme detient donc le pipeline de prospection commerciale, les gates Go/No-Go et le cockpit d'economie de production de sa propre organisation, et voit l'onglet dans sa navigation.
Le chemin, verifie ligne a ligne :
| Fait | Preuve |
|---|---|
hasPermission rend true pour TOUTE permission des role === "owner", avant deny, allow, presets et baseline | src/lib/security/permissions.ts:307 |
ROLE_DEFAULTS.owner reenumere en dur les douze studio.* | src/lib/security/permissions.ts:106-134 |
getOrgAccess synthetise role: "owner" depuis Organization.ownerId sans exiger de ligne de membership, et rend permissions: {} en dur | src/lib/security/tenant-auth.ts:58 |
Le layout org calcule studioAccess sur sept studio.* et dessine l'onglet | src/app/(dashboard)/[orgSlug]/layout.tsx:24-32 |
Consequence de conception, et c'est le piege principal : borner le
court-circuit de la ligne 307 est un no-op. Un owner prive du
court-circuit tombe a l'etape 5 du resolveur et recupere la permission par
ROLE_DEFAULTS.owner. Il faut editer les deux, plus tenant-auth.ts:58,
dans le meme commit.
Second constat : les deux Studios ne sont differencies par rien au niveau
autorisation. Memes douze permissions, meme organisation, meme loader ; la
seule difference est un argument d'appel (loadQcBoard({orgId}) vs
loadQcBoard({orgId, storeId}), src/features/studio/board.ts). Et la
surface marchande pose elle-meme un lien vers /~/studio/qc des que le
viewer detient review / publish / economics
(src/app/(dashboard)/[orgSlug]/[storeSlug]/studio/page.tsx:132-135).
1.2 Entre « membre » et « ADMIN plateforme », il n'y a rien — et le milieu n'est pas atteignable
Les deux seules portes : OrganizationMember (plancher viewer =
store.read + billing.read + members.read + workspace.read, et un
siege) ou User.role === "ADMIN" (enum Role { ADMIN, USER },
prisma/schema.prisma:1936-1939) qui ouvre 85 pages /admin, le ledger de
credits, le ban, le refund Stripe et les transcripts @Atlas de tous les
clients.
Le mecanisme prevu pour combler ce trou existe et est mort.
OrganizationMember.permissions.studioRoles est lu par le resolveur
(permissions.ts:320) et ecrit par aucun code de production — grep studioRoles hors tests ne rend que le resolveur et un fichier de doc. Son
unique ecrivain, setMemberPermissions, reconstruit {allow, deny} et le
detruit (src/app/(dashboard)/admin/people/organizations/_actions/index.ts:270-281).
Les quatre presets de l'ADR 0004 (sales, client_ops, strategist,
producer) sont donc inatteignables — alors que
docs/architecture/studio-agency-os.md:104 declare la phase 0 « livree,
les 3 recrues peuvent travailler ».
Et le seul ecrivain d'une permission fine exige requireAdmin() : seul un
admin plateforme peut poser un acces partiel, jamais le proprietaire de
l'organisation. L'API org (PATCH /api/organizations/[orgId]/members/[memberId])
ne sait changer que role.
Trois blocages structurels s'ajoutent :
src/proxy.ts:567-576refuse/admin*au bord, sur le claimroledu JWT. UnrequireAdmin(permission?)retrocompatible ne rend donc aucune page/adminatteignable a un non-ADMIN.src/app/(dashboard)/admin/layout.tsx:51faitawait requireAdmin()sans argument. Gater une page seule ne sert a rien.- Le flag vit dans le JWT (
src/modules/auth/server.ts:298-303, maxAge 30 j) : revoquer un admin n'est pas immediat.
Fail-open a fermer au passage : isKnownRole rabat tout role inconnu sur
viewer (permissions.ts:322). Ecrire role: "partner" en base accorde
silencieusement billing.read.
1.3 Aucune permission n'est scopee a une marque
getStoreAccess (tenant-auth.ts:80-99) lit le store uniquement pour son
orgId, delegue a getOrgAccess, puis recopie role / isOwner /
permissions verbatim. Le store n'ajoute aucune information d'autorisation.
Il n'existe aucune table reliant un utilisateur a un store — verifie :
grep -rn "storeIds|scopedStores|allowedStores|storeScope" ne rend aucun
mecanisme d'autorisation.
Trois pieges qu'une correction naive rate :
- Le seam evident n'atteint pas le Studio. La porte unique des ecritures
creatives,
studioActorFor(src/features/studio/guard.ts:129), resoutstore.orgIdpuis appellegetOrgAccess, jamaisgetStoreAccess. Idem en lecture :studioAccessFor(studio-guard.ts:81) etgetStudioPermissions. - Les quatre portes reconstruisent l'objet passe au resolveur :
hasPermission({ role: access.role, permissions: access.permissions }, p). Tout champ ajoute aOrgAccessest jete par 100 % du Studio. - Sur les ~94 appelants non-test de
getStoreAccess, plus de 40 ne consultent jamaishasPermission— ils traitent un retour non-null comme l'autorisation. Y ajouter une source d'identite sans precaution n'est pas une restriction, c'est une concession : elle ouvrirait d'un coup les transcripts @Atlas, la memoire store, le ledger, les callbacks OAuth des connecteurs et le pilotage Browserbase.
2. Ce que le monde reel fait
| Pattern | Qui l'utilise | Ce qu'on en prend | Ce qu'on en laisse |
|---|---|---|---|
Grant scope (sujet, role, portee, fenetre) comme LIGNE, pas colonne | Azure role assignment, GCP role binding, AWS account assignment | La forme exacte, les deux index (qui peut X / qui accede a CE store), revokedAt plutot que DELETE | Le moteur ReBAC : deux etages fixes, quatre roles → dual-write, hop reseau par check, perte du JOIN pour src/lib/search/registry.ts |
| Collaborator account hors siege, code court rotatif, flux ENTRANT | Shopify | Zero siege consomme, la demande initiee par le prestataire, le code qui n'accorde rien | Le trou « Develop apps » : un collaborateur retire laisse un token Admin API vivant |
| Partner access entite → entite | Meta Business Portfolio, Google Ads MCC, Xero HQ | Le client accorde a l'entite, l'entite gere ses gens | L'arete org↔org tout de suite : prematuree a N ≤ 5 freelances |
| Permission set par REFERENCE + regle anti-escalade | Salesforce, AWS IAM Identity Center, HubSpot | Presets references par id (deja le choix de studioRoles) ; « on n'assigne qu'un set dont on detient soi-meme les permissions » | Le constructeur de roles custom (plafond 20, explosion combinatoire) |
| Expiration native, colonne indexable | SpiceDB (use expiration), GDAP (2 ans max, « permanent relationships aren't possible ») | expiresAt requis sur tout mandat externe, notification J-7 | Le moteur de conditions CEL/Rego : un second langage, des decisions illisibles |
| Base permissions ne s'appliquent PAS aux outside collaborators | GitHub | Le plancher vide porte par le TYPE d'acteur, pas par une soustraction | Les custom org roles (surface de configuration enorme) |
| Invite ton comptable, gratuit, borne, revocable, onglet separe | QuickBooks, Xero | L'onglet separe des membres : voir d'un coup d'oeil qui n'est pas de la maison | — |
| Sub-account / white-label | GoHighLevel | Rien | Tout : le proprietaire d'une sub-account ne peut pas retirer l'agence. Store.domain est @unique (schema.prisma:283) : le lock-in serait une contrainte de base, pas un contrat |
3. La primitive proposee : AccessGrant, opt-in a la lecture
Une ligne (sujet, presets, portee, fenetre) qui alimente le meme
hasPermission, par la meme forme PermissionOverride. Une source
d'identite de plus, jamais un second resolveur — ce que l'ADR 0004 autorise
(il interdit un second AXE a cote de OrganizationMember.role, pas une
seconde source pour le resolveur unique).
La regle qui fait tout tenir :
Un grant n'est jamais lu par defaut. Il n'est lu que par un appelant qui NOMME la permission qu'il exige.
getOrgAccess(u, org)etgetStoreAccess(u, store)sansrequirerendent exactement ce qu'ils rendent aujourd'hui : l'appartenance, rien d'autre. Avecrequire, ils composent appartenance ∪ grants et rendentnullsi la permission ne resout pas.
┌────────────────────────────────────────┐
│ hasPermission(member, perm) INCHANGE │
│ owner → deny → allow → presets → base │
└──────────────────▲─────────────────────┘
│ { role, permissions }
┌────────────────────────────┴────────────────────────────┐
│ │
getOrgAccess(u, org, {require?}) getStoreAccess(u, store, {require?})
│ │
sans require : membership seul sans require : membership seul
─ comportement d'aujourd'hui ─ ─ comportement d'aujourd'hui ─
│ │
avec require : ∪ grants(org) avec require : ∪ grants(org) ∪ grants(store)
│ │
└──────────────► resolveGrants(u, scope) ◄────────────────┘
WHERE revokedAt IS NULL
AND startsAt <= now() AND expiresAt > now()
Ce que cette seule regle repare :
- Zero concession aux ~40 appelants qui traitent non-null comme
l'autorisation : ils n'appellent pas avec
require, un delegue n'y passe pas. Le trou ne s'ouvre jamais par accident. - Zero requete ajoutee au chemin chaud : la population de grants est vide le jour du deploy, et le cout est paye la ou quelqu'un l'a demande.
- La migration est site par site, visible en diff, et chaque site migre declare sa permission.
- Elle rend ecrivable le cliquet derive : « tout appelant de
getStoreAccessqui ne passe pasrequireest sur une liste qui ne peut que retrecir ».
Alternatives ecartees
| Alternative | Pourquoi non |
|---|---|
Tout dans OrganizationMember.permissions Json? (storeIds[], expiresAt) — la voie zero-DDL | (1) « Qui a acces a CE store ? » est inrepondable : un Json n'est pas indexable, et c'est la requete d'un offboarding, d'un incident et d'une demande RGPD. (2) Une expiration doit etre balayable par un cron. (3) Elle exige une ligne OrganizationMember, donc un siege, le plancher viewer, et l'apparition dans le roster que /api/me livre a tout membre |
Une 5e valeur d'enum User.role (INTERNAL, PARTNER) | Mecaniquement gratuit, fonctionnellement nul : les 133 fichiers qui testent === "ADMIN" continueraient de refuser. Une valeur d'enum Postgres est en outre definitive |
Un role partner sur OrganizationMember.role | role est une colonne String libre, donc zero DDL — et c'est un fail-open : isKnownRole (permissions.ts:322) rabat l'inconnu sur viewer |
| Un moteur ReBAC (SpiceDB, OpenFGA, Keto) | Hierarchie a deux etages fixes, quatre roles, aucun partage a des individus arbitraires : le seuil Zanzibar n'est pas atteint. On acheterait le dual-write, un hop reseau par check, des zookies, et la perte du JOIN SQL — decisif pour le fan-out de src/lib/search/registry.ts |
Organization.kind / PLATFORM_ORG_ID pour nommer l'org agence | featureFlags est interdit en ecriture (src/test/a-toggle-controls-something.test.ts) ; une variable d'env fail-soft "" change silencieusement de comportement en preview. Et surtout c'est circulaire : si l'agence est « une org ou quelqu'un detient agency.* », tout owner se mint le grant. Remplace par une regle executable : agency.* n'est mintable que par un appelant admin plateforme |
requireAdmin(permission?) pour diviser /admin | Bloque au bord (proxy.ts:567-576) et par admin/layout.tsx:51. Le debloquer exige soit les permissions dans le JWT (la revocation cesse d'etre immediate), soit retirer le gate au bord pour 85 pages afin d'en ouvrir deux. Remplace par : on ne divise pas /admin, on en SORT les surfaces d'equipe |
Une arete org↔org (OrgPartnerGrant, modele MCC/Meta) | Forme correcte a terme, se rajoutera au-dessus des grants sans les invalider. A N ≤ 5 freelances elle coute sa propre machine d'invitation, sa propre console et son propre consentement pour un gain nul. Declencheur ecrit : plus de trois humains en rotation chez un meme partenaire |
Un deny dans le grant | Le modele de grant est purement ADDITIF (Azure, AWS, Salesforce). Deux couches de soustraction concurrentes n'ont pas de precedence defendable. Une seule soustraction : OrganizationMember.permissions.deny |
4. Le modele
AccessGrant — NOUVELLE TABLE (libre)
/// Un mandat : (sujet, presets, portee, fenetre). Alimente le MEME
/// hasPermission par la MEME forme PermissionOverride. Jamais supprime :
/// revokedAt fait de la table son propre journal (discipline Credit).
model AccessGrant {
id String @id @default(cuid())
subjectUserId String // pas de FK : convention du depot (Task.assigneeUserId)
scopeType String // "platform" | "org" | "store"
scopeKey String // "platform" | "org:<id>" | "store:<id>"
orgId String? // null pour scopeType "platform"
storeId String? // non-null seulement pour scopeType "store"
presets String[] @default([])
allow String[] @default([])
grantedById String
reason String?
dpaAcceptedAt DateTime?
monthlyCreditCapUsd Decimal? @db.Decimal(19, 2)
startsAt DateTime @default(now())
expiresAt DateTime // REQUIS : pas de mandat permanent
acceptedAt DateTime?
lastUsedAt DateTime?
revokedAt DateTime?
revokedById String?
revokeReason String?
createdAt DateTime @default(now())
@@unique([scopeKey, subjectUserId])
@@index([subjectUserId, revokedAt]) // « que peut faire X »
@@index([scopeKey, revokedAt]) // « qui accede a CE store » ← requete d'incident
@@index([expiresAt]) // le cron
}
Classification schema guard : table neuve → table-create emet toutes
les colonnes DANS le CREATE TABLE
(scripts/generate-schema-guard.mjs:236-246), et refusableColumnAdds
retourne false quand la table n'est pas au catalogue commite (:462-463).
Aucun 23502 possible. Cout : 6 colonnes NOT NULL sans defaut (id,
subjectUserId, scopeType, scopeKey, grantedById, expiresAt), donc
CEILING passe de 783 a 789 dans
src/services/database/not-null-column-adds.test.ts:83.
Pourquoi scopeKey et pas @@unique([scopeType, orgId, storeId, subjectUserId]) :
Postgres traite les NULL comme DISTINCTS dans un index unique. Un @@unique
sur des colonnes nullables ne protegerait pas les grants plateforme.
Le @@unique est pose sur une table vide : aucun collapse de doublons a
ecrire dans EXTRA_STEPS.
orgId et storeId sont des colonnes reelles en plus de scopeKey
precisement pour rester visibles de
org-delete-reaches-every-orphan.test.ts et
store-delete-reaches-every-orphan.test.ts, qui derivent les modeles portant
un orgId/storeId sans @relation. Un scopeId polymorphe leur serait
invisible.
Autres changements de schema
| Changement | Phase | Verdict guard |
|---|---|---|
AccessGrantRequest (forme copiee de OrganizationInvitation, schema.prisma:247-265) | 5 | Table neuve → libre. Relever CEILING une SECONDE fois (≈ 789 → 794), sinon pnpm db:guard echoue |
Store.collabRequestCode String? | 5 | Colonne nullable → libre |
OrganizationMember | — | AUCUN CHANGEMENT. permissions Json? continue de servir les MEMBRES et n'est pas etendue. Deux corrections de CODE, zero DDL : setMemberPermissions fusionne au lieu de remplacer, et le commentaire de colonne (schema.prisma:225-232) est reecrit — il decrit {allow, deny} et ignore studioRoles, en production depuis l'ADR 0004 |
Organization, Store (hors collabRequestCode), User, enum Role | — | AUCUN CHANGEMENT. Les trois modeles deja peuples ne gagnent pas une colonne requise |
Risque schema non evident, phase economie : l'index
Credit(orgId, userId, createdAt) fait echouer pnpm db:indexes.
scripts/check-redundant-indexes.mjs:67 porte deja
{ model: "Credit", redundant: "orgId", coveredBy: "orgId,expiresAt" } sur
la liste ACCEPTED, qui « ne peut que retrecir ». Un troisieme index menant
par orgId cree une paire redondante non declaree. La sortie est un DROP
operateur (docs/ops/database-index-maintenance.md), donc hors PR — a
budgeter comme tel.
Les permissions
agency.* — 7, le metier de la plateforme. Mintables uniquement par un
appelant admin plateforme.
agency.prospect.read · agency.prospect.manage · agency.client.read ·
agency.client.manage · agency.qc.review · agency.cadence.manage ·
agency.economics.read
studio.* — 7, la production, delegable
studio.concept.read · studio.concept.write · studio.concept.validate ·
studio.asset.create · studio.qc.read (nouvelle) ·
studio.drop.read (nouvelle) · studio.drop.publish
Les deux nouvelles reparent un defaut que studio-sections.ts documente
comme delibere et qui cesse de l'etre des qu'un reviewer existe :
aujourd'hui la lecture de la board QC est ouverte par
studio.qc.review, la permission d'ecrire, et le panneau de livraisons
marchand par studio.drop.publish.
platform.* : platform.growth.manage · platform.bulletin.approve ·
platform.creative.review · platform.hub.read.
store.customers.read — PII. Absente de toute baseline et de tout
preset. Ne ship PAS avant ses lecteurs (/api/stores/[storeId]/export,
l'outil MCP customers, le free-text Order, Conversation/Message) :
une permission declaree et lue par personne est exactement le defaut
studio.asset.create.
NON_DELEGABLE — ce qu'aucun preset ne peut porter : billing.manage ·
store.delete · store.create · members.invite · members.remove ·
settings.update · agent.approve_tools.
Correction d'une erreur repandue :
FORBIDDEN_ACTIONS(src/features/ai/tools/admin-write-tools.ts:62-74) contient des noms de server actions (banUser,adjustCredits,resolveDispute), pas des permissions. Elle ne peut pas servir de definition machine deNON_DELEGABLE: un preset ne contiendra jamais la chaine"banUser", et le garde ne pourrait structurellement pas echouer.
Les scopes
scopeType | Couvre | Lu par |
|---|---|---|
platform | Les surfaces d'equipe cross-org | Uniquement requirePlatformPermission(perm). Jamais unione dans getOrgAccess — c'est ce qui empeche un grant plateforme de devenir un droit d'ecriture sur n'importe quel store via le repli de studioActorFor |
org | Tous les stores de l'org | getOrgAccess(u, org, {require}), getStoreAccess(u, store, {require}) |
store | Ce store seulement | getStoreAccess(u, store, {require}) |
Resolution : union / maximum, jamais intersection ni dernier-ecrit.
Precedence complete :
deny du membre > owner-inherent > allow du membre > presets du membre > grants > baseline de role.
Le role delegate
Role et TenantRole gagnent la meme valeur et restent identiques :
ROLE_DEFAULTS.delegate = [] // permissions.ts — plancher VIDE
ROLE_RANK.delegate = 0 // tenant-auth.ts
isKnownRole → fallback "delegate" // permissions.ts:322 — ferme le fail-open
Trois proprietes gratuites : la chaine monotone s'etend par le bas
(∅ ⊆ viewer ⊆ member ⊆ admin ⊆ owner), donc permissions.test.ts:160-192
passe sans exception ; les quatre appelants de hasMinRole (approbation
d'outils, reset memoire, Browserbase, autonomie L3) refusent un delegue par
construction, sans etre touches ; et ecrire un role inconnu en base
n'accorde plus la facturation en lecture.
Les presets — GRANT_PRESETS, par reference
Les quatre existants gardent leurs ids (rien n'ecrit studioRoles
aujourd'hui, donc aucune donnee a migrer) et sont recables :
| id | permissions |
|---|---|
sales | agency.prospect.read|manage, agency.client.read |
client_ops | agency.prospect.read, agency.client.read|manage, studio.concept.read |
strategist | agency.client.read, studio.concept.read|write|validate |
producer | agency.client.read, studio.concept.read, studio.asset.create, agency.qc.review, studio.drop.publish |
creative_reviewer (nouveau) | studio.concept.read, studio.qc.read, studio.drop.read — voit, ne juge pas, ne publie pas |
brand_producer (nouveau) | studio.concept.read|write, studio.asset.create, studio.qc.read, studio.drop.read, ai.use |
brand_viewer (nouveau) | store.read, studio.concept.read, studio.qc.read, studio.drop.read — pas ai.use |
Regle anti-escalade (modele HubSpot) : on ne peut accorder qu'un preset
dont on detient soi-meme toutes les permissions. Trois lignes dans
createGrant, une refonte si on l'ajoute apres.
studio.drop.publish est exclu de tout preset externe : publier mint un
jeton 192 bits qui survivrait a la revocation.
5. Studio agence vs Studio store
La frontiere est le namespace, pas la requete.
| Section | Aujourd'hui | Apres | Nature |
|---|---|---|---|
~/studio/pipeline — prospects, scoring, relances | studio.prospect.read — tout owner | agency.prospect.read | Plateforme-interne. Prospect est la seule table Studio org-scopee ; un marchand n'a pas de prospects |
~/studio/clients — gates Go/No-Go, score d'onboarding | studio.client.read/manage — tout owner | agency.client.read/manage | Plateforme-interne. « capacity validated », « margin viable » sont NOTRE decision de produire, pas une information client |
~/studio/cockpit — 11 KPI de production | studio.economics.read — tout owner | agency.economics.read | Plateforme-interne. Table comparative inter-clients rendue a quelqu'un qui a une seule ligne |
~/studio/qc — file de verdicts, cadence, spend | studio.qc.review | agency.qc.review | Plateforme-interne. C'est l'acte de juger la production |
~/studio/concepts — avatars, angles, hooks | studio.concept.read | inchange — reste ouvert au marchand sur SES marques | Partage. La production est le produit |
[storeSlug]/studio | 5 ids dont studio.qc.review et studio.client.read | studio.concept.read + studio.qc.read + studio.drop.read, lecture seule | Produit vendu. Le panneau de gate client DISPARAIT de la surface marchande |
Vente de cadence (setCreativeCadence) | studio.cadence.manage | agency.cadence.manage | Plateforme-interne. Engagement commercial |
/drop/[token] | jeton 192 bits, PUBLISHED, rotation | inchange | Produit vendu, sans compte |
Le piege de la navigation
STUDIO_NAV_PERMISSIONS (src/app/(dashboard)/[orgSlug]/layout.tsx:24-32)
vaut aujourd'hui :
studio.qc.review · studio.drop.publish · studio.cadence.manage ·
studio.economics.read · studio.prospect.read · studio.client.read ·
studio.concept.read
Ce n'est pas l'ensemble des 7 agency.* proposes : il contient
studio.concept.read (qui reste marchand) et pas prospect.manage /
client.manage. Le remplacer par « les 7 agency.* » ferait disparaitre
l'onglet Studio d'un marchand dont la page ~/studio/concepts reste
legitime et joignable — le defaut « lien mort / surface injoignable » que
cockpit-surfaces-are-reachable.test.ts:225-226 evalue dans les deux sens.
La liste correcte est AGENCY_PERMISSIONS ∪ { studio.concept.read }.
Ce que perd un owner existant
~/studio/{pipeline,clients,cockpit} rendent notFound(), la board QC
agence aussi, la vente de cadence aussi. Il conserve tout store.*,
billing.*, members.*, settings.update, agent.*, ai.use,
workspace.*, [storeSlug]/studio inchange, et ~/studio/concepts sur ses
propres marques. Aucune donnee n'est touchee.
Ce que la scission ne fait pas
~/studio/concepts reste la meme page sur les memes loaders pour
l'agence et le marchand. La partie melange du grief est reparee, la partie
dedouble reste. Borner cela a une marque est exactement ce que le scope
store apporte, et pas avant. Les donnees ne bougent pas non plus :
Prospect reste orgId, StudioAsset / StudioDrop / CreativeConcept
restent storeID, les quinze colonnes de gate restent sur Store — le
schema guard ne sait ni ALTER COLUMN ni DROP.
6. Delegation freelance / agence
Cote marchand (le client)
- Il genere un code dans
/[orgSlug]/[storeSlug]/settings(Store.collabRequestCode, 4 chiffres, rotatif). Le code n'accorde rien : il autorise seulement a demander. Sa rotation coupe les demandes futures sans casser les mandats en cours (modele Shopify). - Ou il invite directement :
POST /api/organizations/[orgId]/access-grants, gate surmembers.invite. Jeton 256 bits, seul le sha256 stocke, 7 jours, et l'invariant deja en place chezOrganizationInvitationest conserve — l'e-mail de session doit EGALER celui de l'invitation. - L'ecran d'octroi affiche ce qui s'ouvre en DONNEES, en deux listes
(modele Shopify) : « ce prestataire pourra lire les concepts et la file
QC de la marque X » / « il pourra produire des creatives sur la marque X,
aux frais de votre solde ». Un ecran qui liste des noms de pages n'est pas
un consentement eclaire. Il choisit
expiresAt(defaut 90 j, plafond 180 j) et, s'il accordeai.use,monthlyCreditCapUsd. - Il voit ses prestataires dans
/[orgSlug]/~/members, onglet separe de ses membres (modele QuickBooks). - Il revoque unilateralement, sans cooperation du prestataire, en un clic. C'est la ligne rouge : l'anti-modele explicite est GoHighLevel.
Cote prestataire
- Il demande depuis
/collab/requestavec le code, ou il accepte l'invitation. A l'acceptation,acceptedAtetdpaAcceptedAtsont poses — c'est cette ligne, pas une case dans les CGU, qui materialise l'autorisation ecrite prealable de l'Art. 28(2). - Il n'est membre de rien. Aucune ligne
OrganizationMember:requireQuota(orgId, "seats")n'est pas franchi, il n'apparait pas au roster, et son role resolu estdelegate— plancher vide. - Il atterrit sur
/collab, hors[orgSlug]: un hub DERIVE de ses grants actifs, une ligne par marque deleguee avec sa portee et son echeance. Pas de tenant parent, pas de modeleAgency./collab/[seatId]est une PORTE qui redirige vers la premiere section detenue etnotFound()s'il n'en detient aucune — le patron exact de~/studio/page.tsx:59-65. Le proxy autorise deja/account*sans organisation (proxy.ts:579-592) ;/collab*y est ajoute. - Le switch de contexte est ce hub. Cinq marques de cinq orgs = cinq lignes, cinq revocations independantes (modele Shopify : les comptes collaborateurs sont crees par store).
- Il ne mint aucun credential.
storeAccessDenial(MCP) etmayGrantForStore(OAuth) exigent toujours une ligne de membership, donc un delegue y est fail-closed. Limite assumee, pas un oubli.
/api/me — la moitie qui protege reellement
Aujourd'hui le payload est scope a l'appartenance, pas aux permissions :
il livre a tout membre les stores avec leurs grantedScopes de connecteurs,
l'abonnement Stripe, le solde de credits et le roster complet avec les
e-mails (route.ts:174-281, 406-455). Une nav parfaitement filtree ne
protege rien si la donnee est deja partie.
Les select etant deja explicites, le scoping se fait champ par champ :
- Les organisations sont derivees de deux sources (membership + grants
vivants), chaque entree gagnant
accessVia: "owner" | "member" | "grant"etexpiresAt. - Pour une org atteinte par grant :
storesfiltre sur lestoreIddu scope (sinon c'est le portefeuille complet du client livre a un prestataire externe) ;subscriptionomis ;creditsomis saufbilling.read;connectorsreduits aprovider+statussans les scopes ;membersreduit a la seule ligne de l'appelant — ce qui suffit a~/billing/page.tsx:206. - Le client recoit des booleens deja resolus (
can: { "studio.qc.read": true }), jamais l'override brut :hasPermissionn'est importe dans aucun fichier desrc/components/, et c'est un acquis a proteger par un test.
Prerequis non negociable de la meme phase : ~/billing, ~/members,
~/settings et ~/memory sont des pages "use client" sans aucune garde
serveur. Et /[orgSlug]/page.tsx:85-143 fait ses propres requetes
Prisma (roster avec e-mails, 40 evenements, carte credits) que la projection
de /api/me ne touche pas.
Les portes de LECTURE, qui ne doivent pas etre oubliees
Migrer la porte d'ecriture (studioActorFor) sans migrer les lectures
produit l'inverse du produit vendable : un delegue pourrait approuver un
creatif sans voir la file QC.
| Porte | Etat | Ce qu'il faut |
|---|---|---|
studioActorFor (features/studio/guard.ts:129) | getOrgAccess | → getStoreAccess(userId, storeId, { require }). Meme nombre de requetes : il lit deja le store |
studioAccessFor (studio-guard.ts:72-91) | getOrgAccess, signature orgId — un grant store lui est invisible par signature | Variante studioAccessForStore(userId, storeId, permission). Ferme 5 outils @Atlas + GET /api/organizations/[orgId]/studio/[section] sinon |
getStudioPermissions(orgId, permissions[]) (studio-guard.ts:132-149) | lecteur de 10 pages dont le layout org. Nomme un ENSEMBLE, pas une permission : la regle « nomme la permission que tu exiges » n'a pas d'expression pour lui | Variante scopee store qui resout l'ensemble contre les grants du store, retour identique |
[storeSlug]/studio/page.tsx:85-96 | resout le store par prisma.store.findFirst({ where: { organization: { OR: [{ownerId}, {members:{some:{userId}}}] } } }) — appartenance avant toute permission. src/test/tenant-pages-scope-by-membership.test.ts impose ce motif | Ajouter la resolution par grant a la liste d'autorisateurs acceptes du test, avec sa justification |
resolveCycleAssignees (src/services/jobs/handlers/_drop-cycle.ts:83-100) | lit prisma.organizationMember.findMany directement et resout l'assignataire de chaque etape par permission | Sans lui, un delegue producteur ne recoit jamais de tache : la delegation existe, le travail ne l'atteint pas |
Qui paie les credits
L'org proprietaire du store, toujours. Le ledger sait deja distinguer :
Credit { userId: <acteur>, orgId: <org facturee>, type: "usage" }
(runtime/billing.ts:232-235), et handler.ts:284-322 re-derive
obligatoirement l'org active via getOrgAccess apres avoir refuse tout
orgId non verifie.
Par defaut, il voit, il ne depense pas : ai.use n'est dans aucun preset
sauf brand_producer, et canUseAi refuse en 403 de PERMISSION, pas 402
de solde — pour qu'un delegue n'aille pas acheter des credits qui ne
debloqueraient rien.
Quand le proprietaire accorde ai.use : monthlyCreditCapUsd. Le consomme se derive
du ledger existant (SUM(Credit) WHERE orgId AND userId AND type="usage" AND createdAt >= debut du mois) — aucune colonne viaGrantId, aucun compteur
materialise. Quand plusieurs grants s'appliquent, le plafond effectif est
le minimum des plafonds applicables. Lu par credits-check.ts:68-101
et par handler-cost-guard.ts:74-105 — sinon un seul tour long depasse
le plafond sans qu'aucun garde ne le voie.
Daily bonus — un bug d'argent que la delegation rend visible.
daily-bonus/route.ts:363,393 n'evalue l'eligibilite que sur org.ownerId :
une marque operee quotidiennement par un freelance, avec un proprietaire
dormant, perd $1/$3/$5 par jour — la delegation coute de l'argent au
client. Symetriquement, les trois sondes groupent par userId sans
filtre orgId (:120-165) : un owner de N orgs requalifie ses N orgs d'un
seul acte. Correctif unique : la sonde lit l'activite de tout acteur de
l'org (membres + porteurs de grant vivant), filtree par orgId, en
conservant le pinning type: "usage" (:127-140). Cles d'idempotence
inchangees : DailyBonus(orgId, day) et MonthlyReset(orgId, year, month)
sont per-org, un delegue multi-org ne double ni ne collisionne rien.
Revocation, et ce qu'elle ne fait pas
revokeGrant(id, by, reason) pose revokedAt (jamais de DELETE : la table
est son propre journal) et appelle revokeDerivedCredentials(grant) :
OAuthAccessToken / OAuthAuthorizationCode mintes par ce user sur les
stores du scope, IntelligenceApiKey dont il est createdBy, rotation des
StudioDrop.shareToken publies par lui via cutDropLinkFor(mode: "rotate").
Et src/lib/security/ban-enforcement.ts:109, qui revoque deja les ApiToken
en masse au ban, revoque desormais aussi les AccessGrant.
Deux honnetetes requises.
(a) Aujourd'hui un pur delegue ne peut mint aucun de ces credentials : la
cascade est une garantie prospective, ecrite maintenant pour que le jour
ou ces portes apprennent les grants, la revocation couvre deja. Seule la
ligne ban-enforcement est effective immediatement.
(b) Couper un jeton ne rend pas les octets confidentiels : les assets
Studio sont des URLs Vercel Blob publiques sans TTL. La revocation borne la
duree d'exposition de la PAGE, pas celle des fichiers deja copies. Le cron
cleanup-chat-attachments a deja paye cette lecon pour le chat.
Expiration : expiresAt requis (lecon GDAP : « permanent relationships
aren't possible for security reasons »), plafonne a 180 jours, renouvelable
par un acte explicite. Le resolveur ne voit un grant que si
revokedAt IS NULL AND startsAt <= now() AND expiresAt > now() : il cesse
d'etre vu immediatement, la ligne survit comme trace. Le cron
grants-expire (64e sur 100) notifie les deux parties a J-7 et J-1 et
signale les mandats inutilises depuis 90 jours (lastUsedAt, pose des le
depart parce qu'il est impossible a reconstituer plus tard).
Chemin de degradation avant la rupture (modele QuickBooks) :
narrowGrant(id, presets) retire les presets d'ecriture sans couper
l'acces. Le vrai cout d'une revocation est social, pas technique.
RGPD — sous-traitance ulterieure
Chaine par defaut : le marchand est responsable de traitement des donnees
de ses acheteurs, Shopify sous-traitant, BoostEcom sous-traitant du marchand.
Un freelance mandate via un AccessGrant est un sous-traitant ulterieur.
- Accorder est un acte du RESPONSABLE.
createGrantexigemembers.inviteOUsettings.updatesur l'organisation. Un admin plateforme peut lire les grants (support, incident) et ne peut pas en accorder — saufagency.*, reserve au break-glass. C'est l'inverse exact de la situation actuelle, ou la seule UI capable de poser un acces partiel (setMemberPermissions) est derriererequireAdmin: l'autorisation ecrite prealable du responsable n'existe tout simplement pas aujourd'hui, un admin plateforme la donne a sa place. C'est le defaut le plus grave que ce document corrige, et il est juridique avant d'etre ergonomique. - Le grant EST la preuve :
grantedById+reason+createdAt+presets+expiresAt+dpaAcceptedAt, jamais supprimes. - Le registre est derive :
/[orgSlug]/~/settings/privacyliste les sous-traitants ulterieurs (qui, quelle portee, depuis quand, jusqu'a quand, dernier acces). Le marchand produit son registre Art. 30 sans nous ecrire. - La PII est une permission separee (
store.customers.read). La limite passe entre l'agregat et la personne, pas entre lecture et ecriture (modele Triple WhalePII Access, Klaviyo Manager). - La fin du mandat DOIT terminer le sous-traitement : la cascade de revocation est une obligation legale, pas une commodite.
- Journal de consultation sur le precedent
viewKycReport(marketplace/compliance/_actions/index.ts:93) :grant.session_opened, une fois par jour et par mandat. Aujourd'hui consulter le transcript @Atlas d'un client ne laisse aucune trace.
Trois pieges nommes sans etre resolus ici — ils sont des items
type: "decision" (section 9) :
- Des que l'equipe plateforme co-decide les finalites ET les moyens de l'operation d'une marque — les niveaux d'autonomie 3 et 4 de la roadmap — on bascule en responsabilite conjointe (Art. 26) : accord obligatoire, responsabilite solidaire pour l'integralite du dommage. Aucune ligne de code ne signalera ce basculement.
StoreSignalIndex,StoreMetricDaily,MarketCluster,PredictionAccuracysont des agregats cross-clients construits pour NOS finalites : la doctrine EDPB est constante, un sous-traitant qui utilise les donnees pour ses propres finalites devient responsable pour ce traitement.- La responsabilite civile du prestataire qui approuve un creatif portant
une allegation illicite, publie un drop avec un prix faux, ou casse la
vitrine. Il n'y a ni clause d'indemnisation, ni CGU de mandat, ni moyen
pour le marchand de prouver l'imputation au-dela d'un
via: "delegated"sur une ligne d'audit.
7. Le Hub — le besoin qui n'entre pas dans le modele
Le besoin : donner JUSTE le Studio creative, ou JUSTE le Hub.
La porte du Hub n'est pas une permission : c'est getCallerPlan
(src/lib/hub/paywall-gate.ts:43-73), qui parcourt
prisma.organizationMember.findMany({ where: { userId } }) et debloque sur
le meilleur plan paye de n'importe quelle org dont l'utilisateur est
membre.
Un AccessGrant ne creant aucune ligne OrganizationMember, il ne
franchit rien. Un head of growth mandate resout "free" et ne voit que le
teaser.
C'est une phase a part entiere, et elle est independante du reste :
getCallerPlan apprend une seconde source — un grant platform portant
platform.hub.read debloque sans plan. Trois lignes dans un fichier que rien
d'autre ne lit. C'est probablement la plus petite des phases et elle repond a
la moitie de l'exemple.
8. Migration et phasage
La migration de donnees est VIDE, et c'est le point le plus important
Ce qu'on retire aux owners n'a jamais ete stocke : les douze studio.*
viennent du court-circuit permissions.ts:307 et de ROLE_DEFAULTS.owner,
pas d'une ligne. Zero backfill, zero fenetre de double lecture.
Trois editions, dans le meme commit :
// permissions.ts:307
if (member.role === "owner" && !isAgencyPermission(permission)) return true
// permissions.ts:123-134 — les 7 ids qui deviennent agency.* SORTENT de ROLE_DEFAULTS.owner
// tenant-auth.ts:58 — la branche proprietaire cesse de rendre `permissions: {}`
// en dur et lit la ligne OrganizationMember du owner
setMemberPermissions refuse aujourd'hui d'ecrire sur une ligne owner
(_actions/index.ts:264-268) : cette ligne doit etre amendee, sinon le
porteur de agency.* de l'org agence n'a aucun carrier.
L'amorcage, dans la MEME PR que le retrait
Apres le retrait, personne ne detient agency.*. Entre deux merges, le
pipeline commercial serait injoignable par tout le monde.
POST /api/admin/access-grants/bootstrap, via withAdminRoute,
idempotent, avec un dry-run obligatoire (GET) lu avant le POST. Il
pose un allow: [les 7 agency.*] sur la ligne OrganizationMember du owner
de chaque organisation qui pratique reellement le metier d'agence.
Signal unique et correct :
EXISTS (Prospect WHERE orgId = o.id).CreativeConceptetgoProductionAtne sont pas des signaux d'agence :CreativeConcept.storeIDest store-scope (schema.prisma:7238) etgoProductionAtest une colonne deStore(:491) — ce sont des signaux du Studio marchand. Les utiliser reclasserait en agence tout marchand ayant valide un concept, et annulerait la feature.
requireAdmin()nu est formellement interdit dans une route/api/admin/**:src/test/admin-api-answers-json-to-a-non-admin.test.tsl'epingle, parce querequireAdminfinit enredirect("/"), que Next transforme en 307, qui rejoue le POST avec son corps. Tous les nouveaux endpoints admin passent parwithAdminRoute, etadmin-api-single-door.test.tsdoit etre amende.
Retrocompatibilite
- 94 appelants non-test de
getStoreAccesset 55 degetOrgAccess: signature inchangee (parametre optionnel), et sansrequirele resultat est bit-a-bit identique a aujourd'hui. - 55 appelants de
hasAccessPermission: inchanges. hasPermission: signature inchangee. Le grant alimente le champpermissionsen amont, jamais le resolveur — c'est ce qui garantit qu'il n'existe toujours qu'UN resolveur.- 133 fichiers / 239 appels de
requireAdmin: zero touche. La signature ne change pas,admin/layout.tsxne change pas,proxy.tsne change pas. Les deux surfaces d'equipe demenagent sous/ops; les 82 autres pages restent ou elles sont. ServerOrgSnapshot.studioAccessgarde son nom : le renommer touchesrc/config/cockpit-nav.ts, hot file, pour zero gain.- Retrait arriere de la phase 1 : vider
AGENCY_PERMISSIONS. Aucune ligne de base touchee. - Fenetre d'observation :
log.info("permission.agency_denied_outside_grant", { orgId, permission })sur chaque refus du a la nouvelle regle. Si personne ne l'atteint en une semaine, la these est validee par les faits.
Le phasage
L'ordre suit celui des besoins, pas celui de la difficulte
technique. Les permissions allow / deny du script de renommage sont des
string[] non valides a la lecture : readPermissionOverride conserve
n'importe quelle chaine, donc renommer sans migrer rend une permission
silencieusement fausse — allow.includes() ne refuse jamais, il cesse de
matcher. Le meme script renomme, en dry-run d'abord, les 7 ids dans allow
comme dans deny, et readPermissionOverride filtre desormais contre
PERMISSION_IDS a la lecture.
| Phase | Scope | Piliers | Debloque | Ne resout pas encore |
|---|---|---|---|---|
| 1 — La frontiere ✅ LIVREE | Scission agency.* (7) / studio.* (7 dont studio.qc.read, studio.drop.read). Retrait du court-circuit et de ROLE_DEFAULTS.owner. tenant-auth.ts:58. setMemberPermissions fusionne (preserve studioRoles). STUDIO_NAV_PERMISSIONS → AGENCY_PERMISSIONS ∪ {studio.concept.read}. Page marchande re-gatee + lien /~/studio/qc supprime. ROLE_DEFAULTS.delegate = [], isKnownRole → delegate. Bootstrap dry-run puis POST avant le merge. Zero DDL. | security-identity, app-shell, ai-platform — trailer Cross-pillar:. Hot file : CLAUDE.md (la claim api.handlers change) | Le besoin 1, en entier et visible au merge. Aucune org cliente — pas meme son owner — n'atteint le pipeline, les gates, le cockpit, la cadence | L'acces partiel et la delegation |
| 2 — Le Hub | getCallerPlan apprend une seconde source : un grant platform portant platform.hub.read debloque sans plan. Depend de la phase 3 pour le modele, mais c'est 3 lignes | security-identity | La moitie « juste le Hub » du besoin 2 | — |
| 3 — Le mandat ✅ LIVREE | Modele AccessGrant (CEILING 783 → 789). src/lib/security/access-grants.ts : resolveGrants (React.cache), createGrant / narrowGrant / renewGrant / revokeGrant. getOrgAccess / getStoreAccess gagnent {require?}. Les CINQ portes du tableau §6 migrent ensemble — ecriture ET lectures, sinon un delegue approuve sans voir. GRANT_PRESETS + NON_DELEGABLE. Mint via withAdminRoute. ban-enforcement revoque les grants | data-platform, security-identity, app-shell (features/studio/** appartient a app-shell). Hot file : prisma/schema.prisma | Le resolveur sait exprimer « ce delegue, sur cette marque, pour ce metier, jusqu'a cette date », et les cinq portes Studio l'honorent. Delibérement sans UI d'octroi cote owner : /api/me fuit encore | La confidentialite du payload, le hub prestataire, l'expiry automatique |
| 4 — Le siege interne ✅ LIVREE | PLATFORM_PERMISSIONS (4) + requirePlatformPermission, sur le MEME hasPermission via un membre synthetique role: "delegate" — plancher vide, donc seul un mandat vivant ouvre. Racine /ops : /ops/creative/qc (ex-/admin/creative/qc), /ops/growth, /ops/bulletin, /ops/bulletin/health. ops reserve dans les DEUX listes. AtlasToolContext gagne platformPermissions[] a cote d'isPlatformAdmin, et les deux outils Radar / Growth gatent dessus. Zero DDL. | platform-ops, security-identity, app-shell, ai-platform. Hot files : src/config/reserved-segments.ts, src/services/organizations/reserved-slugs.ts, src/proxy.ts, next.config.mjs | Le besoin 2 en entier : un reviewer crea, un head of growth recoivent JUSTE leur surface, sans le ledger, le ban, le refund et les 82 autres pages /admin | La facturation d'un siege partenaire |
5 — Le mandat utilisable ✅ LIVREE (app-shell/0608, complete par app-shell/2793) | Projection /api/me par accessVia (dont stores filtre sur le storeId du scope). Gardes serveur sur ~/billing, ~/members, ~/settings, ~/memory. /[orgSlug] devient une porte a redirection par capacite. /collab + /collab/[seatId]. Octroi par le proprietaire + onglet prestataires. resolveCycleAssignees apprend les grants. Cron grants-expire (64e). Store.collabRequestCode + AccessGrantRequest (relever CEILING une 2e fois) | app-shell, security-identity, platform-ops, billing (garde sur ~/billing). Hot files : vercel.json, CLAUDE.md | Le besoin 3 en production, et la valeur vendable : un freelance opere UNE marque, sans siege, sans voir l'abonnement ni le solde ni les e-mails, avec une echeance, revocable en un clic | Le plafond de depense, la PII separee |
| 6 — Economie et conformite | monthlyCreditCapUsd lu par credits-check et handler-cost-guard (minimum des plafonds). Sonde daily-bonus elargie + filtre orgId manquant. store.customers.read avec ses lecteurs. ~/settings/privacy : registre derive. Journal de consultation. gdpr-erasure purge les grants | ai-platform (credits-check, handler-cost-guard), platform-ops (daily-bonus), data-platform (index Credit — plus un DROP operateur hors PR), security-identity | Un mandat cesse d'etre un cheque en blanc. Le marchand produit son registre Art. 30 seul. Corrige au passage un sur-versement structurel qui coute de l'argent aujourd'hui | L'arete org↔org |
Les deux reports de la phase 5, livres par app-shell/2793
app-shell/0608 a livre la frontiere /collab et l'octroi descendant, et
a nommement REPORTE deux choses. Elles sont la, et le report etait juste :
les livrer plus tot aurait releve le CEILING pour du code que rien
n'exercait.
1. Le cron grants-expire avertit. Il ne coupe rien, et c'est le
point : liveWhere refuse deja un mandat echu a chaque lecture, donc
l'acces s'arrete a l'heure dite, tout seul. Ce qui manquait etait
l'AVERTISSEMENT, J-7 puis J-1, aux deux parties — le prestataire perd
une surface, le proprietaire perd quelqu'un qui travaillait sur sa marque.
L'idempotence vit dans la ligne (AccessGrant.expiryNoticeDay) et non
dans une table de marqueurs : la question « a-t-on prevenu pour CE
mandat » n'a qu'un endroit ou vivre. Un envoi qui echoue ne pose pas le
marqueur, donc il repart demain — pour un avertissement, le doublon coute
moins cher que le silence.
2. Le chemin MONTANT. AccessGrantRequest + Store.collabRequestCode
(le CEILING est bien releve une seconde fois, 804 → 809). Le
proprietaire emet un code par boutique et le donne ; le prestataire le
tape sur /collab et demande. Trois proprietes tiennent la regle :
- une demande n'est jamais un acces. Approuver appelle
createGrant, avec ses trois refus, exactement comme l'octroi descendant. Le module des demandes ne sait pas ecrire unAccessGrant, et la garde le mesure ; - le code designe, il n'autorise pas. Il borne la file du proprietaire a des gens a qui il a donne quelque chose. Sans lui, n'importe quel compte pourrait demander l'acces a n'importe quelle marque ;
- le prestataire n'entre toujours pas cote user. Le formulaire vit
sous
/collab, il appelle/api/collab/requests, et il ne nomme aucune organisation — il ne le peut pas, un demandeur n'est membre de rien.
Garde : src/test/an-access-request-is-never-an-access.test.ts.
Ce que la phase 4 a trouve et que le phasage n'avait pas prevu
Deux choses, et les deux sont des consequences directes de « un interne n'a ni organisation ni boutique » — la phrase que le besoin 2 pose et dont le modele n'avait tire qu'une moitie.
resolveInternalStudioScope resolvait la portee depuis les ADHESIONS.
C'etait juste tant qu'un seul lecteur existait : sous /admin, l'acteur
est le proprietaire, et il est membre de l'organisation interne. Pour un
reviewer recrute, la meme fonction rendait null, donc la page affichait
« Vous n'etes membre d'aucune organisation — ouvrir l'onboarding » : le
tunnel marchand, propose a la personne qu'on venait d'embaucher pour ne
pas le traverser. La portee lit desormais adhesions ∪ organisations
atteintes par un mandat portant la permission NOMMEE par l'appelant
(orgIdsGrantedWith). /admin ne passe pas de permission et ne paie donc
aucune requete de plus.
getStudioPermissions (portee ORG) ne lisait pas les mandats, alors
que son jumeau getStoreStudioPermissions (portee BOUTIQUE) les
composait deja, avec l'argument « l'ensemble demande EST la declaration ».
L'asymetrie n'etait pas un choix, c'etait le cote qui n'avait pas ete
migre en phase 3 — et elle rendait un board d'ORGANISATION 404 pour un
mandataire pendant que le meme board a l'echelle d'une BOUTIQUE lui
repondait. Les deux lisent maintenant la meme chose.
9. Gardes derivees
Tests EXISTANTS qui vont echouer — a amender dans la PR qui les casse
| Garde | Pourquoi | Ce qu'il faut y ecrire |
|---|---|---|
src/lib/security/permissions.test.ts:203-238 | Son univers est derive par id.startsWith("studio.") : les 7 ids renommes sortent silencieusement de l'echantillon, dont l'assertion « the org owner holds all of them ». Le test qui epingle le court-circuit cesse de le mesurer la ou il bouge, et reste vert | Deriver agency. et studio., asserter l'inverse pour agency.*. Plus : chaine monotone etendue a delegate, et hasPermission({role:"delegate", permissions:{}}, p) === false pour CHAQUE entree du registre |
src/test/studio-authorization-doors.test.ts | La porte d'ecriture change de resolveur et les ids changent. Il epingle statiquement, dans les deux sens, l'ensemble des fichiers acceptant un admin plateforme | Decision de securite relue : le repli platform-admin reste dans features/studio/guard.ts, reste absent de prospect-writes.ts, et aucune quatrieme porte n'est creee |
src/services/database/not-null-column-adds.test.ts:83 | CEILING = 783 | → 789 en phase 3, puis une seconde fois en phase 5 (AccessGrantRequest) |
src/test/credential-storage.test.ts:62 | Egalite EXACTE : toEqual(["api-token.ts", "intelligence-api-key.ts"]) | Trois entrees ; et le resolveur lit la base sans construire de Map/Set — une permission mise en cache est une revocation qui n'existe pas |
src/test/admin-api-single-door.test.ts + admin-api-answers-json-to-a-non-admin.test.ts | Deux nouveaux endpoints admin | Passer par withAdminRoute, jamais requireAdmin nu |
src/test/cockpit-surfaces-are-reachable.test.ts:225-226 | Teste studioAccess: true ET false | La nav doit rester coherente avec §5 |
src/test/reserved-org-slugs.test.ts | Derive du disque les trois groupes de routes, (dashboard) inclus | /collab et /ops dans ROUTABLE_ROOT_SEGMENTS en plus de reserved-segments.ts |
org-delete-reaches-every-orphan.test.ts / store-delete-reaches-every-orphan.test.ts | Derivent les modeles portant orgId/storeId sans @relation | Couvert parce que AccessGrant porte les deux colonnes reelles. Traitement explicite dans les deux handlers de suppression |
pnpm docs:claims | 64e cron, nouveaux handlers d'API | pnpm docs:claims:fix (cron.total dans CLAUDE.md et docs/team/roster.md) |
studio-nav-conventions, store-studio-conventions, creative-qc-conventions, prisma-payload-provenance, studio-guard, studio-tools, gate-actions, prospect-actions, outreach-templates | Ils lisent les CHAINES renommees. creative-qc-conventions epingle jusqu'a l'ORDRE TEXTUEL de getStudioPermissions avant loadQcBoard( | Neuf fichiers en phase 1. C'est la vraie masse de la phase 1 |
Gardes NOUVELLES, sans lesquelles ce modele derive
grant-is-opt-in.test.ts— le cliquet central. Derive les appelants degetStoreAccess/getOrgAccessqui ne passent PASrequire, et fige la liste. Elle ne peut que retrecir.grant-single-door.test.ts— aucune requeteprisma.accessGranthors desrc/lib/security/access-grants.ts(nouveau fichier de la phase 3, pas encore sur disque). Empeche qu'unwhere: { grants: { some: { userId } } }pose un jour pour reparer un 404 oublie un des trois predicats de vivacite et fasse fuir la revocation.delegate-has-no-floor.test.ts— pour CHAQUE entree dePERMISSION_REGISTRY,hasPermission({role:"delegate"}, p) === false. Le seul rempart contre l'ajout dedelegateaROLE_DEFAULTS« pour coherence ».agency-permissions-are-platform-minted.test.ts— aucun preset accordable par un owner ne contient d'agency.*, etcreateGrantrefuseagency.*hors d'un appelant admin. Sans elle, tout owner se re-mint le back-office d'agence en un clic dans l'ecran de la phase 5, et la frontiere n'existe que dans la navigation.preset-cannot-escalate.test.ts— aucun preset ne contient une entree deNON_DELEGABLE.permission-is-consumed.test.ts— toute entree dePERMISSION_REGISTRYest LUE par du code non-test, sur le modele deenv-var-consumed.test.ts, avec une liste d'exceptions qui ne peut que retrecir. C'est le defautstudioRoles(declare, resolu, ecrit par personne) generalise.client-does-not-resolve-permissions.test.ts—hasPermissionn'est importe dans aucun fichier desrc/components/, et aucun.includes("studio./"agency./"platform.n'y apparait.me-payload-respects-grant-scope.test.ts— pour une org atteinte par un grantstore-scope, le payload ne contient que ce store, et nisubscription, nicredits, ni les autres membres.
10. Ce qu'on ne fait pas
- On ne divise pas
/admin. 85 pages, un gate au bord, un layout. On demenage les deux surfaces d'equipe sous/ops. Cout assume : une racine de plus a documenter et a reserver a deux endroits. - On ne borne pas
~/studio/conceptsa une marque en phase 1. Un membre d'une org agence lit les concepts de toutes ses marques. Dit plutot que decouvert. - On ne touche pas au repli
platform-admindefeatures/studio/guard.ts:137-139. ToutUser.role === "ADMIN"peut donc lever un Go/No-Go et vendre une cadence sur n'importe quelle org, trace commevia: "platform-admin". Le borner paroutsideOrg()n'est pas transposable (elle compare actx.orgId, la conversation en cours ; une server action cross-org n'en a pas). Le vrai remede est PIM (eligible-puis-active, fenetre courte, journal de DETENTION du pouvoir) — item separe. - On ne cree pas d'arete org↔org. Une agence de cinq personnes sur vingt marques = cent mandats nominatifs. C'est un modele de collaborateur, pas d'agence. Declencheur ecrit : plus de trois humains en rotation chez un meme partenaire.
- On ne traite pas la sortie de relation cote prestataire. Un freelance
qui perd ses mandats perd son portfolio, son historique et ses
apprentissages, sans export. Cote client, sain : tout pend au
Store. Pour une plateforme qui veut recruter des agences, c'est un argument anti-adoption non traite. - On ne transfere pas la propriete d'une organisation.
DevStorePoola deja la machine a etats pour le store Shopify ; le pendant plateforme est un item distinct. - La revocation ne reprend pas les octets deja copies.
11. Items type: "decision" — aucun agent n'a le droit d'y repondre
Chacun doit etre inscrit a docs/ops/operator-decisions.md, sinon
src/test/operator-decisions-register.test.ts echoue en le nommant.
- Un mandat partenaire est-il facture ? Aujourd'hui non par
construction :
PLAN_PRICING.additionalSeatsvaut 0 sur les cinq tiers (billing-plans.ts:641-645) et un mandat ne cree aucune ligneOrganizationMember. Recommandation a inscrire : rester a $0 (modele Shopify collaborator / HubSpot Partner Seat), plafonner parPLAN_LIMITS[plan].guestGrants(free 1 / pro 3 / max_5x 10 / max_20x 25 / custom illimite), compte en identites distinctes par organisation. Engage un prix. - Un mandat est-il autorise sur le plan Free ? Il l'est mecaniquement,
et le delegue y sera bloque par
enforcePaidPlansur toute surface IA. Double question : ergonomique (afficher le gating au moment de l'invitation) et contractuelle (BoostEcom fait traiter les donnees clients d'un tiers par un quatrieme acteur, sans contrat et sans revenu). - Le plafond de credits par defaut d'un mandat portant
ai.use: 0, illimite, ou une fraction du solde ? Engage une ligne de depense cote client. - Une organisation operee par la plateforme exige-t-elle un accord de responsabilite conjointe signe (Art. 26) ? Responsabilite solidaire pour l'integralite du dommage. Le risque augmente avec les niveaux d'autonomie 3 et 4 de la roadmap.
- Les donnees d'une marque operee par nous alimentent-elles le pipeline
Intelligence ?
StoreSignalIndex,StoreMetricDaily,MarketCluster,PredictionAccuracysont construits pour NOS finalites, ce qui nous rend responsable pour ce traitement au sens EDPB. Base legale propre requise, ou exclusion explicite. - Quelle responsabilite civile pour un prestataire qui casse un store
client ? Clause d'indemnisation, CGU de mandat, et moyen de preuve de
l'imputation. Aujourd'hui : rien au-dela d'un
via: "delegated"sur une ligne d'audit. - Qui paie la production Studio interne ?
trackStudioMediaUsageretournenullsansorgId(generation gratuite ET invisible au ledger), etCredit { userId, orgId }fait payer l'org de l'appelant. Quand l'equipe plateforme produit depuis son org agence, elle se debite elle-meme, et ces lignes apparaissent comme de la consommation client dans le P&L. La scissionagency.*/studio.*rend l'ecart lisible sans le corriger. Lie a l'incoherence de cout de gros deja epinglee parstudio-media-units-have-a-cost.test.ts.
L'identite de service « review » (security-identity/3035)
La capture video du cinema (creative/pipeline/capture-video.mjs, qui ouvre
/ops/creative/cinema/<scene>?capture=1) et les revues automatisees ont
besoin d'une session qui tient platform.creative.review. Avant cet item, la
seule reponse etait de semer un utilisateur e2e- et de demander au
proprietaire de lui accorder le mandat a la main. Elle est remplacee par UNE
identite non humaine, que la plateforme provisionne elle-meme.
Qui elle est
- adresse
review-bot@service.boostecom.invalid(REVIEW_SERVICE_EMAIL,src/lib/security/service-account-identity.ts)..invalidest reserve (RFC 2606 / 6761) : aucune boite ne peut recevoir un code, personne ne peut racheter le domaine. Tout le domaineservice.boostecom.invalidest traite comme « identite de service » ; - aucun drapeau en base : l'adresse EST le drapeau. Pas de colonne, donc pas de delta de schema a propager ;
role: USER,onboarded: true, desinscrite des e-mails non transactionnels, membre d'AUCUNE organisation.
Ce qu'elle tient : une permission, trois portes
platform.creative.review, par un mandat de portee platform, sans preset.
Chacune des trois portes suffit seule :
| Porte | Ou | Ce qu'elle refuse |
|---|---|---|
| Ecriture | createGrant (access-grants.ts) | tout autre mandat pour ce sujet, lie ou en attente sur son adresse, quelle que soit la console qui le demande |
| Lecture | platformPermissionsFor, isPlatformAdminUser | un mandat pose a la main en base (filtre sur SERVICE_ACCOUNT_ALLOWED_PERMISSIONS), un role bascule a ADMIN |
| Session | callbacks jwt et session (src/modules/auth/server.ts) | la session ELLE-MEME : le callback jwt jette sur tout jeton de service, donc NextAuth ne la resout nulle part (cf. « Une session bornee » ci-dessous, security-identity/3070) |
Et elle ne se connecte jamais par code : send-otp et authorize refusent
son adresse.
Les gestes (admin plateforme uniquement)
/api/security/service-account, derriere withAdminRoute (CSRF d'origine,
requireAdmin, 10 appels / minute) :
| Geste | Appel | Journal (AdminAuditLog) |
|---|---|---|
| Etat | GET | aucun (lecture) |
| Provisionner | POST {"action":"ensure"} | service_account.created, grant.granted, service_account.role_reset, grant.revoked pour un mandat en trop |
| Minter une session | POST {"action":"session","minutes":60} | service_account.session_minted (duree, jamais le jeton) |
| Couper | DELETE (corps optionnel {"reason":"..."}) | service_account.revoked (nombre de mandats coupes) |
ensure est idempotent : sur un etat conforme il n'ecrit ni ne journalise
rien. Il REFUSE deux etats qui demandent un humain : un compte banni, et une
adhesion a une organisation (il ne supprime pas une adhesion en silence). Le
mandat dure 90 jours (plafond des mandats : 180) ; rejouer ensure apres
l'echeance en emet un nouveau.
La coupure revoque tous les mandats, lies ou en attente. Le compte reste : il
porte l'historique. Une session deja mintee n'ouvre plus RIEN des la requete
suivante : la porte relit a chaque requete le mandat nomme par la claim
svcGrant, et une re-provision ne la ressuscite pas (le mandat ressuscite
est re-date, la claim svcGrantAt ne correspond plus).
Le mint refuse aussi quand l'identite est membre d'une organisation ou tient un jeton MCP vivant : c'est la trace d'une session sortie de son perimetre, a defaire par un humain avant toute nouvelle emission.
Nourrir CINEMA_SESSION_COOKIE
La session est le meme JWE NextAuth que l'application emet, signe par le
deploiement lui-meme : l'operateur n'a jamais a detenir NEXTAUTH_SECRET, et
aucun secret nouveau n'existe. Duree 1 a 240 minutes (defaut 60). Son
echeance est la claim svcExp, pas l'exp du cookie (cf. ci-dessous).
# depuis un navigateur connecte en admin, ou avec le cookie admin :
curl -s -X POST https://www.boostecom.app/api/security/service-account \
-H 'content-type: application/json' -H 'origin: https://www.boostecom.app' \
-b "__Secure-bst.session-token=<cookie admin>" \
-d '{"action":"session","minutes":60}'
# -> {"token":"...","cookieName":"__Secure-bst.session-token","expiresAt":"..."}
CINEMA_SESSION_COOKIE=<token> CINEMA_SESSION_COOKIE_NAME=<cookieName> \
npm run capture:video -- --scene home-hero --fps 60
Le jeton ne s'ecrit jamais dans un fichier du depot ni dans un journal. En CI, il se passe comme un secret de job de courte duree, jamais comme un secret de depot permanent.
Une session bornee : refuser partout, ouvrir une porte (security-identity/3070)
L'audit de la session a trouve deux defauts majeurs dans la premiere version :
la session durait en realite trente jours des qu'elle passait par
GET /api/auth/session (NextAuth re-encode le jeton a session.maxAge), puis
glissait par le roulement du proxy ; et elle etait une session USER
complete, capable de creer une organisation dont elle devenait OWNER, puis
des cles bei_ qui survivaient a la revocation. Les deux sont fermes ainsi :
| Couche | Ou | Ce qu'elle fait |
|---|---|---|
| Echeance absolue | claims svcIat, svcExp, svcGrant, svcGrantAt posees au mint (service-account.ts), lues par liveServiceClaims (service-session-claims.ts) | svcExp vit dans la charge chiffree : aucun re-encodage ne la deplace. Au-dela, la session n'est plus reconnue, quel que soit l'exp du JWE |
| Refus central | callback jwt (src/modules/auth/server.ts) | jette sur tout jeton de service. getServerSession rend null (donc getSession, withSessionAuth, toutes les routes), et la route /api/auth/session EFFACE le cookie au lieu de le re-encoder |
| Le bord | serviceSessionVerdict dans src/proxy.ts | une session de service n'est reconnue que pour un GET/HEAD d'une page de REVIEW_SERVICE_PAGE_PREFIXES (le plateau cinema), et jamais prolongee (rollSessionCookie l'ignore). Partout ailleurs, action serveur comprise, la requete est anonyme |
| La porte | resolveReviewServiceActor (review-service-session.ts) | n'ouvre que sur le tampon HMAC que le proxy pose (et retire toujours d'une requete client), relit le compte (banni ?) et le mandat nomme par svcGrant a chaque requete. Deux appelants : requirePlatformPermission (pour platform.creative.review seulement) et le layout de /ops |
L'echange du ticket de capture (POST /api/creative/capture-session) relit
aussi le DEMANDEUR : banni ou retrograde depuis l'emission du ticket, il
recoit invalid_ticket, et la raison va au journal serveur.
Ce que cette identite ne resout pas
- une session mintee ne se revoque pas en tant que cookie (strategie JWT) : elle reste dans le navigateur qui la tient, mais n'ouvre plus rien des la requete qui suit la coupure ;
- ce qui a ete cree AVANT
security-identity/3070avec une telle session (une organisation, des clesbei_) n'est pas defait ici : le mint refuse tant qu'une adhesion ou un jeton MCP vivant existe, et c'est a un humain de les retirer ; - la CI totalement autonome (sans admin qui mint) demanderait un script
local qui signe avec
NEXTAUTH_SECRET, donc que ce secret quitte Vercel. Ce n'est pas fait, volontairement : decision de gouvernance.
Gardes : src/lib/security/service-account.test.ts,
src/lib/security/service-session-claims.test.ts,
src/modules/auth/service-session-refused.test.ts (le vrai handler NextAuth
et POST /api/organizations), src/lib/security/service-session-proxy.test.ts,
src/test/review-service-door.test.ts (l'inventaire des routes et des
pages), src/app/api/creative/capture-session/route.test.ts et
src/app/api/auth/send-otp/service-account.test.ts.
Annexe — methode
Ce document est la synthese d'un audit parallele de la codebase (9 lectures
ciblees : Studio agence, Studio store, resolveur RBAC, flag plateforme,
mecanismes de delegation existants, Hub/Growth, couplage facturation,
coutures du schema, navigation), d'une revue de l'etat de l'art (Shopify,
Google Ads/GA4, Meta Business, GitHub, Vercel, Notion, Figma, Slack, Stripe,
QuickBooks/Xero, HubSpot, Klaviyo, Zanzibar/SpiceDB/OpenFGA, GoHighLevel), de
quatre propositions d'architecture independantes jugees chacune sous quatre
angles adversaires (securite, faisabilite, produit, coherence), et d'une
passe de critique de completude qui a corrige sept erreurs factuelles de la
synthese initiale — dont le numero d'ADR, la liste reelle de
STUDIO_NAV_PERMISSIONS, l'interdiction de requireAdmin nu dans
/api/admin/**, et l'oubli complet du Hub.