Foundation for the user-accounts platform. Self-hosted PocketBase v0.39.6, version-pinned from the official release binary, schema-as-code so the collections auto-apply on boot and never drift from hand-clicking. Collections: users (auth, admin-only nsfwEnabled), profiles, watch_state, watchlist, prefs. Per-owner access rules traverse profile.user; nsfwEnabled is never client-writable (superuser-only). Relations cascadeDelete; unique indexes keep one resume/watchlist row per (profile,itemId) and one prefs per profile. Verified locally against v0.39.6: migration applies clean, and scripts/verify.py proves two users can't read/write each other's data and can't set their own nsfwEnabled (19/19 checks). Coolify deploy (domain/TLS, superuser, SMTP, backups) documented in README as the manual half. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
165 lines
5.8 KiB
JavaScript
165 lines
5.8 KiB
JavaScript
/// <reference path="../pb_data/types.d.ts" />
|
|
|
|
// Initial account + multi-profile schema for myanime-app (epic #6, issue #9).
|
|
//
|
|
// Ownership model: users 1─┬─* profiles ─┬─* watch_state
|
|
// │ ├─* watchlist
|
|
// │ └─1 prefs
|
|
//
|
|
// Access rules: a user only ever touches their own `users` row, their own
|
|
// profiles, and rows hanging off those profiles (`profile.user = auth.id`).
|
|
// `nsfwEnabled` is admin-only: users can't submit it (superusers bypass rules
|
|
// and set it from the admin UI). It gates adult vs clean builds in issue #16.
|
|
|
|
migrate((app) => {
|
|
// ---- 1. Augment the default `users` auth collection --------------------
|
|
const users = app.findCollectionByNameOrId("users")
|
|
|
|
users.fields.add(new Field({
|
|
type: "text",
|
|
name: "username",
|
|
max: 150,
|
|
presentable: true,
|
|
}))
|
|
users.fields.add(new Field({
|
|
type: "bool",
|
|
name: "nsfwEnabled",
|
|
}))
|
|
|
|
// Unique username, but the field is optional — only enforce when non-empty.
|
|
users.indexes.push(
|
|
"CREATE UNIQUE INDEX `idx_users_username` ON `users` (`username`) WHERE `username` != ''"
|
|
)
|
|
|
|
// Own record only; nsfwEnabled never accepted from a client on create/update.
|
|
users.listRule = "id = @request.auth.id"
|
|
users.viewRule = "id = @request.auth.id"
|
|
users.createRule = "@request.body.nsfwEnabled:isset = false"
|
|
users.updateRule = "id = @request.auth.id && @request.body.nsfwEnabled:isset = false"
|
|
users.deleteRule = "id = @request.auth.id"
|
|
|
|
app.save(users)
|
|
|
|
const usersId = users.id
|
|
|
|
// ---- 2. profiles -------------------------------------------------------
|
|
const profiles = new Collection({
|
|
type: "base",
|
|
name: "profiles",
|
|
listRule: "@request.auth.id != '' && user = @request.auth.id",
|
|
viewRule: "@request.auth.id != '' && user = @request.auth.id",
|
|
createRule: "@request.auth.id != '' && user = @request.auth.id",
|
|
updateRule: "@request.auth.id != '' && user = @request.auth.id",
|
|
deleteRule: "@request.auth.id != '' && user = @request.auth.id",
|
|
fields: [
|
|
{
|
|
type: "relation",
|
|
name: "user",
|
|
required: true,
|
|
collectionId: usersId,
|
|
cascadeDelete: true,
|
|
maxSelect: 1,
|
|
minSelect: 0,
|
|
},
|
|
{ type: "text", name: "name", required: true, max: 100, presentable: true },
|
|
{
|
|
type: "file",
|
|
name: "avatar",
|
|
maxSelect: 1,
|
|
maxSize: 5242880,
|
|
mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/gif"],
|
|
},
|
|
{ type: "bool", name: "isChild" },
|
|
// PIN for child profiles; hidden so it never leaves the API.
|
|
{ type: "text", name: "pinHash", hidden: true, max: 200 },
|
|
{ type: "autodate", name: "created", onCreate: true },
|
|
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
|
],
|
|
})
|
|
app.save(profiles)
|
|
const profilesId = profiles.id
|
|
|
|
// Rows below all belong to a profile; ownership traverses profile.user.
|
|
const OWNS = "@request.auth.id != '' && profile.user = @request.auth.id"
|
|
|
|
const profileRel = () => ({
|
|
type: "relation",
|
|
name: "profile",
|
|
required: true,
|
|
collectionId: profilesId,
|
|
cascadeDelete: true,
|
|
maxSelect: 1,
|
|
minSelect: 0,
|
|
})
|
|
|
|
// ---- 3. watch_state (resume points) ------------------------------------
|
|
const watchState = new Collection({
|
|
type: "base",
|
|
name: "watch_state",
|
|
listRule: OWNS, viewRule: OWNS, createRule: OWNS, updateRule: OWNS, deleteRule: OWNS,
|
|
fields: [
|
|
profileRel(),
|
|
{ type: "text", name: "itemId", required: true, max: 100 },
|
|
{ type: "text", name: "itemType", required: true, max: 40 },
|
|
{ type: "number", name: "position", min: 0 },
|
|
{ type: "number", name: "duration", min: 0 },
|
|
{ type: "autodate", name: "updatedAt", onCreate: true, onUpdate: true },
|
|
],
|
|
indexes: [
|
|
"CREATE UNIQUE INDEX `idx_watch_state_profile_item` ON `watch_state` (`profile`, `itemId`)",
|
|
],
|
|
})
|
|
app.save(watchState)
|
|
|
|
// ---- 4. watchlist ------------------------------------------------------
|
|
const watchlist = new Collection({
|
|
type: "base",
|
|
name: "watchlist",
|
|
listRule: OWNS, viewRule: OWNS, createRule: OWNS, updateRule: OWNS, deleteRule: OWNS,
|
|
fields: [
|
|
profileRel(),
|
|
{ type: "text", name: "itemId", required: true, max: 100 },
|
|
{ type: "text", name: "itemType", required: true, max: 40 },
|
|
{ type: "autodate", name: "addedAt", onCreate: true },
|
|
],
|
|
indexes: [
|
|
"CREATE UNIQUE INDEX `idx_watchlist_profile_item` ON `watchlist` (`profile`, `itemId`)",
|
|
],
|
|
})
|
|
app.save(watchlist)
|
|
|
|
// ---- 5. prefs (one row per profile) ------------------------------------
|
|
const prefs = new Collection({
|
|
type: "base",
|
|
name: "prefs",
|
|
listRule: OWNS, viewRule: OWNS, createRule: OWNS, updateRule: OWNS, deleteRule: OWNS,
|
|
fields: [
|
|
profileRel(),
|
|
// playback / audio / subtitle preferences blob.
|
|
{ type: "json", name: "data", maxSize: 2000000 },
|
|
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
|
],
|
|
indexes: [
|
|
"CREATE UNIQUE INDEX `idx_prefs_profile` ON `prefs` (`profile`)",
|
|
],
|
|
})
|
|
app.save(prefs)
|
|
}, (app) => {
|
|
// ---- Down migration ----------------------------------------------------
|
|
for (const name of ["prefs", "watchlist", "watch_state", "profiles"]) {
|
|
try {
|
|
app.delete(app.findCollectionByNameOrId(name))
|
|
} catch (_) { /* already gone */ }
|
|
}
|
|
|
|
const users = app.findCollectionByNameOrId("users")
|
|
users.indexes = users.indexes.filter((idx) => !idx.includes("idx_users_username"))
|
|
users.fields.removeByName("username")
|
|
users.fields.removeByName("nsfwEnabled")
|
|
users.createRule = ""
|
|
users.updateRule = "id = @request.auth.id"
|
|
users.listRule = "id = @request.auth.id"
|
|
users.viewRule = "id = @request.auth.id"
|
|
users.deleteRule = "id = @request.auth.id"
|
|
app.save(users)
|
|
})
|