ArchitectureAcces delegue et frontiere Studio

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.app ont été retirés. Le contrat CURRENT est docs/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 scission agency.* / studio.*, la fermeture du court-circuit owner, puis le modele AccessGrant et 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], de studio.drop.*, studio.gate.read, agency.prospect.*, agency.client.*, agency.cadence.manage et des presets sales / client_ops decrit du code supprime. AccessGrant, studio.concept.*, studio.qc.read, studio.economics.read, agency.qc.review et agency.economics.read restent vivants.

Quatre choses que l'implementation a dementies, corrigees ci-dessous plutot que laissees comme des affirmations perimees :

  1. credential-storage.test.ts ne bouge PAS. Il derive les fichiers qui exportent un resolve*/validate* et nomment un keyHash / tokenHash ; access-grants.ts n'a pas de jeton en phase 3, donc il n'entre pas dans l'echantillon.
  2. Les deux gardes d'orphelins ne demandent AUCUN traitement manuel dans les handlers de suppression : AccessGrant porte de vraies FK onDelete: Cascade vers Organization et Store, donc Postgres garantit la purge. Une garantie de base bat un handler qu'on peut oublier de mettre a jour.
  3. lastUsedAt, acceptedAt, dpaAcceptedAt et monthlyCreditCapUsd ne sont PAS dans la table. Une colonne que rien n'ecrit est le defaut studioRoles sous un autre nom ; elles arriveront avec leur ecrivain, et une colonne nullable ajoutee plus tard est libre.
  4. Le cliquet grant-is-opt-in.test.ts n'a pas ete ecrit sous forme de liste figee. La propriete est prouvee par le COMPORTEMENT (access-grants.test.ts compte les lectures de la table et exige zero sans require), 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.

  1. Le Studio agence est melange au Studio marchand.
  2. Il n'existe aucun moyen de donner un acces partiel a un interne, un partenaire ou un reviewer.
  3. 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 :

FaitPreuve
hasPermission rend true pour TOUTE permission des role === "owner", avant deny, allow, presets et baselinesrc/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 dursrc/lib/security/tenant-auth.ts:58
Le layout org calcule studioAccess sur sept studio.* et dessine l'ongletsrc/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-576 refuse /admin* au bord, sur le claim role du JWT. Un requireAdmin(permission?) retrocompatible ne rend donc aucune page /admin atteignable a un non-ADMIN.
  • src/app/(dashboard)/admin/layout.tsx:51 fait await 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), resout store.orgId puis appelle getOrgAccess, jamais getStoreAccess. Idem en lecture : studioAccessFor (studio-guard.ts:81) et getStudioPermissions.
  • Les quatre portes reconstruisent l'objet passe au resolveur : hasPermission({ role: access.role, permissions: access.permissions }, p). Tout champ ajoute a OrgAccess est jete par 100 % du Studio.
  • Sur les ~94 appelants non-test de getStoreAccess, plus de 40 ne consultent jamais hasPermission — 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

PatternQui l'utiliseCe qu'on en prendCe qu'on en laisse
Grant scope (sujet, role, portee, fenetre) comme LIGNE, pas colonneAzure role assignment, GCP role binding, AWS account assignmentLa forme exacte, les deux index (qui peut X / qui accede a CE store), revokedAt plutot que DELETELe 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 ENTRANTShopifyZero siege consomme, la demande initiee par le prestataire, le code qui n'accorde rienLe trou « Develop apps » : un collaborateur retire laisse un token Admin API vivant
Partner access entite → entiteMeta Business Portfolio, Google Ads MCC, Xero HQLe client accorde a l'entite, l'entite gere ses gensL'arete org↔org tout de suite : prematuree a N ≤ 5 freelances
Permission set par REFERENCE + regle anti-escaladeSalesforce, AWS IAM Identity Center, HubSpotPresets 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 indexableSpiceDB (use expiration), GDAP (2 ans max, « permanent relationships aren't possible »)expiresAt requis sur tout mandat externe, notification J-7Le moteur de conditions CEL/Rego : un second langage, des decisions illisibles
Base permissions ne s'appliquent PAS aux outside collaboratorsGitHubLe plancher vide porte par le TYPE d'acteur, pas par une soustractionLes custom org roles (surface de configuration enorme)
Invite ton comptable, gratuit, borne, revocable, onglet separeQuickBooks, XeroL'onglet separe des membres : voir d'un coup d'oeil qui n'est pas de la maison—
Sub-account / white-labelGoHighLevelRienTout : 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) et getStoreAccess(u, store) sans require rendent exactement ce qu'ils rendent aujourd'hui : l'appartenance, rien d'autre. Avec require, ils composent appartenance ∪ grants et rendent null si 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 :

  1. 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.
  2. 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.
  3. La migration est site par site, visible en diff, et chaque site migre declare sa permission.
  4. Elle rend ecrivable le cliquet derive : « tout appelant de getStoreAccess qui ne passe pas require est sur une liste qui ne peut que retrecir ».

