Skip to content
Closed
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
24 changes: 16 additions & 8 deletions LIBRARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 40 additions & 5 deletions scripts/release/library-batch-check.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions src/constants/library/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions src/layouts/library/Library/Library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import React, {
} from 'react';

import {
LIBRARY_AI_FLAG,
LIBRARY_FULL_MESSAGE,
LIBRARY_SHELVES_REFETCH_EVENT,
MAX_OBJECTS_PER_LIBRARY,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions src/lib/library/flags.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
15 changes: 8 additions & 7 deletions src/lib/library/owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.',
Expand Down
4 changes: 1 addition & 3 deletions src/pages/api/library/ai-shelf.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 1 addition & 3 deletions src/pages/api/library/magic-book.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading