Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/FORTRESS_DEDICATED_FOLDERS_TEST_20261028.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Dossiers dédiés via l’API Drive — candidat TEST du 28 octobre 2026

Le parcours existant crée et relit un dossier dédié, mais impose ensuite Google Picker. Ce candidat ajoute une sélection explicite parmi les dossiers déjà autorisés à l’application. Il ne change aucun réglage de cookies ou de navigateur et ne prétend pas réparer le contrôle manuel du navigateur cloud.

## Périmètre et autorité

- RC : base 7e5cd990f7f736ab77220bb2b35fad643407e672 ; CV : base 3675ec1128675e007a1e341379bf28c8eebe6431, qui reprend le durcissement CSP séparé.
- Branches TEST isolées, PR en brouillon, aucune fusion ni production. Les résultats appartiennent au SHA effectivement testé.
- Origines autorisées : rc-drive-test-ipoower.pages.dev et cv-drive-test-ipoower.pages.dev.

## Contrôles et impact

La requête officielle files.list utilise le scope existant drive.file, l’espace drive, le propriétaire courant, le type dossier, l’absence de corbeille et la propriété privée cv_contract=rc-folder-v1. Chaque réponse est contrôlée à nouveau. Un résultat incomplet, un doublon, une boucle ou le dépassement de 10 pages / 1 000 dossiers bloque la liste au lieu de la tronquer.

La sélection est explicite et limitée aux identifiants de la liste obtenue dans la session courante. Le compte est relu ; propriétaire, contrat et droit d’ajouter des fichiers sont relus avant de calculer la liaison existante client/compte/dossier. Une déconnexion ou expiration invalide la liste. Les noms sont rendus comme texte, y compris s’ils contiennent du HTML.

Aucun ajout de scope, jeton persistant, nouvel endpoint serveur, crypto, schéma de coffre, file durable, migration, suppression, permission Google, règle de sécurité, dépendance ou workflow. Les paquets restent chiffrés par RC et opaques pour CV. Les lecteurs et le stockage existants sont réutilisés. Le transport RC est reconstruit par tools/vendor-vault-drive.js à partir des sources CV, avec empreintes dans son en-tête.

Le parcours Picker est conservé, de même que les anciens échecs documentés. Les tests Google simulé vérifient les deux parcours ; la voie API est également éprouvée avec Picker rendu indisponible dans le harnais. Cela n’établit pas la cause d’une panne iPhone ou du contrôle manuel cloud.

## Validation et limites

Tests ciblés : pagination complète, propriétaire/contrat/type/capacité, réponse incomplète ou tardive, liste invalidée, compte changé, liaison équivalente à Picker, conservation des files et des refus de récupération avant écriture. Parcours navigateur : sélection explicite, nom hostile rendu comme texte, upload et relecture avec réseau Google simulé.

La CI et le manifeste de chaque nouvel artefact doivent être attestés avant publication TEST. Une CI verte ne prouve ni transfert Google réel, ni installation vierge indépendante, ni persistance physique. Aucun RPO/RTO n’est déduit des minuteries.

## Retour arrière

Ne pas publier le candidat, ou remettre le précédent artefact TEST attesté après contrôle des octets. Le contrat de stockage et les codecs sont inchangés ; aucune purge n’est requise. Préserver tous les dossiers et révisions créés. Un retour du code ne constitue pas un retour des données. Les preuves de compatibilité existantes restent datées et ne couvrent pas des migrations futures.

## Références officielles

- https://developers.google.com/workspace/drive/api/guides/api-specific-auth
- https://developers.google.com/workspace/drive/api/guides/search-files
- https://developers.google.com/workspace/drive/api/reference/rest/v3/files/list

## Réserve mobile reproduite puis corrigée

