diff --git a/packages/account-ui/src/pages/drive.jsx b/packages/account-ui/src/pages/drive.jsx
index 17f55e1..04278ed 100644
--- a/packages/account-ui/src/pages/drive.jsx
+++ b/packages/account-ui/src/pages/drive.jsx
@@ -65,6 +65,63 @@ function Breadcrumb({ ancestry, space, onNavigate }) {
);
}
+/**
+ * Make a drive that lives in a permissioned space. Its files are private and
+ * shared with whoever joins the space, and it is a space of the drive's own
+ * type: another app's space is that app's data model.
+ */
+function NewDriveDialog({ onCreate, busy }) {
+ const [name, setName] = useState('');
+ return (
+
+ );
+}
+
/** Make a folder here. It is a record of its own, so an empty one stays. */
function NewFolderDialog({ folder, space, onCreate, busy }) {
const [name, setName] = useState('');
@@ -230,6 +287,12 @@ export function DrivePage({ data: account, folder, space, onNavigate }) {
await post('/drive/folder/delete', { rkey, space: space || undefined });
});
+ const makeDrive = (name) =>
+ action.run(async () => {
+ const made = await post('/drive/space', { name });
+ onNavigate(folderHref('', made.uri));
+ });
+
const empty = data.folders.length === 0 && data.files.length === 0;
return (
@@ -250,7 +313,7 @@ export function DrivePage({ data: account, folder, space, onNavigate }) {
if (files.length) sendFiles(files);
}}
>
- {data.spaces?.length > 0 && (
+ {(data.spaces?.length > 0 || data.canMakeSpace) && (
Drive in
{entry.name}
))}
+ {data.canMakeSpace && (
+
+ )}
)}
diff --git a/packages/core/src/handlers/account.js b/packages/core/src/handlers/account.js
index 24ded16..8362960 100644
--- a/packages/core/src/handlers/account.js
+++ b/packages/core/src/handlers/account.js
@@ -144,30 +144,28 @@ function parseByteRange(header, size) {
return { start, end: Math.min(end, size - 1) };
}
+// A drive kept in a permissioned space uses a space of its own type. Another
+// app's space is that app's data model, and its members read what it holds, so
+// a file written there is both squatting and shared with people the drive
+// never named.
+const DRIVE_SPACE_TYPE = 'dev.pdsjs.drive.space';
+
+// A space key is the name its owner gave the drive, so it has to read as one.
+const DRIVE_SPACE_KEY = /^[a-z0-9][a-z0-9-]{0,62}$/;
+
/**
- * Name each space for a picker. A space AT-URI ends in its record key, which
- * is a TID or the literal `self` and names nothing a person recognises; the
- * type NSID is the part that does. The key joins the name only where two
- * spaces share a type, since there the type alone would name both.
- * @param {Array<{uri: string, spaceType: string}>} rows
+ * The drives among an account's spaces. Every drive space shares one type, so
+ * the key is what names each: the owner chose it when the drive was made.
+ * @param {Array<{uri: string, spaceType: string, deletedAt?: string|null}>} rows
* @returns {Array<{uri: string, type: string, key: string, name: string}>}
*/
-function nameSpaces(rows) {
- /** @type {Map} */
- const byType = new Map();
- for (const row of rows) {
- byType.set(row.spaceType, (byType.get(row.spaceType) ?? 0) + 1);
- }
- return rows.map((row) => {
- const key = row.uri.split('/').pop() ?? '';
- const short = row.spaceType.split('.').pop() || row.spaceType;
- return {
- uri: row.uri,
- type: row.spaceType,
- key,
- name: (byType.get(row.spaceType) ?? 0) > 1 ? `${short} (${key})` : short,
- };
- });
+function driveSpaces(rows) {
+ return rows
+ .filter((row) => row.spaceType === DRIVE_SPACE_TYPE && !row.deletedAt)
+ .map((row) => {
+ const key = row.uri.split('/').pop() ?? '';
+ return { uri: row.uri, type: row.spaceType, key, name: key };
+ });
}
/**
@@ -2397,10 +2395,13 @@ export function createAccountHandlers(ctx) {
);
if (!listing) throw new Error('That folder name is not valid.');
// The spaces this drive can also live in, so the page can offer them.
- const rows = spaceBrowser
- ? (await spaceBrowser.listSpaces()).filter((space) => !space.deletedAt)
- : [];
- return { enabled: true, ...listing, spaces: nameSpaces(rows) };
+ const rows = spaceBrowser ? await spaceBrowser.listSpaces() : [];
+ return {
+ enabled: true,
+ ...listing,
+ spaces: driveSpaces(rows),
+ canMakeSpace: Boolean(spaceAdmin && driveWriter),
+ };
});
}
@@ -2486,6 +2487,34 @@ export function createAccountHandlers(ctx) {
);
}
+ /**
+ * POST /account/api/drive/space - Make a drive that lives in a permissioned
+ * space, so its files are private and shared with whoever joins it.
+ * @param {Request} request @param {URL} url
+ */
+ async function handleApiDriveSpaceCreate(request, url) {
+ return accountApi(
+ request,
+ url,
+ async (_did, body) => {
+ if (!driveWriter) throw new Error('Drive is not enabled.');
+ if (!spaceAdmin) throw new Error('Spaces are not enabled.');
+ const name = typeof body?.name === 'string' ? body.name.trim() : '';
+ if (!DRIVE_SPACE_KEY.test(name)) {
+ throw new Error(
+ 'Name a drive with lowercase letters, numbers and dashes.',
+ );
+ }
+ const { uri } = await spaceAdmin.createSpace({
+ type: DRIVE_SPACE_TYPE,
+ skey: name,
+ });
+ return { uri, type: DRIVE_SPACE_TYPE, key: name, name };
+ },
+ { write: true },
+ );
+ }
+
/**
* POST /account/api/drive/folder - Make a folder. It is a record of its
* own, so a folder with nothing in it stays.
@@ -4533,6 +4562,10 @@ export function createAccountHandlers(ctx) {
method: 'GET',
handler: handleApiDriveChanges,
},
+ '/account/api/drive/space': {
+ method: 'POST',
+ handler: handleApiDriveSpaceCreate,
+ },
'/account/api/drive/folder': {
method: 'POST',
handler: handleApiDriveFolderCreate,
diff --git a/packages/drive/src/index.js b/packages/drive/src/index.js
index 0058928..b53de2d 100644
--- a/packages/drive/src/index.js
+++ b/packages/drive/src/index.js
@@ -4,6 +4,7 @@ export { createDriveBrowser } from './browser.js';
export {
DRIVE_FILE_COLLECTION,
DRIVE_FOLDER_COLLECTION,
+ DRIVE_SPACE_TYPE,
driveFileLexicon,
driveFolderLexicon,
folderAncestry,
diff --git a/packages/drive/src/lexicon.js b/packages/drive/src/lexicon.js
index 26b43ff..94525aa 100644
--- a/packages/drive/src/lexicon.js
+++ b/packages/drive/src/lexicon.js
@@ -14,6 +14,13 @@
export const DRIVE_FILE_COLLECTION = 'dev.pdsjs.drive.file';
export const DRIVE_FOLDER_COLLECTION = 'dev.pdsjs.drive.folder';
+/**
+ * The space type a private drive uses. A drive kept in a permissioned space
+ * makes one of these rather than borrowing a space some other app made: that
+ * space is the other app's data model, and its members read what it holds.
+ */
+export const DRIVE_SPACE_TYPE = 'dev.pdsjs.drive.space';
+
/** Longest file or folder name, in characters. */
export const MAX_NAME_LENGTH = 512;
diff --git a/packages/drive/test/account-api.test.js b/packages/drive/test/account-api.test.js
index f59c038..287d642 100644
--- a/packages/drive/test/account-api.test.js
+++ b/packages/drive/test/account-api.test.js
@@ -52,7 +52,7 @@ async function fileRecord(file) {
}
/**
- * @param {{drive?: boolean, readOnly?: boolean, upload?: boolean, spaces?: Array<{uri: string, spaceType: string}>}} [opts]
+ * @param {{drive?: boolean, readOnly?: boolean, upload?: boolean, spaces?: Array<{uri: string, spaceType: string, deletedAt?: string}>}} [opts]
*/
async function makePds(opts = {}) {
/** @type {Map} */
@@ -86,6 +86,7 @@ async function makePds(opts = {}) {
const deleted = /** @type {string[]} */ ([]);
const unlinked = /** @type {string[]} */ ([]);
+ const created = /** @type {Array<{type: string, skey: string}>} */ ([]);
// The repo event log, which the change feed reads to build a delta.
/** @type {Array<{seq: number, evt: Uint8Array}>} */
const events = [];
@@ -179,10 +180,20 @@ async function makePds(opts = {}) {
spaceBrowser: opts.spaces
? /** @type {any} */ ({
listSpaces: async () =>
- opts.spaces?.map((space) => ({ ...space, deletedAt: null })),
+ opts.spaces?.map((space) => ({ deletedAt: null, ...space })),
countRecords: async () => [],
})
: undefined,
+ spaceAdmin: opts.spaces
+ ? /** @type {any} */ ({
+ createSpace: async (
+ /** @type {{type: string, skey: string}} */ made,
+ ) => {
+ created.push(made);
+ return { uri: `at://${DID}/space/${made.type}/${made.skey}` };
+ },
+ })
+ : undefined,
});
// Commit and reap run their full course in integration; here the API
// contract is under test, not the repo machinery.
@@ -209,7 +220,15 @@ async function makePds(opts = {}) {
return [];
}
);
- return { pds, deleted, unlinked, commits, records, reapCount: () => reaped };
+ return {
+ pds,
+ deleted,
+ unlinked,
+ commits,
+ records,
+ created,
+ reapCount: () => reaped,
+ };
}
/** @param {PersonalDataServer} pds */
@@ -747,68 +766,131 @@ describe('the change feed as a client would walk it', () => {
});
});
-describe('naming the spaces a drive can live in', () => {
+describe('which spaces a drive is offered', () => {
const uriFor = (/** @type {string} */ type, /** @type {string} */ key) =>
`at://${DID}/space/${type}/${key}`;
- it('names a space by its type, not by its record key', async () => {
- // A key is a TID or the literal `self`, which names nothing a person
- // recognises. The type NSID is the part that does.
+ it("offers only spaces made for a drive, never another app's", async () => {
const { pds } = await makePds({
spaces: [
+ // An app's own space holds that app's data model, and its members
+ // read what is in it. A file put there is squatting and is shared.
+ {
+ uri: uriFor('app.bulleted.space', 'self'),
+ spaceType: 'app.bulleted.space',
+ },
{
uri: uriFor('sh.tangled.repo', '3msw4lgrmisi4'),
spaceType: 'sh.tangled.repo',
},
{
- uri: uriFor('com.example.notes', 'self'),
- spaceType: 'com.example.notes',
+ uri: uriFor('dev.pdsjs.drive.space', 'photos'),
+ spaceType: 'dev.pdsjs.drive.space',
},
],
});
const cookie = await signInCookie(pds);
const { body } = await api(pds, '/account/api/drive/files', { cookie });
- expect(body.spaces.map((/** @type {any} */ s) => s.name)).toEqual([
- 'repo',
- 'notes',
+ expect(body.spaces).toEqual([
+ {
+ uri: uriFor('dev.pdsjs.drive.space', 'photos'),
+ type: 'dev.pdsjs.drive.space',
+ key: 'photos',
+ name: 'photos',
+ },
]);
});
- it('joins the key only where two spaces share a type', async () => {
+ it('names a drive by the key its owner chose', async () => {
const { pds } = await makePds({
spaces: [
- { uri: uriFor('sh.tangled.repo', 'aaa'), spaceType: 'sh.tangled.repo' },
- { uri: uriFor('sh.tangled.repo', 'bbb'), spaceType: 'sh.tangled.repo' },
{
- uri: uriFor('com.example.notes', 'self'),
- spaceType: 'com.example.notes',
+ uri: uriFor('dev.pdsjs.drive.space', 'photos'),
+ spaceType: 'dev.pdsjs.drive.space',
+ },
+ {
+ uri: uriFor('dev.pdsjs.drive.space', 'work'),
+ spaceType: 'dev.pdsjs.drive.space',
},
],
});
const cookie = await signInCookie(pds);
const { body } = await api(pds, '/account/api/drive/files', { cookie });
+ // Every drive space shares one type, so the key is what tells them apart.
expect(body.spaces.map((/** @type {any} */ s) => s.name)).toEqual([
- 'repo (aaa)',
- 'repo (bbb)',
- 'notes',
+ 'photos',
+ 'work',
]);
});
- it('carries the type and key beside the name', async () => {
+ it('leaves out a deleted drive', async () => {
const { pds } = await makePds({
spaces: [
{
- uri: uriFor('sh.tangled.repo', 'self'),
- spaceType: 'sh.tangled.repo',
+ uri: uriFor('dev.pdsjs.drive.space', 'gone'),
+ spaceType: 'dev.pdsjs.drive.space',
+ deletedAt: '2026-08-15T00:00:00.000Z',
},
],
});
const cookie = await signInCookie(pds);
const { body } = await api(pds, '/account/api/drive/files', { cookie });
- expect(body.spaces[0]).toMatchObject({
- type: 'sh.tangled.repo',
- key: 'self',
- name: 'repo',
+ expect(body.spaces).toEqual([]);
+ });
+});
+
+describe('/account/api/drive/space', () => {
+ it('makes a space of the drive type, not of any other', async () => {
+ const { pds, created } = await makePds({ spaces: [] });
+ const cookie = await signInCookie(pds);
+ const { status, body } = await api(pds, '/account/api/drive/space', {
+ cookie,
+ method: 'POST',
+ body: { name: 'photos' },
});
+ expect(status).toBe(200);
+ expect(created).toEqual([
+ { type: 'dev.pdsjs.drive.space', skey: 'photos' },
+ ]);
+ expect(body).toMatchObject({ key: 'photos', name: 'photos' });
+ });
+
+ it('refuses a name that would not read as one', async () => {
+ const { pds } = await makePds({ spaces: [] });
+ const cookie = await signInCookie(pds);
+ for (const name of ['', 'Photos', 'my photos', 'a/b', '-lead']) {
+ const { status } = await api(pds, '/account/api/drive/space', {
+ cookie,
+ method: 'POST',
+ body: { name },
+ });
+ expect(status).toBe(400);
+ }
+ });
+
+ it('reports the feature off where spaces are not enabled', async () => {
+ const { pds } = await makePds();
+ const cookie = await signInCookie(pds);
+ const { status, body } = await api(pds, '/account/api/drive/space', {
+ cookie,
+ method: 'POST',
+ body: { name: 'photos' },
+ });
+ expect(status).toBe(400);
+ expect(body.error).toMatch(/not enabled/);
+
+ const listing = await api(pds, '/account/api/drive/files', { cookie });
+ expect(listing.body.canMakeSpace).toBe(false);
+ });
+
+ it('is blocked in read-only mode', async () => {
+ const { pds } = await makePds({ spaces: [], readOnly: true });
+ const cookie = await signInCookie(pds);
+ const { status } = await api(pds, '/account/api/drive/space', {
+ cookie,
+ method: 'POST',
+ body: { name: 'photos' },
+ });
+ expect(status).toBe(403);
});
});