Alternatives ecartees

AlternativePourquoi 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.rolerole 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 agencefeatureFlags 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 /adminBloque 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 grantLe 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

ChangementPhaseVerdict guard
AccessGrantRequest (forme copiee de OrganizationInvitation, schema.prisma:247-265)5Table neuve → libre. Relever CEILING une SECONDE fois (≈ 789 → 794), sinon pnpm db:guard echoue
Store.collabRequestCode String?5Colonne 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 de NON_DELEGABLE : un preset ne contiendra jamais la chaine "banUser", et le garde ne pourrait structurellement pas echouer.

Les scopes

scopeTypeCouvreLu par
platformLes surfaces d'equipe cross-orgUniquement 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
orgTous les stores de l'orggetOrgAccess(u, org, {require}), getStoreAccess(u, store, {require})
storeCe store seulementgetStoreAccess(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 :

idpermissions
salesagency.prospect.read|manage, agency.client.read
client_opsagency.prospect.read, agency.client.read|manage, studio.concept.read
strategistagency.client.read, studio.concept.read|write|validate
produceragency.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.

SectionAujourd'huiApresNature
~/studio/pipeline — prospects, scoring, relancesstudio.prospect.read — tout owneragency.prospect.readPlateforme-interne. Prospect est la seule table Studio org-scopee ; un marchand n'a pas de prospects
~/studio/clients — gates Go/No-Go, score d'onboardingstudio.client.read/manage — tout owneragency.client.read/managePlateforme-interne. « capacity validated », « margin viable » sont NOTRE decision de produire, pas une information client
~/studio/cockpit — 11 KPI de productionstudio.economics.read — tout owneragency.economics.readPlateforme-interne. Table comparative inter-clients rendue a quelqu'un qui a une seule ligne
~/studio/qc — file de verdicts, cadence, spendstudio.qc.reviewagency.qc.reviewPlateforme-interne. C'est l'acte de juger la production
~/studio/concepts — avatars, angles, hooksstudio.concept.readinchange — reste ouvert au marchand sur SES marquesPartage. La production est le produit
[storeSlug]/studio5 ids dont studio.qc.review et studio.client.readstudio.concept.read + studio.qc.read + studio.drop.read, lecture seuleProduit vendu. Le panneau de gate client DISPARAIT de la surface marchande
Vente de cadence (setCreativeCadence)studio.cadence.manageagency.cadence.managePlateforme-interne. Engagement commercial
/drop/[token]jeton 192 bits, PUBLISHED, rotationinchangeProduit 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)

  1. 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).
  2. Ou il invite directement : POST /api/organizations/[orgId]/access-grants, gate sur members.invite. Jeton 256 bits, seul le sha256 stocke, 7 jours, et l'invariant deja en place chez OrganizationInvitation est conserve — l'e-mail de session doit EGALER celui de l'invitation.
  3. 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 accorde ai.use, monthlyCreditCapUsd.
  4. Il voit ses prestataires dans /[orgSlug]/~/members, onglet separe de ses membres (modele QuickBooks).
  5. Il revoque unilateralement, sans cooperation du prestataire, en un clic. C'est la ligne rouge : l'anti-modele explicite est GoHighLevel.