Le run CV 40608745070, tête `36447d5a2b5fe4e417e332913d18fa80273b45b0`, conserve un échec Chromium 414×896 : le clic sur « Utiliser ce dossier » est intercepté après rendu d’un nom long. La classe générique de ligne prévoit trois colonnes dont une icône ; les entrées Drive n’ont que deux enfants. Le correctif donne aux seules entrées Drive deux colonnes puis une disposition verticale sur mobile, avec retour à la ligne des noms et métadonnées. Le même défaut de structure des versions distantes est corrigé. Aucun test supprimé, aucun clic forcé, aucune assertion assouplie.

Avant : 161 parcours réussis, 14 ignorés, 1 échec (reproduit à la relance). Après : nouveau run requis sur le nouveau SHA. Build local réussi ; le navigateur local n’a pas démarré, car Vite sur 0.0.0.0 échoue sur `uv_interface_addresses` dans cet environnement. Cette limite locale ne vaut pas résultat navigateur.
29 changes: 23 additions & 6 deletions src/services/connected-drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { readRacePacket, type RacePacket } from '../core/race-packet';
import { DriveStore } from './drive-store.js';
import { GoogleDriveSession, type DriveConfig, type DriveSession } from './google-drive-session.js';
import { OpaqueArchive } from './opaque-archive';
import { OpaqueDrive, type RemotePacket, type ByteProof } from './opaque-drive';
import { OpaqueDrive, type RemotePacket, type ByteProof, type DedicatedFolder } from './opaque-drive';

