diff --git a/LIBRARY.md b/LIBRARY.md index 4a4b3c91..a94d6c3d 100644 --- a/LIBRARY.md +++ b/LIBRARY.md @@ -57,17 +57,25 @@ owner's address. Phones and tablets stay read-only, so a signed-in owner without a library reads "Use a desktop to create your library" there. A visitor at an address no library answers to reads "No such library". -The AI shelf and the magic books are behind the `library-ai` account flag, -read from `GET /api/users/me` as `featureNames` (LIBRARY_AI_FLAG). The page -draws neither surface without it, and `/api/library/ai-shelf` and -`/api/library/magic-book` answer 403 without it through `ownerOfLibrary`, so -the hidden shelf is not the gate. Both surfaces are the owner's alone on -every account: a visitor never sees them. An operator hands the flag out in +The AI shelf and the magic books open to an owner who holds the `library-ai` +account flag, read from `GET /api/users/me` as `featureNames` +(LIBRARY_AI_FLAG), or whose library holds more than 15 books +(LIBRARY_AI_BOOKS_OVER, Wolf, 2026-09-25). Only objects of type book count, +on every shelf, private ones included; the count is read from the library on +every check, so the AI arrives with the sixteenth book and leaves if the +library drops back to fifteen, while the flag holds regardless. One check, +`opensLibraryAi` in `src/lib/library/flags.ts`, decides for the page and the +routes: the page draws neither surface without it, and +`/api/library/ai-shelf` and `/api/library/magic-book` answer 403 without it +through `ownerOfLibrary`, so the hidden shelf is not the gate. The AI shelf +itself stays locked until 30 books (AI_SHELF_MIN_BOOKS). Both surfaces are +the owner's alone on every account: a visitor never sees them. An operator hands the flag out in the CMS admin panel (the user's Feature Flags relation) or ahead of signup through the Mail Permission List; it takes effect on the account's next page load, no new sign-in. Wolf names the accounts, one at a time, to the agent; -on 2026-09-12 they are Alina, Mary, Lemongrass and Wolf. Cover, video and -audio autofill stay open to everyone: they cost no model call. +on 2026-09-12 they are Alina, Mary, Lemongrass and Wolf. On 2026-09-25 +Lilith and Maksim got it for holding more than 15 books, before the rule +above shipped. Cover, video and audio autofill stay open to everyone: they cost no model call. A library carries `hidden`, set only in the CMS admin panel (the content API refuses it on update). Hidden, it leaves the home list and diff --git a/scripts/release/library-batch-check.cjs b/scripts/release/library-batch-check.cjs index 478a697b..01f27456 100644 --- a/scripts/release/library-batch-check.cjs +++ b/scripts/release/library-batch-check.cjs @@ -3,6 +3,7 @@ const path = require('node:path'); const vm = require('node:vm'); const assert = require('node:assert/strict'); const ts = require('typescript'); + process.chdir(path.resolve(__dirname, '../..')); function load(file, mocks = {}) { const exports = {}; @@ -279,27 +280,61 @@ function check() { // LIBRARY ACCESS. Creation needs no flag; the AI does, and the routes // behind it check the same flag the page draws by, so a direct call is // stopped where the shelf is not drawn. - const { holdsFlag } = load('src/lib/library/flags.ts'); + const flags = load('src/lib/library/flags.ts', { + '@constants/library/common': { + LIBRARY_AI_FLAG: 'library-ai', + LIBRARY_AI_BOOKS_OVER: 15, + }, + }); + const { holdsFlag, countBooks, opensLibraryAi } = flags; assert(holdsFlag({ featureNames: ['library-ai'] }, 'library-ai')); assert(!holdsFlag({ featureNames: ['can-create-library'] }, 'library-ai')); assert(!holdsFlag({ featureNames: 'library-ai' }, 'library-ai')); assert(!holdsFlag({}, 'library-ai')); assert(!holdsFlag(null, 'library-ai')); + // More than 15 books opens the AI without the flag (Wolf, 2026-09-25); + // only books count, on every shelf. + const shelf = (...types) => ({ + attributes: { + objects: { data: types.map(type => ({ attributes: { type } })) }, + }, + }); + const libraryOf = (...shelves) => ({ + attributes: { singleShelves: { data: shelves } }, + }); + const fifteen = libraryOf( + shelf(...Array(10).fill('book'), 'audio', 'video'), + shelf(...Array(5).fill('book'), 'audio'), + ); + const sixteen = libraryOf( + shelf(...Array(10).fill('book')), + shelf(...Array(6).fill('book')), + ); + assert.equal(countBooks(fifteen), 15); + assert.equal(countBooks(sixteen), 16); + assert.equal(countBooks(null), 0); + assert(!opensLibraryAi({}, fifteen)); + assert(opensLibraryAi({}, sixteen)); + assert(opensLibraryAi({ featureNames: ['library-ai'] }, fifteen)); + assert(!opensLibraryAi(null, null)); // The constants file carries icon components for its sample cards; the // icons are not what is checked here. const common = load('src/constants/library/common.ts', { '@icons/library/svg': new Proxy({}, { get: () => () => null }), }); assert.equal(common.LIBRARY_AI_FLAG, 'library-ai'); + assert.equal(common.LIBRARY_AI_BOOKS_OVER, 15); assert.equal(common.MAX_OBJECTS_PER_LIBRARY, 300); for (const route of [ 'src/pages/api/library/ai-shelf.ts', 'src/pages/api/library/magic-book.ts', ]) - assert( - fs.readFileSync(route, 'utf8').includes('flag: LIBRARY_AI_FLAG'), - route, - ); + assert(fs.readFileSync(route, 'utf8').includes('libraryAi: true'), route); + assert( + fs + .readFileSync('src/layouts/library/Library/Library.tsx', 'utf8') + .includes('opensLibraryAi(accountData, library)'), + ); for (const file of [ 'src/layouts/library/Library/Library.tsx', 'src/layouts/library/Home/Home.tsx', diff --git a/src/constants/library/common.ts b/src/constants/library/common.ts index 086ad0f8..2aeae658 100644 --- a/src/constants/library/common.ts +++ b/src/constants/library/common.ts @@ -97,6 +97,10 @@ export const LIBRARY_OBJECTS_FULL_MESSAGE = // library needs no flag since 2026-09-12. export const LIBRARY_AI_FLAG = 'library-ai'; +// A library holding more books than this opens the AI to its owner without the +// flag (Wolf, 2026-09-25). Only objects of type book count. +export const LIBRARY_AI_BOOKS_OVER = 15; + export const SHELF_FULL_MESSAGE = 'This shelf is full.'; // The library-level twin, worded the same way so the two limits read as one diff --git a/src/layouts/library/Library/Library.tsx b/src/layouts/library/Library/Library.tsx index 53feb891..4ad26d04 100644 --- a/src/layouts/library/Library/Library.tsx +++ b/src/layouts/library/Library/Library.tsx @@ -27,7 +27,6 @@ import React, { } from 'react'; import { - LIBRARY_AI_FLAG, LIBRARY_FULL_MESSAGE, LIBRARY_SHELVES_REFETCH_EVENT, MAX_OBJECTS_PER_LIBRARY, @@ -61,7 +60,7 @@ import { keepFavoriteFields, sortFavorites, } from '@lib/library/favorites'; -import { holdsFlag } from '@lib/library/flags'; +import { opensLibraryAi } from '@lib/library/flags'; import { libraryPath } from '@lib/library/libraryPath'; import { objectIdFromSlug } from '@lib/library/objectSlug'; import { @@ -268,12 +267,13 @@ export function LibraryTemplate({ // and on first paint, so the markup hydrates identically everywhere. const canEditHere = viewAsOwner && supportsEditing; - // The AI shelf and the magic books are behind the `library-ai` account - // flag from GET /api/users/me; the routes behind them check the same flag, - // so this decides what is drawn, not what is allowed. Creating a library + // The AI shelf and the magic books open with the `library-ai` account flag + // or with more than LIBRARY_AI_BOOKS_OVER books in this library; the routes + // behind them run the same check, so this decides what is drawn, not what + // is allowed. Creating a library // needs no flag: any signed-in owner of this address bootstraps one from // their first shelf. - const hasLibraryAi = holdsFlag(accountData, LIBRARY_AI_FLAG); + const hasLibraryAi = opensLibraryAi(accountData, library); // The magic books are read once the owner is known to be editing here: // desktop, own library, not previewing as a guest, and flagged. The diff --git a/src/lib/library/flags.ts b/src/lib/library/flags.ts index 9dcd8d42..466ffeca 100644 --- a/src/lib/library/flags.ts +++ b/src/lib/library/flags.ts @@ -1,3 +1,10 @@ +import { + LIBRARY_AI_BOOKS_OVER, + LIBRARY_AI_FLAG, +} from '@constants/library/common'; + +import type { StrapiLibraryEntry } from '@local-types/library/library'; + /** * Account feature flags, as `GET /api/users/me` reports them in * `featureNames`. One pure check, shared by the page (what is drawn) and the @@ -7,3 +14,29 @@ export const holdsFlag = ( me: { featureNames?: unknown } | null | undefined, flag: string, ): boolean => Array.isArray(me?.featureNames) && me.featureNames.includes(flag); + +/** Books in the library across every shelf, private ones included. Audio and + * video do not count. */ +export const countBooks = ( + library: StrapiLibraryEntry | null | undefined, +): number => + (library?.attributes.singleShelves?.data ?? []).reduce( + (sum, shelf) => + sum + + (shelf.attributes.objects?.data ?? []).filter( + object => object.attributes.type === 'book', + ).length, + 0, + ); + +/** + * The AI shelf and the magic books open to an owner who holds the + * `library-ai` flag, or whose library holds more than LIBRARY_AI_BOOKS_OVER + * books (Wolf, 2026-09-25). The count is read from the library each time, so + * the AI arrives with the book that crosses the line and nothing is stored. + */ +export const opensLibraryAi = ( + me: { featureNames?: unknown } | null | undefined, + library: StrapiLibraryEntry | null | undefined, +): boolean => + holdsFlag(me, LIBRARY_AI_FLAG) || countBooks(library) > LIBRARY_AI_BOOKS_OVER; diff --git a/src/lib/library/owner.ts b/src/lib/library/owner.ts index 6bed5496..ddfe7a14 100644 --- a/src/lib/library/owner.ts +++ b/src/lib/library/owner.ts @@ -2,7 +2,7 @@ import type { NextApiRequest } from 'next'; import type { StrapiLibraryEntry } from '@local-types/library/library'; -import { holdsFlag } from '@lib/library/flags'; +import { opensLibraryAi } from '@lib/library/flags'; /** * Who is asking, and do they own the library they are asking about. Every @@ -45,14 +45,15 @@ export interface OwnerWording { signIn?: string; /** A session, but not the owner of this library. */ forbidden?: string; - /** The owner, but without the account flag this surface needs. */ + /** The owner, but the surface is not open to their account. */ locked?: string; } /** What the surface needs of the account beyond owning the library. */ export interface OwnerRequires { - /** A `featureNames` entry from /api/users/me, e.g. LIBRARY_AI_FLAG. */ - flag?: string; + /** The AI shelf and the magic books: the `library-ai` flag, or more than + * LIBRARY_AI_BOOKS_OVER books in this library (opensLibraryAi). */ + libraryAi?: boolean; } export interface OwnerCheck { @@ -99,9 +100,9 @@ export async function ownerOfLibrary( userId: me.id, }; - // The owner, but the surface is behind an account flag they do not hold: - // the page does not draw it, and this is what stops a direct call. - if (requires.flag && !holdsFlag(me, requires.flag)) + // The owner, but the surface is not open to their account: the page does + // not draw it, and this is what stops a direct call. + if (requires.libraryAi && !opensLibraryAi(me, library)) return { status: 403, error: wording.locked ?? 'This surface is not open to your account.', diff --git a/src/pages/api/library/ai-shelf.ts b/src/pages/api/library/ai-shelf.ts index 741b817d..5aa0afa7 100644 --- a/src/pages/api/library/ai-shelf.ts +++ b/src/pages/api/library/ai-shelf.ts @@ -1,8 +1,6 @@ // motion-passport: exempt — a server route; nothing here is drawn. import type { NextApiRequest, NextApiResponse } from 'next'; -import { LIBRARY_AI_FLAG } from '@constants/library/common'; - import type { BannedBook, RecommendedPick, @@ -178,7 +176,7 @@ export default async function handler( req, libraryId, { locked: 'The AI shelf is not open to your account.' }, - { flag: LIBRARY_AI_FLAG }, + { libraryAi: true }, ); if (owner.status !== 200 || !owner.library) { if (owner.status === 403) diff --git a/src/pages/api/library/magic-book.ts b/src/pages/api/library/magic-book.ts index 05556d81..7aac31e5 100644 --- a/src/pages/api/library/magic-book.ts +++ b/src/pages/api/library/magic-book.ts @@ -1,8 +1,6 @@ // motion-passport: exempt — a server route; nothing here is drawn. import type { NextApiRequest, NextApiResponse } from 'next'; -import { LIBRARY_AI_FLAG } from '@constants/library/common'; - import type { MagicBooksResponse, MagicShelfResult, @@ -112,7 +110,7 @@ export default async function handler( forbidden: 'Only the owner sees the magic books.', locked: 'The magic books are not open to your account.', }, - { flag: LIBRARY_AI_FLAG }, + { libraryAi: true }, ); if (owner.status !== 200 || !owner.library) { if (owner.status === 403)