Cote prestataire

  1. Il demande depuis /collab/request avec le code, ou il accepte l'invitation. A l'acceptation, acceptedAt et dpaAcceptedAt sont poses — c'est cette ligne, pas une case dans les CGU, qui materialise l'autorisation ecrite prealable de l'Art. 28(2).
  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 est delegate — plancher vide.
  3. 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 modele Agency. /collab/[seatId] est une PORTE qui redirige vers la premiere section detenue et notFound() 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.
  4. 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).
  5. Il ne mint aucun credential. storeAccessDenial (MCP) et mayGrantForStore (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" et expiresAt.
  • Pour une org atteinte par grant : stores filtre sur le storeId du scope (sinon c'est le portefeuille complet du client livre a un prestataire externe) ; subscription omis ; credits omis sauf billing.read ; connectors reduits a provider + status sans les scopes ; members reduit 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 : hasPermission n'est importe dans aucun fichier de src/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.

PorteEtatCe 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 signatureVariante 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 luiVariante scopee store qui resout l'ensemble contre les grants du store, retour identique
[storeSlug]/studio/page.tsx:85-96resout 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 motifAjouter 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 permissionSans 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.

  1. Accorder est un acte du RESPONSABLE. createGrant exige members.invite OU settings.update sur l'organisation. Un admin plateforme peut lire les grants (support, incident) et ne peut pas en accorder — sauf agency.*, 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 derriere requireAdmin : 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.
  2. Le grant EST la preuve : grantedById + reason + createdAt + presets + expiresAt + dpaAcceptedAt, jamais supprimes.
  3. Le registre est derive : /[orgSlug]/~/settings/privacy liste les sous-traitants ulterieurs (qui, quelle portee, depuis quand, jusqu'a quand, dernier acces). Le marchand produit son registre Art. 30 sans nous ecrire.
  4. 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 Whale PII Access, Klaviyo Manager).
  5. La fin du mandat DOIT terminer le sous-traitement : la cascade de revocation est une obligation legale, pas une commodite.
  6. 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, PredictionAccuracy sont 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). CreativeConcept et goProductionAt ne sont pas des signaux d'agence : CreativeConcept.storeID est store-scope (schema.prisma:7238) et goProductionAt est une colonne de Store (: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.ts l'epingle, parce que requireAdmin finit en redirect("/"), que Next transforme en 307, qui rejoue le POST avec son corps. Tous les nouveaux endpoints admin passent par withAdminRoute, et admin-api-single-door.test.ts doit etre amende.

Retrocompatibilite

  • 94 appelants non-test de getStoreAccess et 55 de getOrgAccess : signature inchangee (parametre optionnel), et sans require le resultat est bit-a-bit identique a aujourd'hui.
  • 55 appelants de hasAccessPermission : inchanges.
  • hasPermission : signature inchangee. Le grant alimente le champ permissions en 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.tsx ne change pas, proxy.ts ne change pas. Les deux surfaces d'equipe demenagent sous /ops ; les 82 autres pages restent ou elles sont.
  • ServerOrgSnapshot.studioAccess garde son nom : le renommer touche src/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.

PhaseScopePiliersDebloqueNe resout pas encore
1 — La frontiere ✅ LIVREEScission 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 cadenceL'acces partiel et la delegation
2 — Le HubgetCallerPlan 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 lignessecurity-identityLa moitie « juste le Hub » du besoin 2—
3 — Le mandat ✅ LIVREEModele 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 grantsdata-platform, security-identity, app-shell (features/studio/** appartient a app-shell). Hot file : prisma/schema.prismaLe 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 encoreLa confidentialite du payload, le hub prestataire, l'expiry automatique
4 — Le siege interne ✅ LIVREEPLATFORM_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.mjsLe 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 /adminLa 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.mdLe 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 clicLe plafond de depense, la PII separee
6 — Economie et conformitemonthlyCreditCapUsd 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 grantsai-platform (credits-check, handler-cost-guard), platform-ops (daily-bonus), data-platform (index Credit — plus un DROP operateur hors PR), security-identityUn 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'huiL'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 un AccessGrant, 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

GardePourquoiCe qu'il faut y ecrire
src/lib/security/permissions.test.ts:203-238Son 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 vertDeriver 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.tsLa porte d'ecriture change de resolveur et les ids changent. Il epingle statiquement, dans les deux sens, l'ensemble des fichiers acceptant un admin plateformeDecision 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:83CEILING = 783→ 789 en phase 3, puis une seconde fois en phase 5 (AccessGrantRequest)
src/test/credential-storage.test.ts:62Egalite 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.tsDeux nouveaux endpoints adminPasser par withAdminRoute, jamais requireAdmin nu
src/test/cockpit-surfaces-are-reachable.test.ts:225-226Teste studioAccess: true ET falseLa nav doit rester coherente avec §5
src/test/reserved-org-slugs.test.tsDerive 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.tsDerivent les modeles portant orgId/storeId sans @relationCouvert parce que AccessGrant porte les deux colonnes reelles. Traitement explicite dans les deux handlers de suppression
pnpm docs:claims64e cron, nouveaux handlers d'APIpnpm 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-templatesIls 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

  1. grant-is-opt-in.test.ts — le cliquet central. Derive les appelants de getStoreAccess / getOrgAccess qui ne passent PAS require, et fige la liste. Elle ne peut que retrecir.
  2. grant-single-door.test.ts — aucune requete prisma.accessGrant hors de src/lib/security/access-grants.ts (nouveau fichier de la phase 3, pas encore sur disque). Empeche qu'un where: { grants: { some: { userId } } } pose un jour pour reparer un 404 oublie un des trois predicats de vivacite et fasse fuir la revocation.
  3. delegate-has-no-floor.test.ts — pour CHAQUE entree de PERMISSION_REGISTRY, hasPermission({role:"delegate"}, p) === false. Le seul rempart contre l'ajout de delegate a ROLE_DEFAULTS « pour coherence ».
  4. agency-permissions-are-platform-minted.test.ts — aucun preset accordable par un owner ne contient d'agency.*, et createGrant refuse agency.* 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.
  5. preset-cannot-escalate.test.ts — aucun preset ne contient une entree de NON_DELEGABLE.
  6. permission-is-consumed.test.ts — toute entree de PERMISSION_REGISTRY est LUE par du code non-test, sur le modele de env-var-consumed.test.ts, avec une liste d'exceptions qui ne peut que retrecir. C'est le defaut studioRoles (declare, resolu, ecrit par personne) generalise.
  7. client-does-not-resolve-permissions.test.ts — hasPermission n'est importe dans aucun fichier de src/components/, et aucun .includes("studio. / "agency. / "platform. n'y apparait.
  8. me-payload-respects-grant-scope.test.ts — pour une org atteinte par un grant store-scope, le payload ne contient que ce store, et ni subscription, ni credits, 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/concepts a 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-admin de features/studio/guard.ts:137-139. Tout User.role === "ADMIN" peut donc lever un Go/No-Go et vendre une cadence sur n'importe quelle org, trace comme via: "platform-admin". Le borner par outsideOrg() n'est pas transposable (elle compare a ctx.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. DevStorePool a 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.

  1. Un mandat partenaire est-il facture ? Aujourd'hui non par construction : PLAN_PRICING.additionalSeats vaut 0 sur les cinq tiers (billing-plans.ts:641-645) et un mandat ne cree aucune ligne OrganizationMember. Recommandation a inscrire : rester a $0 (modele Shopify collaborator / HubSpot Partner Seat), plafonner par PLAN_LIMITS[plan].guestGrants (free 1 / pro 3 / max_5x 10 / max_20x 25 / custom illimite), compte en identites distinctes par organisation. Engage un prix.
  2. Un mandat est-il autorise sur le plan Free ? Il l'est mecaniquement, et le delegue y sera bloque par enforcePaidPlan sur 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).
  3. 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.
  4. 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.
  5. Les donnees d'une marque operee par nous alimentent-elles le pipeline Intelligence ? StoreSignalIndex, StoreMetricDaily, MarketCluster, PredictionAccuracy sont construits pour NOS finalites, ce qui nous rend responsable pour ce traitement au sens EDPB. Base legale propre requise, ou exclusion explicite.
  6. 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.
  7. Qui paie la production Studio interne ? trackStudioMediaUsage retourne null sans orgId (generation gratuite ET invisible au ledger), et Credit { 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 scission agency.* / studio.* rend l'ecart lisible sans le corriger. Lie a l'incoherence de cout de gros deja epinglee par studio-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). .invalid est reserve (RFC 2606 / 6761) : aucune boite ne peut recevoir un code, personne ne peut racheter le domaine. Tout le domaine service.boostecom.invalid est 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 :

PorteOuCe qu'elle refuse
EcriturecreateGrant (access-grants.ts)tout autre mandat pour ce sujet, lie ou en attente sur son adresse, quelle que soit la console qui le demande
LectureplatformPermissionsFor, isPlatformAdminUserun mandat pose a la main en base (filtre sur SERVICE_ACCOUNT_ALLOWED_PERMISSIONS), un role bascule a ADMIN
Sessioncallbacks 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) :

GesteAppelJournal (AdminAuditLog)
EtatGETaucun (lecture)
ProvisionnerPOST {"action":"ensure"}service_account.created, grant.granted, service_account.role_reset, grant.revoked pour un mandat en trop
Minter une sessionPOST {"action":"session","minutes":60}service_account.session_minted (duree, jamais le jeton)
CouperDELETE (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.

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 :

CoucheOuCe qu'elle fait
Echeance absolueclaims 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 centralcallback 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 bordserviceSessionVerdict dans src/proxy.tsune 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 porteresolveReviewServiceActor (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/3070 avec une telle session (une organisation, des cles bei_) 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.