Google Drive home
The Drive home stores the backup in the user’s own Drive, not yours. You
never operate storage. Google never sees cleartext once the backup has a
password: the file is then encrypted before it leaves the device, so the cloud
holds opaque bytes. Without one the backup is a readable ZIP, because
encryption is off by default (requireEncryption defaults to false), so the
one-call example sets both.
One call
import { selfstore, gisDriveAuth } from 'selfstore';
// Never write a readable backup, whatever a later code path forgets.
const store = await selfstore('my-app', { requireEncryption: true });
const outcome = await store.connectDrive(gisDriveAuth({ clientId: GOOGLE_CLIENT_ID }), {
password: backupPassword, // encrypts a new backup from its first write
});
// 'started' | 'merged' | 'manual' | 'cancelled' (see quick start)
The password goes into the connect call, not into a store.protect() before
it: protect() needs a connected destination and throws NOT_CONNECTED
without one. Called after connecting, it does encrypt, but the first backup has
already been written in clear by then. With requireEncryption: true, a connect
without a password throws ENCRYPTION_REQUIRED instead of writing anything.
One prerequisite: an OAuth client id from a Google Cloud project (APIs and
Services, then Credentials, then “OAuth client ID”, type Web application, your
origin listed). selfstore requests only the drive.file scope, which grants
access solely to files this app created, the narrowest scope Drive offers.
The options it takes
interface GisDriveAuthOptions {
clientId: string;
scope?: string;
hint?: string | (() => string | null | undefined);
persist?: GisTokenPersistence; // 'memory' (default) | 'session'
}
hint pre-selects an account in the Google chooser - pass a function when your
app only learns which account later. persist: 'session' keeps the token for
the tab so a reload does not re-prompt; 'memory' drops it, which is the safer
default and the one a shared machine wants.
driveTarget.account() answers who holds the backup, as a
DriveAccount { email, name }. Worth showing next to “saved to Google Drive”: a
brand name is not an address, and several accounts look alike.
The same password also opens a Drive file that is already encrypted, so the
merge can read it. Without it the call throws PASSWORD_REQUIRED before
changing anything, so you can prompt and retry.
Load Google Identity before the click
import { preloadGoogleIdentity } from 'selfstore';
preloadGoogleIdentity(); // when the screen holding the Drive button opens
The consent popup only opens inside the user activation of a click. Loaded cold
on that click, Google Identity Services can take longer than the activation
lasts: the browser blocks the popup, and Google answers as if the user had
refused. preloadGoogleIdentity() (since 1.8.24, also in selfstore/advanced)
starts the load early, and the click reuses it. It contacts Google, so selfstore
never calls it for you. Safe to call any number of times, and a no-op outside a
browser.
The honest trade-off
Stated plainly, because it shapes your UX:
- Client-only (
gisDriveAuth): zero backend, periodic re-consent. Google Identity Services issues roughly hour-long tokens to pure browser apps, and third-party-cookie rules prevent silent renewal forever. Expect an occasional one-click re-consent. Fine for a backup that syncs a few times a day. - Permanent connection: a small token broker. For “connected for good”,
exchange the OAuth code server-side for a refresh token and mint short-lived
access tokens on demand. That is the one place a tiny backend buys real UX,
and it still never sees data.
DriveAuthis three methods, so a broker slots in:
import type { DriveAuth } from 'selfstore';
const brokerAuth: DriveAuth = {
async token({ signal } = {}) { /* fetch a fresh access token; pass signal to your fetch */ },
async reconnect() { /* run consent again; true if access re-established */ },
async forget() { /* drop the session, locally and broker-side */ },
};
await store.connectDrive(brokerAuth);
Either way, a genuine loss of access (a real 401) surfaces as
store.status.action === 'reconnect'; a cold start or a blip is retried
silently and never bothers the user.
token() also receives a signal (since 1.1.0) that fires the moment the
user disconnects the backup. A broker running its own retry loop should pass
it to its fetches and stop retrying at once, so a “disconnect” tap lands
immediately instead of waiting for a slow request to time out. Ignoring the
signal keeps working exactly as before - the loop just runs to its own
deadline first.
On the advanced store
Driving createLocalStore yourself, the home is
driveTarget from the advanced subpath, which also ships shared-file
primitives (preview, adopt, findOrCreateOwnFile) for pointing several
devices at one file:
import { driveTarget, gisDriveAuth } from 'selfstore/advanced';
const drive = () => driveTarget.connect({ auth, kv: cache.kv, fileName: 'my-app.zip' });
await store.attachTarget(await drive(), { strategy: 'merge', password });
Sharing with other people
Handing a copy to somebody else is a different file, never the backup itself:
createCompanion mints one next to it, share publishes it on a link,
unshare takes that back, owner says whose Drive a copy lives on, and
secondary is the read-write target a mirror publishes into. The full reference
is in the advanced API;
the topology those calls serve is peers.
One thing they deliberately do not do: read another account’s shared file.
The drive.file scope only sees files your app created or the user picked, so
reaching somebody else’s copy needs either the Google Picker (a gesture per
file) or a relay you host - and a relay is a server, which is not a decision a
zero-server library gets to make for you.