interface Job { binding: string; bytes: Uint8Array; fileId: string | null; sha: string }
interface Durable { v: 1; job: Job | null; receipts: ByteProof[] }
Expand All @@ -18,6 +18,7 @@ function sameJob(a: Job | null, b: Job | null): boolean {
a.bytes.every((value, i) => value === b.bytes[i]);
}
export interface ConnectedState { configured: boolean; prepared: boolean; connected: boolean; selected: boolean; busy: boolean; message: string;
dedicatedFolders: DedicatedFolder[];
versions: RemotePacket[]; receipts: ByteProof[]; recoveryOrigin: string | null; account: string | null; accountConfirmed: boolean; folder: string | null;
phase: 'WAITING' | 'SENDING' | 'UPLOADED' | 'VERIFIED_BYTES' | 'CONFLICT' | 'SYNC_BLOCKED' | 'ERROR'; pending: boolean; lastOperationAt: number | null }
export class ConnectedDrive {
Expand All @@ -30,7 +31,7 @@ export class ConnectedDrive {
private listeners = new Set<() => void>();
private epoch = 0;
state: ConnectedState = { configured: false, prepared: false, connected: false, selected: false, busy: false,
message: 'Connexion réelle non activée sur cette version.', versions: [], receipts: [], recoveryOrigin: null, account: null, accountConfirmed: false, folder: null, phase: 'WAITING', pending: false, lastOperationAt: null };
message: 'Connexion réelle non activée sur cette version.', dedicatedFolders: [], versions: [], receipts: [], recoveryOrigin: null, account: null, accountConfirmed: false, folder: null, phase: 'WAITING', pending: false, lastOperationAt: null };
subscribe(fn: () => void): () => void { this.listeners.add(fn); return () => this.listeners.delete(fn); }
private change(patch: Partial<ConnectedState>) { this.state = { ...this.state, ...patch }; this.listeners.forEach(fn => fn()); }
async init() {
Expand Down Expand Up @@ -59,19 +60,35 @@ export class ConnectedDrive {
// Conserver le geste utilisateur : ouvrir Google avant tout await.
connect() {
if (!this.session || this.state.busy) return Promise.resolve();
this.epoch++; this.selection = null; this.change({ selected: false, account: null, accountConfirmed: false, folder: null, versions: [], receipts: [], phase: 'WAITING' });
this.epoch++; this.selection = null; this.change({ selected: false, account: null, accountConfirmed: false, folder: null, dedicatedFolders: [], versions: [], receipts: [], phase: 'WAITING' });
const promise = this.session.connect();
return this.action(async epoch => { await promise; this.check(epoch); this.change({ connected: true, account: this.session?.identity?.label || null, accountConfirmed: false, message: 'Compte affiché : confirme explicitement ce compte dédié avant tout accès aux fichiers.' }); });
}
confirmAccount() {
if (!this.session?.identity || this.state.busy) return;
try { this.session.confirmAccount(this.session.identity.id); this.change({ accountConfirmed: true, message: 'Compte confirmé. Crée un dossier neuf dédié ou retrouve celui créé par cette application dans Picker.' }); }
try { this.session.confirmAccount(this.session.identity.id); this.change({ accountConfirmed: true, message: 'Compte confirmé. Crée un dossier neuf dédié ou retrouve les dossiers déjà autorisés pour cette application.' }); }
catch { this.change({ phase: 'SYNC_BLOCKED', message: 'Confirmation du compte refusée. Reconnecte le compte dédié.' }); }
}
newFolder() {
if (!this.session || this.state.busy || !this.state.accountConfirmed) return Promise.resolve();
this.epoch++; this.selection = null; this.change({ selected: false, folder: null, versions: [], receipts: [] });
return this.action(async epoch => { await this.session!.newFolder(); this.check(epoch); this.change({ phase: 'WAITING', lastOperationAt: Date.now(), message: 'Dossier neuf dédié créé et relu. Choisis-le maintenant dans Google Picker.' }); });
return this.action(async epoch => { await this.session!.newFolder(); this.check(epoch); this.change({ dedicatedFolders: [], phase: 'WAITING', lastOperationAt: Date.now(), message: 'Dossier neuf dédié créé et relu. Retrouve les dossiers dédiés pour le sélectionner.' }); });
}
async listDedicatedFolders() {
if (!this.session || this.state.busy || !this.state.accountConfirmed) return;
this.change({ dedicatedFolders: [] });
return this.action(async epoch => {
const folders = await this.session!.listDedicatedFolders(); this.check(epoch);
this.change({ dedicatedFolders: folders, message: folders.length ? 'Dossiers dédiés retrouvés. Choisis explicitement celui à utiliser.' : 'Aucun dossier dédié autorisé trouvé. Crée un dossier neuf, puis actualise cette liste.' });
});
}
chooseDedicatedFolder(id: string) {
if (!this.session || this.state.busy || !this.state.accountConfirmed) return Promise.resolve();
this.epoch++; this.selection = null; this.change({ selected: false, folder: null, versions: [], receipts: [], phase: 'WAITING' });
return this.action(async epoch => {
const selection = await this.session!.chooseDedicatedFolder(id); this.check(epoch); this.selection = selection;
this.change({ selected: true, folder: selection.folder, versions: [], receipts: [], lastOperationAt: Date.now(), message: 'Dossier dédié relu et sélectionné. Actualise les versions pour les lire.' });
});
}
chooseFolder() {
if (!this.session || this.state.busy) return Promise.resolve();
Expand All @@ -82,7 +99,7 @@ export class ConnectedDrive {
}
disconnect() {
this.epoch++; this.session?.disconnect(); this.selection = null;
this.change({ busy: false, connected: false, selected: false, account: null, accountConfirmed: false, folder: null, phase: 'WAITING', versions: [], receipts: [], message: 'Déconnecté. Les fichiers et les envois en attente sont conservés.' });
this.change({ busy: false, connected: false, selected: false, account: null, accountConfirmed: false, folder: null, dedicatedFolders: [], phase: 'WAITING', versions: [], receipts: [], message: 'Déconnecté. Les fichiers et les envois en attente sont conservés.' });
}
private check(epoch: number) { if (this.epoch !== epoch || !this.session?.connected) throw Object.assign(new Error('auth'), { code: 'auth' }); }
private selected() { if (!this.provider || !this.selection) throw new Error('Dossier requis'); return { provider: this.provider, ...this.selection }; }
Expand Down
4 changes: 2 additions & 2 deletions src/services/google-drive-session.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { OpaqueDrive } from './opaque-drive';
import type { OpaqueDrive, DedicatedFolder } from './opaque-drive';
export interface DriveConfig { approved: true; clientId: string; apiKey: string; appId: string; raceOrigin: string; vaultOrigin: string }
export interface DriveSession { prepare(): Promise<void>; connect(): Promise<void>; confirmAccount(id: string): void; newFolder(): Promise<string>; chooseFolder(): Promise<{ folder: string; binding: string; account: string }>; readonly identity: { id: string; label: string } | null; readonly confirmed: boolean; disconnect(): void; readonly connected: boolean }
export interface DriveSession { prepare(): Promise<void>; connect(): Promise<void>; confirmAccount(id: string): void; newFolder(): Promise<string>; listDedicatedFolders(): Promise<DedicatedFolder[]>; chooseDedicatedFolder(id: string): Promise<{ folder: string; binding: string; account: string }>; chooseFolder(): Promise<{ folder: string; binding: string; account: string }>; readonly identity: { id: string; label: string } | null; readonly confirmed: boolean; disconnect(): void; readonly connected: boolean }
export const GoogleDriveSession: {
config(value: unknown, origin?: string): DriveConfig;
load(fetcher?: typeof fetch): Promise<DriveConfig>;
Expand Down
24 changes: 22 additions & 2 deletions src/services/google-drive-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@ const GoogleDriveSession = (() => {
function create(input, provider, host = globalThis) {
const cfg = config(input, host.location.origin);
let token = null, until = 0, epoch = 0, prepared = false, pending = null, picker = null, pickReject = null, pickerBusy = false, identity = null, approvedAccount = null, pendingFolder = null;
let dedicated = new Set();
const script = src => new Promise((resolve, reject) => {
const el = host.document.createElement('script'); el.src = src; el.async = true; el.referrerPolicy = 'strict-origin-when-cross-origin';
const timer = setTimeout(() => { el.remove(); reject(Object.assign(new Error('network'), { code: 'network' })); }, 20000);
el.onload = () => { clearTimeout(timer); resolve(); }; el.onerror = () => { clearTimeout(timer); el.remove(); reject(Object.assign(new Error('network'), { code: 'network' })); };
host.document.head.appendChild(el);
});
function disconnect() {
token = null; until = 0; identity = approvedAccount = pendingFolder = null; pickerBusy = false; epoch++; provider.disconnect(); picker?.setVisible(false); picker = null;
token = null; until = 0; identity = approvedAccount = pendingFolder = null; dedicated = new Set(); pickerBusy = false; epoch++; provider.disconnect(); picker?.setVisible(false); picker = null;
if (pickReject) { pickReject(Object.assign(new Error('cancelled'), { code: 'cancelled' })); pickReject = null; }
if (pending) { pending.reject(Object.assign(new Error('cancelled'), { code: 'cancelled' })); pending = null; }
}
Expand Down Expand Up @@ -92,6 +93,25 @@ const GoogleDriveSession = (() => {
if (lease !== epoch || Date.now() >= until) return fail('auth');
return folder;
}
async function listDedicatedFolders() {
fileAccess(); dedicated = new Set(); const lease = epoch, account = await provider.account();
if (lease !== epoch || account !== approvedAccount) return fail('auth');
const folders = await provider.folders();
if (lease !== epoch || Date.now() >= until) return fail('auth');
dedicated = new Set(folders.map(f => f.id));
return folders.map(f => Object.freeze({ ...f }));
}
async function chooseDedicatedFolder(folder) {
fileAccess();
if (!dedicated.has(folder)) return fail('confirmation');
const lease = epoch, account = await provider.account();
if (lease !== epoch || account !== approvedAccount) return fail('auth');
await provider.folder(folder);
if (lease !== epoch || Date.now() >= until) return fail('auth');
const binding = [...new Uint8Array(await host.crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify([cfg.clientId, account, folder]))))].map(x => x.toString(16).padStart(2, '0')).join('');
if (lease !== epoch || Date.now() >= until) return fail('auth');
return { folder, binding, account: identity.label };
}
function chooseFolder() {
if (pickReject) return Promise.reject(Object.assign(new Error('busy'), { code: 'busy' }));
if (!token || Date.now() >= until) { disconnect(); return Promise.reject(Object.assign(new Error('auth'), { code: 'auth' })); }
Expand Down Expand Up @@ -121,7 +141,7 @@ const GoogleDriveSession = (() => {
picker.setVisible(true);
});
}
return Object.freeze({ prepare, connect, confirmAccount, newFolder, chooseFolder, disconnect,
return Object.freeze({ prepare, connect, confirmAccount, newFolder, listDedicatedFolders, chooseDedicatedFolder, chooseFolder, disconnect,
get identity() { return identity && { ...identity }; }, get confirmed() { return !!approvedAccount; },
get connected() { if (token && (Date.now() >= until || provider.connected === false)) disconnect(); return !!token; } });
}
Expand Down
34 changes: 34 additions & 0 deletions src/services/opaque-drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface ByteProof {
readonly revision?: SyncRevision;
}
export interface SyncProfile { readonly fileId: string; readonly sha256: string; readonly bytes: Uint8Array }
export interface DedicatedFolder { readonly id: string; readonly name: string; readonly createdTime: string }
export class OpaqueDrive {
#token: string | null = null;
#until = 0;
Expand Down Expand Up @@ -143,6 +144,39 @@ export class OpaqueDrive {
!object(p.appProperties) || p.appProperties.cv_contract !== 'rc-folder-v1' ||
!object(p.capabilities) || p.capabilities.canAddChildren !== true) return fail('conflict');
}
// Only folders already visible to this OAuth application and bearing its
// private contract. This never grants access to an arbitrary Drive folder.
async folders(signal?: AbortSignal): Promise<DedicatedFolder[]> {
const epoch = this.lease(), out: DedicatedFolder[] = [], pages = new Set<string>(), ids = new Set<string>();
let next = '', count = 0;
do {
if (++count > 10) return fail('incomplete');
const params = new URLSearchParams({
q: "mimeType = 'application/vnd.google-apps.folder' and trashed = false and 'me' in owners and appProperties has { key='cv_contract' and value='rc-folder-v1' }",
spaces: 'drive', pageSize: '100',
fields: 'nextPageToken,incompleteSearch,files(id,name,createdTime,mimeType,trashed,ownedByMe,appProperties,capabilities(canAddChildren))',
});
if (next) params.set('pageToken', next);
const page = await this.json(await this.request(API + '/files?' + params, { signal }, epoch), signal);
this.check(epoch, signal);
if (page.incompleteSearch !== false || !Array.isArray(page.files) || page.files.length > 100) return fail('incomplete');
for (const f of page.files) {
if (!object(f) || typeof f.id !== 'string' || typeof f.name !== 'string' || !f.name || f.name.length > 1024 ||
typeof f.createdTime !== 'string' || f.createdTime.length > 40 || !Number.isFinite(Date.parse(f.createdTime)) ||
f.mimeType !== 'application/vnd.google-apps.folder' || f.trashed !== false || f.ownedByMe !== true ||
!object(f.appProperties) || f.appProperties.cv_contract !== 'rc-folder-v1' ||
!object(f.capabilities) || f.capabilities.canAddChildren !== true) return fail('conflict');
const folder = id(f.id);
if (ids.has(folder) || out.length >= 1000) return fail('incomplete');
ids.add(folder); out.push(Object.freeze({ id: folder, name: f.name, createdTime: f.createdTime }));
}
if (page.nextPageToken === undefined) next = '';
else if (typeof page.nextPageToken !== 'string' || !page.nextPageToken || page.nextPageToken.length > 4096 || pages.has(page.nextPageToken)) return fail('incomplete');
else { next = page.nextPageToken; pages.add(next); }
} while (next);
this.check(epoch, signal);
return out.sort((a, b) => b.createdTime.localeCompare(a.createdTime) || a.id.localeCompare(b.id));
}
async createFolder(fileId: string, signal?: AbortSignal): Promise<string> {
id(fileId); const epoch = this.lease();
// Réserver l'ID avant l'appel ; une réponse perdue est relue, jamais remplacée.
Expand Down
Loading
Loading