Phase 2 of the plan in `docs/design.md` ("Next"): product info.
**Drafts**
- A new upload is a draft. Only its creator and moderators can see it, and nobody can get or wear it.
- After uploading, the website goes straight to the product's review page, where the creator checks its info and presses Publish.
- Drafts not published within 30 days are deleted (checked every 10 minutes), and their catalogs get `delete` entries in their changes feed.
- The console's overview shows a "Drafts" to-do.
**Editing is a permission check now** (`product:edit`, from #7)
- Moderators can edit anyone's product. They must give a reason, which the creator sees.
- Moderators can lock fields (`product:lock`). Once a field is locked, only people with `product:edit-locked` can change it, so the creator sees it greyed out with "Locked by a moderator."
- Raising a rating to adult is immediate for anyone who can edit. Lowering it takes `product:rate-lower` (moderators), and is refused while any revision's files are rated adult. With cases (phase 3), a creator's request to lower it will open a rating review.
**Info history**
- Every edit, publish, lock change and undo is recorded with who, when, what changed (before and after) and why.
- It's shown on the edit page to the creator and moderators.
- "Undo" puts back what an entry changed, as a new entry, by the same rules (a creator can't undo a moderator's change to a field that's now locked).
- Moderator actions are also in the creator's audit log.
**Unlisted products and tags**
- An unlisted product is left out of search, lists and creator pages, except for its creator. Its link still works and people can still get it.
- Tags: up to 10 per product, lowercased.
- `/products` filters by `tag` and `catalog`, and a product's tags link to the filtered list.
- People who don't manage a product don't see its state labels (draft, unlisted, locks).
**Console → Products**
- Lists your products with drafts included; moderators can switch to everyone's.
- Filters: draft, published, unlisted, locked, deleted, removed, plus search.
- Shows how many libraries each product is in, and an Edit or Review button.
**Category**
- A product can be filed under a different IMVU category from its parent's.
- The choices are every category path the hub has seen on any product's parent, plus each level of those paths. They're limited to the same top-level category as the parent's, so a product stays the same kind (clothing, furniture or rooms), which the proxy relies on for the shop and inventory tabs.
- The list grows as products are uploaded. IMVU's own category tree, if it has a public one, would replace it.
- Category works like the other info fields: it can be locked, it's in the history, and it can be undone. The product list's category filter, and everything that shows a product's categories (including what the proxy gets), use the chosen path.
**Migration** `0011_product_info`: new product columns and the `product_info_change` table. Existing products count as published (with `published_at` set from their creation date) and listed.
**Compatibility**
- `t5de-catalog` needs no change: the hub's new `draft` field in the registration answer is ignored, and the website doesn't rely on it.
- The proxy only loads products from libraries, and drafts can't be added to one.
**Checks**
- 44 API tests pass, including a new one for moderator edits, locks, history, undo, unlisted, tags, the console list and draft purging, and new checks for category choices.
- Type checks pass.
- On a local hub I loaded the pages as the creator, an admin, another person and a signed-out visitor.
- I submitted the forms through the website, the way a browser does without JavaScript: save info, publish, choosing a category (and undoing it), a moderator's save with and without a reason, save locks, and undo, including a creator's undo refused by a lock. Each saved or refused correctly with the right message.
- Not tried: the upload form's redirect after a real upload to a running catalog.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Phase 2 of the plan in `docs/design.md` ("Next"): product info.
**Drafts**
- A new upload is a draft. Only its creator and moderators can see it, and nobody can get or wear it.
- After uploading, the website goes straight to the product's review page, where the creator checks its info and presses Publish.
- Drafts not published within 30 days are deleted (checked every 10 minutes), and their catalogs get `delete` entries in their changes feed.
- The console's overview shows a "Drafts" to-do.
**Editing is a permission check now** (`product:edit`, from #7)
- Moderators can edit anyone's product. They must give a reason, which the creator sees.
- Moderators can lock fields (`product:lock`). Once a field is locked, only people with `product:edit-locked` can change it, so the creator sees it greyed out with "Locked by a moderator."
- Raising a rating to adult is immediate for anyone who can edit. Lowering it takes `product:rate-lower` (moderators), and is refused while any revision's files are rated adult. With cases (phase 3), a creator's request to lower it will open a rating review.
**Info history**
- Every edit, publish, lock change and undo is recorded with who, when, what changed (before and after) and why.
- It's shown on the edit page to the creator and moderators.
- "Undo" puts back what an entry changed, as a new entry, by the same rules (a creator can't undo a moderator's change to a field that's now locked).
- Moderator actions are also in the creator's audit log.
**Unlisted products and tags**
- An unlisted product is left out of search, lists and creator pages, except for its creator. Its link still works and people can still get it.
- Tags: up to 10 per product, lowercased.
- `/products` filters by `tag` and `catalog`, and a product's tags link to the filtered list.
- People who don't manage a product don't see its state labels (draft, unlisted, locks).
**Console → Products**
- Lists your products with drafts included; moderators can switch to everyone's.
- Filters: draft, published, unlisted, locked, deleted, removed, plus search.
- Shows how many libraries each product is in, and an Edit or Review button.
**Category**
- A product can be filed under a different IMVU category from its parent's.
- The choices are every category path the hub has seen on any product's parent, plus each level of those paths. They're limited to the same top-level category as the parent's, so a product stays the same kind (clothing, furniture or rooms), which the proxy relies on for the shop and inventory tabs.
- The list grows as products are uploaded. IMVU's own category tree, if it has a public one, would replace it.
- Category works like the other info fields: it can be locked, it's in the history, and it can be undone. The product list's category filter, and everything that shows a product's categories (including what the proxy gets), use the chosen path.
**Migration** `0011_product_info`: new product columns and the `product_info_change` table. Existing products count as published (with `published_at` set from their creation date) and listed.
**Compatibility**
- `t5de-catalog` needs no change: the hub's new `draft` field in the registration answer is ignored, and the website doesn't rely on it.
- The proxy only loads products from libraries, and drafts can't be added to one.
**Checks**
- 44 API tests pass, including a new one for moderator edits, locks, history, undo, unlisted, tags, the console list and draft purging, and new checks for category choices.
- Type checks pass.
- On a local hub I loaded the pages as the creator, an admin, another person and a signed-out visitor.
- I submitted the forms through the website, the way a browser does without JavaScript: save info, publish, choosing a category (and undoing it), a moderator's save with and without a reason, save locks, and undo, including a creator's undo refused by a lock. Each saved or refused correctly with the right message.
- Not tried: the upload form's redirect after a real upload to a running catalog.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: dhkatz <[email protected]>
87 lines
3.9 KiB
TypeScript
87 lines
3.9 KiB
TypeScript
// Runs the hub's API. Configuration (environment):
|
|
// DATABASE_URL postgres://... (default: PGlite files in ./data/db, for development)
|
|
// AUTH_SECRET at least 32 random characters (required unless NODE_ENV is development)
|
|
// API_URL the API's address as the website and proxies reach it (default http://localhost:4101)
|
|
// WEB_URL the website's address (default http://localhost:4100)
|
|
// PORT default 4101
|
|
// REGISTRATION open or closed (new accounts wait for a moderator's approval), until an admin sets it in the console
|
|
// AGE_PROVIDERS ways to verify age, comma-separated (default imvu): imvu, didit
|
|
// DIDIT_API_KEY, DIDIT_WORKFLOW_ID, DIDIT_WEBHOOK_SECRET
|
|
// Didit's ID document check (docs/design.md, "Age"); its webhook is API_URL/api/v1/age/webhook/didit
|
|
// DIDIT_MONTHLY_LIMIT checks started a month at most (default 30: Didit's free plan has $10 of credit a month, and a
|
|
// check with ID document, liveness and face match costs $0.30)
|
|
import { openDatabase } from "@t5de/db";
|
|
import { AgeService, type Provider } from "./age";
|
|
import { diditAgeProvider } from "./age/didit";
|
|
import { imvuAgeProvider } from "./age/imvu";
|
|
import { createApp } from "./app";
|
|
import { createAuth } from "./auth";
|
|
import { Settings } from "./settings";
|
|
import { ImvuClient } from "./imvu";
|
|
|
|
const env = process.env;
|
|
const dev = env.NODE_ENV !== "production";
|
|
const port = Number(env.PORT ?? 4101);
|
|
const secret = env.AUTH_SECRET ?? (dev ? "development-secret-not-for-production-use" : "");
|
|
if (secret.length < 32) {
|
|
console.error("AUTH_SECRET must be at least 32 characters");
|
|
process.exit(1);
|
|
}
|
|
const apiURL = env.API_URL ?? `http://localhost:${port}`;
|
|
const webURL = env.WEB_URL ?? "http://localhost:4100";
|
|
|
|
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
|
|
const settings = new Settings(db, { registration: env.REGISTRATION });
|
|
const auth = createAuth(db, { apiURL, webURL, secret, settings });
|
|
const imvu = new ImvuClient();
|
|
|
|
function required(name: string): string {
|
|
const v = env[name];
|
|
if (!v) {
|
|
console.error(`${name} must be set`);
|
|
process.exit(1);
|
|
}
|
|
return v;
|
|
}
|
|
const ageProviders: Record<string, () => Provider> = {
|
|
imvu: () => imvuAgeProvider(imvu),
|
|
didit: () =>
|
|
diditAgeProvider({
|
|
apiKey: required("DIDIT_API_KEY"),
|
|
workflowId: required("DIDIT_WORKFLOW_ID"),
|
|
webhookSecret: required("DIDIT_WEBHOOK_SECRET"),
|
|
monthlyLimit: Number(env.DIDIT_MONTHLY_LIMIT ?? 30),
|
|
}),
|
|
};
|
|
const providers = (env.AGE_PROVIDERS ?? "imvu").split(",").map((id) => id.trim()).filter(Boolean);
|
|
if (!providers.includes("imvu")) providers.unshift("imvu"); // linking records IMVU's answer
|
|
for (const id of providers) {
|
|
if (!ageProviders[id]) {
|
|
console.error(`AGE_PROVIDERS: no provider ${id}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
const ageService = new AgeService(db, providers.map((id) => ageProviders[id]!()));
|
|
|
|
const { app, age, rooms, products } = createApp({ db, auth, imvu, age: ageService, apiURL, webURL, settings });
|
|
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" });
|
|
|
|
// Age answers that can change (IMVU's) are checked again when they expire, a few at a time; revisions missing their
|
|
// parent's info (IMVU was unavailable, or they predate it) get it, starting now; drafts never published go after 30
|
|
// days.
|
|
const parentInfo = () => products.fillParentInfo(20).catch((e) => console.error("parent info:", e));
|
|
const purgeDrafts = () =>
|
|
products
|
|
.purgeDrafts()
|
|
.then((n) => n > 0 && console.log(`deleted ${n} drafts never published`))
|
|
.catch((e) => console.error("drafts:", e));
|
|
parentInfo();
|
|
purgeDrafts();
|
|
setInterval(() => {
|
|
age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
|
|
rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e));
|
|
parentInfo();
|
|
purgeDrafts();
|
|
}, 10 * 60 * 1000);
|
|
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
|