create-vitnode-app 0.0.1-canary.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +78 -0
- package/copy-of-vitnode-app/.vscode/settings.json +10 -0
- package/copy-of-vitnode-app/README.md +40 -0
- package/copy-of-vitnode-app/api/.gitignore_template +32 -0
- package/copy-of-vitnode-app/api/drizzle.config.ts +12 -0
- package/copy-of-vitnode-app/api/src/i18n.ts +38 -0
- package/copy-of-vitnode-app/api/src/index.ts +27 -0
- package/copy-of-vitnode-app/api/src/vitnode.api.config.ts +40 -0
- package/copy-of-vitnode-app/api/tsconfig.json +17 -0
- package/copy-of-vitnode-app/api-bun/src/index.ts +16 -0
- package/copy-of-vitnode-app/api-single-app/drizzle.config.ts +12 -0
- package/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts +32 -0
- package/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts +22 -0
- package/copy-of-vitnode-app/api-single-app/src/server/vitnode-api.server.ts +29 -0
- package/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts +37 -0
- package/copy-of-vitnode-app/docker/docker-compose.yml +30 -0
- package/copy-of-vitnode-app/eslint/.prettierrc.mjs +11 -0
- package/copy-of-vitnode-app/eslint/eslint.config.mjs +19 -0
- package/copy-of-vitnode-app/eslint-react/.prettierrc.mjs +11 -0
- package/copy-of-vitnode-app/eslint-react/eslint.config.mjs +19 -0
- package/copy-of-vitnode-app/monorepo/.gitignore_template +41 -0
- package/copy-of-vitnode-app/monorepo/apps/api/.env.example +12 -0
- package/copy-of-vitnode-app/monorepo/apps/web/.env.example +12 -0
- package/copy-of-vitnode-app/monorepo/turbo.json +53 -0
- package/copy-of-vitnode-app/root/.env.example +22 -0
- package/copy-of-vitnode-app/root/.gitignore_template +41 -0
- package/copy-of-vitnode-app/root/global.d.ts +28 -0
- package/copy-of-vitnode-app/root/src/components/admin-shell.tsx +72 -0
- package/copy-of-vitnode-app/root/src/components/main-header.tsx +33 -0
- package/copy-of-vitnode-app/root/src/i18n.ts +36 -0
- package/copy-of-vitnode-app/root/src/lib/admin-auth.ts +11 -0
- package/copy-of-vitnode-app/root/src/lib/admin-nav.ts +43 -0
- package/copy-of-vitnode-app/root/src/lib/admin-search.ts +37 -0
- package/copy-of-vitnode-app/root/src/lib/auth.ts +66 -0
- package/copy-of-vitnode-app/root/src/lib/content-registry.ts +51 -0
- package/copy-of-vitnode-app/root/src/lib/document-headers.ts +128 -0
- package/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts +75 -0
- package/copy-of-vitnode-app/root/src/lib/i18n/shared.ts +20 -0
- package/copy-of-vitnode-app/root/src/lib/navigation.ts +24 -0
- package/copy-of-vitnode-app/root/src/lib/page-head.ts +19 -0
- package/copy-of-vitnode-app/root/src/locales/app.ts +28 -0
- package/copy-of-vitnode-app/root/src/locales/packages.ts +43 -0
- package/copy-of-vitnode-app/root/src/router.tsx +227 -0
- package/copy-of-vitnode-app/root/src/routes/__root.tsx +212 -0
- package/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx +70 -0
- package/copy-of-vitnode-app/root/src/routes/_admin.tsx +258 -0
- package/copy-of-vitnode-app/root/src/routes/_main/index.tsx +60 -0
- package/copy-of-vitnode-app/root/src/routes/_main.tsx +64 -0
- package/copy-of-vitnode-app/root/src/server/messages.server.ts +29 -0
- package/copy-of-vitnode-app/root/src/start.ts +88 -0
- package/copy-of-vitnode-app/root/src/styles.css +171 -0
- package/copy-of-vitnode-app/root/src/vitnode.config.ts +58 -0
- package/copy-of-vitnode-app/root/src/vitnode.shell.config.ts +35 -0
- package/copy-of-vitnode-app/root/tsconfig.json +29 -0
- package/copy-of-vitnode-app/root/tsr.config.json +3 -0
- package/copy-of-vitnode-app/root/vite.config.ts +119 -0
- package/copy-of-vitnode-plugin/root/.swcrc +26 -0
- package/copy-of-vitnode-plugin/root/global.d.ts +21 -0
- package/copy-of-vitnode-plugin/root/npmignore.template +17 -0
- package/copy-of-vitnode-plugin/root/tsconfig.build.json +5 -0
- package/copy-of-vitnode-plugin/root/tsconfig.json +25 -0
- package/dist/src/create/create-package-json.js +276 -0
- package/dist/src/create/create-vitnode.js +204 -0
- package/dist/src/create/package-versions.js +49 -0
- package/dist/src/helpers/get-available-package-managers.js +24 -0
- package/dist/src/helpers/get-package-json.js +2 -0
- package/dist/src/helpers/get-package-manager-from-root.js +15 -0
- package/dist/src/helpers/get-vitnode-package-version.js +9 -0
- package/dist/src/helpers/init-vitnode.js +34 -0
- package/dist/src/helpers/install-dependencies.js +71 -0
- package/dist/src/helpers/is-folder-empty.js +48 -0
- package/dist/src/helpers/is-online.js +38 -0
- package/dist/src/helpers/is-writeable.js +11 -0
- package/dist/src/helpers/packages-json.js +1 -0
- package/dist/src/helpers/validate-pkg.js +14 -0
- package/dist/src/helpers/with-If.js +1 -0
- package/dist/src/index.js +68 -0
- package/dist/src/plugin/create/add-plugin-to-workspace.js +74 -0
- package/dist/src/plugin/create/create-package-json.js +54 -0
- package/dist/src/plugin/create/create-plugin-vitnode.js +80 -0
- package/dist/src/plugin/create/route-templates.js +144 -0
- package/dist/src/plugin/index.js +29 -0
- package/dist/src/plugin/questions.js +14 -0
- package/dist/src/plugin/validation.js +76 -0
- package/dist/src/prepare/prepare.js +19 -0
- package/dist/src/questions.js +83 -0
- package/dist/src/validation.js +40 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://turborepo.dev/schema.json",
|
|
3
|
+
"ui": "tui",
|
|
4
|
+
"tasks": {
|
|
5
|
+
"db:prepare": {
|
|
6
|
+
"cache": false,
|
|
7
|
+
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
|
8
|
+
"env": ["POSTGRES_URL"]
|
|
9
|
+
},
|
|
10
|
+
"db:migrate": {
|
|
11
|
+
"dependsOn": ["^db:migrate"],
|
|
12
|
+
"cache": false,
|
|
13
|
+
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
|
14
|
+
"env": ["POSTGRES_URL"]
|
|
15
|
+
},
|
|
16
|
+
"build": {
|
|
17
|
+
"dependsOn": ["^build"],
|
|
18
|
+
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
|
19
|
+
"outputs": [".output/**", "dist/**"],
|
|
20
|
+
"env": ["POSTGRES_URL", "NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_WEB_URL"]
|
|
21
|
+
},
|
|
22
|
+
"dev": {
|
|
23
|
+
"cache": false,
|
|
24
|
+
"persistent": true,
|
|
25
|
+
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
|
26
|
+
"env": ["POSTGRES_URL", "NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_WEB_URL"]
|
|
27
|
+
},
|
|
28
|
+
"start": {
|
|
29
|
+
"dependsOn": ["^start"]
|
|
30
|
+
},
|
|
31
|
+
"lint": {
|
|
32
|
+
"dependsOn": ["^lint"]
|
|
33
|
+
},
|
|
34
|
+
"lint:fix": {
|
|
35
|
+
"dependsOn": ["^lint:fix"]
|
|
36
|
+
},
|
|
37
|
+
"i18n:create": {
|
|
38
|
+
"dependsOn": ["^i18n:create"]
|
|
39
|
+
},
|
|
40
|
+
"i18n:check": {
|
|
41
|
+
"dependsOn": ["^i18n:check"]
|
|
42
|
+
},
|
|
43
|
+
"i18n:delete": {
|
|
44
|
+
"dependsOn": ["^i18n:delete"]
|
|
45
|
+
},
|
|
46
|
+
"i18n:update": {
|
|
47
|
+
"dependsOn": ["^i18n:update"]
|
|
48
|
+
},
|
|
49
|
+
"i18n:update:ai": {
|
|
50
|
+
"dependsOn": ["^i18n:update:ai"]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
POSTGRES_URL=postgresql://root:root@localhost:5432/vitnode
|
|
2
|
+
REDIS_URL=redis://localhost:6379
|
|
3
|
+
|
|
4
|
+
# Both name this app's own origin, and it has to be the port `dev` actually
|
|
5
|
+
# serves. Neither is strictly required: on the server the API origin is taken
|
|
6
|
+
# off the request being rendered, and in the browser it falls back to the origin
|
|
7
|
+
# the page was served from - so a preview deployment on a generated hostname
|
|
8
|
+
# needs no config at all. Set `NEXT_PUBLIC_API_URL` only to point at a separate
|
|
9
|
+
# API server; `NEXT_PUBLIC_WEB_URL` is what the API stamps cookies with.
|
|
10
|
+
NEXT_PUBLIC_WEB_URL=http://localhost:3000
|
|
11
|
+
NEXT_PUBLIC_API_URL=http://localhost:3000
|
|
12
|
+
|
|
13
|
+
# === CRON Secret for Internal API Calls ===
|
|
14
|
+
CRON_SECRET=your-secure-cron-secret-key
|
|
15
|
+
|
|
16
|
+
# === Docker Database Postgres ===
|
|
17
|
+
POSTGRES_USER=root
|
|
18
|
+
POSTGRES_PASSWORD=root
|
|
19
|
+
POSTGRES_NAME=vitnode
|
|
20
|
+
|
|
21
|
+
# === Docker Redis ===
|
|
22
|
+
REDIS_PASSWORD=root
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules
|
|
3
|
+
|
|
4
|
+
# Turbo
|
|
5
|
+
.turbo
|
|
6
|
+
|
|
7
|
+
# Test & Build
|
|
8
|
+
/coverage
|
|
9
|
+
/.output/
|
|
10
|
+
/.nitro/
|
|
11
|
+
/.vite/
|
|
12
|
+
/.tanstack/
|
|
13
|
+
/out/
|
|
14
|
+
/build
|
|
15
|
+
*.tsbuildinfo
|
|
16
|
+
|
|
17
|
+
# Generated by the VitNode Vite plugin and the TanStack route generator
|
|
18
|
+
src/*.gen.ts
|
|
19
|
+
|
|
20
|
+
# Misc
|
|
21
|
+
.DS_Store
|
|
22
|
+
*.pem
|
|
23
|
+
/.pnp
|
|
24
|
+
.pnp.js
|
|
25
|
+
npm-debug.log*
|
|
26
|
+
yarn-debug.log*
|
|
27
|
+
yarn-error.log*
|
|
28
|
+
|
|
29
|
+
# Local env files
|
|
30
|
+
.env
|
|
31
|
+
.env.local
|
|
32
|
+
.env.development.local
|
|
33
|
+
.env.test.local
|
|
34
|
+
.env.production.local
|
|
35
|
+
|
|
36
|
+
# Others
|
|
37
|
+
.vercel
|
|
38
|
+
.env*.local
|
|
39
|
+
|
|
40
|
+
# Local storage adapter uploads
|
|
41
|
+
/public/uploads
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/// <reference types="use-intl" />
|
|
2
|
+
|
|
3
|
+
import core from '@vitnode/core/locales/en.json' with { type: 'json' }
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What `useTranslations` is allowed to be asked for.
|
|
7
|
+
*
|
|
8
|
+
* Augmenting **`use-intl`** rather than `next-intl`. `AppConfig` is declared by
|
|
9
|
+
* `use-intl`, which is what VitNode renders every string through on every host;
|
|
10
|
+
* `next-intl` only ever re-exported it. This is a type-level dependency and so
|
|
11
|
+
* it survives a grep for imports - which is exactly why it is worth naming.
|
|
12
|
+
*
|
|
13
|
+
* `use-intl` has to be a *direct* dependency of this app for the reference above
|
|
14
|
+
* to resolve. Under pnpm's strict `node_modules` layout, reaching it through
|
|
15
|
+
* `@vitnode/core` is not enough.
|
|
16
|
+
*
|
|
17
|
+
* Typed against core's English tree, which is the default locale: every other
|
|
18
|
+
* language falls back to it key by key, so it is the one that defines which keys
|
|
19
|
+
* exist. Add a plugin's tree to the intersection to get its keys checked too:
|
|
20
|
+
*
|
|
21
|
+
* import blog from '@acme/blog/locales/en.json' with { type: 'json' }
|
|
22
|
+
* Messages: typeof core & typeof blog
|
|
23
|
+
*/
|
|
24
|
+
declare module 'use-intl' {
|
|
25
|
+
interface AppConfig {
|
|
26
|
+
Messages: typeof core
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { AdminUserSearch } from "@vitnode/core/tanstack/admin";
|
|
2
|
+
|
|
3
|
+
import { AdminShellContent } from "@vitnode/core/tanstack/admin";
|
|
4
|
+
import { LanguageSwitcher } from "@vitnode/core/tanstack/layout";
|
|
5
|
+
|
|
6
|
+
import { adminNav } from "#/lib/admin-nav";
|
|
7
|
+
import { adminUserSearchFn } from "#/lib/admin-search";
|
|
8
|
+
import { useAppNavigate } from "#/lib/navigation";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The AdminCP shell, as this app mounts it.
|
|
12
|
+
*
|
|
13
|
+
* Everything the panel *is* - the sidebar, the header, the palette, the user
|
|
14
|
+
* menu, the breadcrumb area and the one `<main>` - is `AdminShellContent`'s.
|
|
15
|
+
* What is bound here is only what a package cannot answer for an application:
|
|
16
|
+
*
|
|
17
|
+
* onNavigate useAppNavigate the palette's Enter key
|
|
18
|
+
* searchUsers adminUserSearchFn this app's own server function
|
|
19
|
+
* languageSwitcher <LanguageSwitcher/> the router's
|
|
20
|
+
* nav adminNav the plugins *this* app configured
|
|
21
|
+
*
|
|
22
|
+
* No `LinkComponent`: every sidebar destination is a route in this application's
|
|
23
|
+
* own tree, so the shell's own default - `RouterLink` - is the right one. The
|
|
24
|
+
* exception is handled a layer down rather than here: a plugin's `admin.nav`
|
|
25
|
+
* entry may point at a docs site or a status page, and `adminLinkFor` renders
|
|
26
|
+
* those as plain anchors, so an absolute URL is never handed to a router that
|
|
27
|
+
* would try to match it. The sidebar and the command palette both go through it,
|
|
28
|
+
* so an entry cannot behave one way when clicked and another when searched.
|
|
29
|
+
*
|
|
30
|
+
* `onNavigate` is still passed, because the palette moves the router *without a
|
|
31
|
+
* link*: Enter on a highlighted entry is a navigation nobody clicked, and it has
|
|
32
|
+
* to be handed the same de-localized destination a `<Link>` would build. See
|
|
33
|
+
* `#/lib/navigation`.
|
|
34
|
+
*
|
|
35
|
+
* ## `nav` is a projection, not the plugin registry
|
|
36
|
+
*
|
|
37
|
+
* `src/vitnode.config.ts` is server-side and carries message loaders, which a
|
|
38
|
+
* browser bundle has no business holding. So the sidebar is built from
|
|
39
|
+
* `#/lib/admin-nav`, which reads the generated browser-safe projection: ids,
|
|
40
|
+
* hrefs, permissions, icons and content type definitions, and nothing that
|
|
41
|
+
* renders a screen. The screens have a projection of their own - see
|
|
42
|
+
* `#/lib/content-registry`, which the content route imports so that a plugin's
|
|
43
|
+
* editor fields and form layouts land in that route's chunk rather than in the
|
|
44
|
+
* shell's.
|
|
45
|
+
*
|
|
46
|
+
* It carries the message namespaces with it, because a plugin group's headings
|
|
47
|
+
* live under that plugin's own id and the shell would otherwise render them as
|
|
48
|
+
* dotted identifiers. `_admin`'s loader warms the same list.
|
|
49
|
+
*
|
|
50
|
+
* No navigation is derived from a plugin's route tree, in either direction: the
|
|
51
|
+
* navigation model is complete regardless of which screen a click lands on, and
|
|
52
|
+
* a nav entry is a product decision rather than a consequence of the route tree.
|
|
53
|
+
*/
|
|
54
|
+
const searchUsers: AdminUserSearch = async search =>
|
|
55
|
+
await adminUserSearchFn({ data: { search } });
|
|
56
|
+
|
|
57
|
+
export const AdminShell = ({ children }: { children: React.ReactNode }) => {
|
|
58
|
+
const navigate = useAppNavigate();
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<AdminShellContent
|
|
62
|
+
languageSwitcher={<LanguageSwitcher />}
|
|
63
|
+
nav={adminNav}
|
|
64
|
+
onNavigate={href => {
|
|
65
|
+
void navigate(href);
|
|
66
|
+
}}
|
|
67
|
+
searchUsers={searchUsers}
|
|
68
|
+
>
|
|
69
|
+
{children}
|
|
70
|
+
</AdminShellContent>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { MainHeader as MainHeaderContent } from "@vitnode/core/tanstack/layout";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The site header, as the main shell's slot for it.
|
|
5
|
+
*
|
|
6
|
+
* One line of application. The bar, the nav, the language and theme switchers,
|
|
7
|
+
* the user area and both cache entries they read are
|
|
8
|
+
* `@vitnode/core/tanstack/layout`'s `MainHeader`.
|
|
9
|
+
*
|
|
10
|
+
* **Your mark goes here.** Left alone the header renders VitNode's own, which is
|
|
11
|
+
* the right default and the wrong answer for a real site. Pass your own:
|
|
12
|
+
*
|
|
13
|
+
* <MainHeaderContent logo={<YourLogo />} />
|
|
14
|
+
*
|
|
15
|
+
* This file exists so that choosing a mark is a sentence a site says out loud
|
|
16
|
+
* rather than something it inherits without noticing.
|
|
17
|
+
*
|
|
18
|
+
* No `LinkComponent`. Every header destination is a route in this application's
|
|
19
|
+
* own tree, so the header's own default - `RouterLink`, the router's `Link` in
|
|
20
|
+
* the shape the shared views ask for - is the right one, and the prop stays
|
|
21
|
+
* available for a host that needs to answer differently.
|
|
22
|
+
*
|
|
23
|
+
* ## What the shell owes it
|
|
24
|
+
*
|
|
25
|
+
* Two warm cache entries, both ensured by `_main`'s loader:
|
|
26
|
+
*
|
|
27
|
+
* headerIntlQueryOptions -> a `useSuspenseQuery`, so this is required
|
|
28
|
+
* prefetchSession -> the first paint shows the visitor, not a gap
|
|
29
|
+
*
|
|
30
|
+
* See the loader in `routes/_main.tsx`, which states why one is `ensure` and the
|
|
31
|
+
* other `prefetch`.
|
|
32
|
+
*/
|
|
33
|
+
export const MainHeader = () => <MainHeaderContent />;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { VitNodeI18nConfig } from "@vitnode/core/lib/i18n/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The languages this app serves.
|
|
5
|
+
*
|
|
6
|
+
* Its own module so the web config and the API config can point at the same
|
|
7
|
+
* object instead of drifting apart - the site and the emails it sends have to
|
|
8
|
+
* agree on which languages exist.
|
|
9
|
+
*
|
|
10
|
+
* Add a language by adding an entry. Packages ship their own translations, so a
|
|
11
|
+
* new locale needs no `messages` here: anything a package has not translated
|
|
12
|
+
* falls back to `defaultLocale` key by key. Register the package's own file for
|
|
13
|
+
* that language in `src/locales/packages.ts`, and put your own rewording in
|
|
14
|
+
* `src/locales/app.ts`.
|
|
15
|
+
*/
|
|
16
|
+
export const i18n = {
|
|
17
|
+
defaultLocale: "en" as const,
|
|
18
|
+
/**
|
|
19
|
+
* Explicit, because the app renders on a server: without one, `use-intl`
|
|
20
|
+
* formats dates in whatever zone the server happens to run in and warns that
|
|
21
|
+
* the client will disagree. Stage 3, which owns the locale runtime, is where a
|
|
22
|
+
* per-visitor zone would come from.
|
|
23
|
+
*/
|
|
24
|
+
timeZone: "UTC",
|
|
25
|
+
/**
|
|
26
|
+
* `as const` on each code, and nothing else: it keeps `"en" | "pl"` out of
|
|
27
|
+
* the widening `satisfies` would otherwise do, which is what makes `Locale`
|
|
28
|
+
* in `lib/i18n/shared.ts` a real union rather than an alias for `string`.
|
|
29
|
+
*/
|
|
30
|
+
locales: [
|
|
31
|
+
{
|
|
32
|
+
code: "en" as const,
|
|
33
|
+
name: "English",
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
} satisfies VitNodeI18nConfig;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
2
|
+
import { setAdminTransport } from "@vitnode/core/tanstack/admin";
|
|
3
|
+
import { readAdminSessionOnApi } from "@vitnode/core/tanstack/admin/server";
|
|
4
|
+
|
|
5
|
+
export const readAdminSessionFn = createServerFn().handler(
|
|
6
|
+
async () => await readAdminSessionOnApi(),
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
setAdminTransport({
|
|
10
|
+
readAdminSession: async () => await readAdminSessionFn(),
|
|
11
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { adminNavBundle } from "@vitnode/core/tanstack/admin";
|
|
2
|
+
|
|
3
|
+
import { pluginAdminNav } from "#/admin-nav.gen";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* This installation's AdminCP navigation - core's own, plus whatever the plugins
|
|
7
|
+
* it configured contribute.
|
|
8
|
+
*
|
|
9
|
+
* Two lines, and both of them are host-only work by necessity. Which plugins are
|
|
10
|
+
* installed is a property of this application, and `src/admin-nav.gen.ts` is
|
|
11
|
+
* where the build writes the answer: one literal import per configured plugin
|
|
12
|
+
* that exports an `admin/nav` module. Every *rule* about what a sidebar contains
|
|
13
|
+
* - which entries a content type earns, how a hand-declared entry is titled,
|
|
14
|
+
* which permission hides one, which message namespaces the result needs - is
|
|
15
|
+
* `@vitnode/core`'s, in `adminNavBundle`. Nothing about navigation is decided
|
|
16
|
+
* here.
|
|
17
|
+
*
|
|
18
|
+
* ## Why it is not read from `vitnode.config.ts`
|
|
19
|
+
*
|
|
20
|
+
* That config is server-side on purpose (`vitnode.shell.config.ts` explains the
|
|
21
|
+
* split): it carries message loaders and API wiring, which a browser bundle has
|
|
22
|
+
* no business holding. A sidebar needs the ids, the hrefs, the permissions, the
|
|
23
|
+
* icons and the content type definitions, all of which are plain data. The
|
|
24
|
+
* generated projection is exactly that half, so the browser gets the navigation
|
|
25
|
+
* without the Content Engine's UI - which arrives, when a content screen
|
|
26
|
+
* actually renders, through `src/lib/content-registry.ts` instead.
|
|
27
|
+
*
|
|
28
|
+
* ## Module scope, and why that matters twice
|
|
29
|
+
*
|
|
30
|
+
* Evaluated once per bundle rather than per render. `AdminShellContent` memoises
|
|
31
|
+
* its namespace list on this object's identity, and `_admin`'s loader warms the
|
|
32
|
+
* messages from the same `namespaces` array the shell then reads - so a stable
|
|
33
|
+
* value here is what makes those one cache entry rather than two.
|
|
34
|
+
*
|
|
35
|
+
* ## Content entries were never rewritten, and that is the point
|
|
36
|
+
*
|
|
37
|
+
* A content type's entry is `/admin/content/…`, and it said exactly that
|
|
38
|
+
* through every change in which framework rendered those screens. Only *how a
|
|
39
|
+
* click travels* ever changed, and nothing here was edited for it. Routes and
|
|
40
|
+
* navigation stay separate concepts: nothing derives a nav entry from a route
|
|
41
|
+
* file, and nothing makes one conditional on a route existing.
|
|
42
|
+
*/
|
|
43
|
+
export const adminNav = adminNavBundle({ plugins: pluginAdminNav });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
2
|
+
import { readAdminUserSearchOnApi } from "@vitnode/core/tanstack/admin/server";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The AdminCP command palette's user lookup, as this app's server function.
|
|
7
|
+
*
|
|
8
|
+
* One handler, one line, exactly like `lib/auth.ts` - and here for the same
|
|
9
|
+
* reason. `createServerFn` needs the module it sits in to be transformed by the
|
|
10
|
+
* Start compiler on both sides of the render, and `@vitnode/core` reaches the
|
|
11
|
+
* server un-compiled (see the note in `lib/auth.ts`), so the declaration lives
|
|
12
|
+
* in the host and the behaviour lives in the package.
|
|
13
|
+
*
|
|
14
|
+
* ## Why it has a validator
|
|
15
|
+
*
|
|
16
|
+
* A server function is a public same-origin endpoint: its input is whatever a
|
|
17
|
+
* caller posts, not whatever the dialog typed. This value reaches an API query
|
|
18
|
+
* string, so it is parsed rather than trusted - trimmed, length-bounded, and a
|
|
19
|
+
* string at all.
|
|
20
|
+
*
|
|
21
|
+
* The bound is not a security control; the API authorizes the read from the
|
|
22
|
+
* admin cookie and pages it itself. It is a cheap refusal of the requests that
|
|
23
|
+
* could only ever be abuse - a megabyte of "search term" that the API would
|
|
24
|
+
* otherwise have to hand to Postgres.
|
|
25
|
+
*
|
|
26
|
+
* `POST` puts it behind `createCsrfMiddleware` in `src/start.ts`, which matters
|
|
27
|
+
* more here than it looks: this reads other people's names and email addresses,
|
|
28
|
+
* and a `GET` would be reachable from another origin's page with the
|
|
29
|
+
* administrator's cookies attached.
|
|
30
|
+
*/
|
|
31
|
+
const adminUserSearchInput = z.object({
|
|
32
|
+
search: z.string().trim().min(1).max(128),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const adminUserSearchFn = createServerFn({ method: "POST" })
|
|
36
|
+
.validator(adminUserSearchInput)
|
|
37
|
+
.handler(async ({ data }) => await readAdminUserSearchOnApi(data.search));
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
2
|
+
import {
|
|
3
|
+
changePasswordInputSchema,
|
|
4
|
+
passwordResetRequestInputSchema,
|
|
5
|
+
setAuthTransport,
|
|
6
|
+
signInInputSchema,
|
|
7
|
+
signOutInputSchema,
|
|
8
|
+
signUpInputSchema,
|
|
9
|
+
ssoCallbackInputSchema,
|
|
10
|
+
ssoStartInputSchema,
|
|
11
|
+
} from "@vitnode/core/tanstack/auth";
|
|
12
|
+
import {
|
|
13
|
+
changePasswordFromResetOnApi,
|
|
14
|
+
completeSsoOnApi,
|
|
15
|
+
readSessionOnApi,
|
|
16
|
+
requestPasswordResetOnApi,
|
|
17
|
+
signInOnApi,
|
|
18
|
+
signOutOnApi,
|
|
19
|
+
signUpOnApi,
|
|
20
|
+
startSsoOnApi,
|
|
21
|
+
} from "@vitnode/core/tanstack/auth/server";
|
|
22
|
+
|
|
23
|
+
export const readSessionFn = createServerFn().handler(
|
|
24
|
+
async () => await readSessionOnApi(),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export const signInFn = createServerFn({ method: "POST" })
|
|
28
|
+
.validator(signInInputSchema)
|
|
29
|
+
.handler(async ({ data }) => await signInOnApi(data));
|
|
30
|
+
|
|
31
|
+
export const signOutFn = createServerFn({ method: "POST" })
|
|
32
|
+
.validator(signOutInputSchema)
|
|
33
|
+
.handler(async ({ data }) => await signOutOnApi(data));
|
|
34
|
+
|
|
35
|
+
export const startSsoFn = createServerFn({ method: "POST" })
|
|
36
|
+
.validator(ssoStartInputSchema)
|
|
37
|
+
.handler(async ({ data }) => await startSsoOnApi(data));
|
|
38
|
+
|
|
39
|
+
export const completeSsoFn = createServerFn({ method: "POST" })
|
|
40
|
+
.validator(ssoCallbackInputSchema)
|
|
41
|
+
.handler(async ({ data }) => await completeSsoOnApi(data));
|
|
42
|
+
|
|
43
|
+
export const signUpFn = createServerFn({ method: "POST" })
|
|
44
|
+
.validator(signUpInputSchema)
|
|
45
|
+
.handler(async ({ data }) => await signUpOnApi(data));
|
|
46
|
+
|
|
47
|
+
export const requestPasswordResetFn = createServerFn({ method: "POST" })
|
|
48
|
+
.validator(passwordResetRequestInputSchema)
|
|
49
|
+
.handler(async ({ data }) => await requestPasswordResetOnApi(data));
|
|
50
|
+
|
|
51
|
+
export const changePasswordFromResetFn = createServerFn({ method: "POST" })
|
|
52
|
+
.validator(changePasswordInputSchema)
|
|
53
|
+
.handler(async ({ data }) => await changePasswordFromResetOnApi(data));
|
|
54
|
+
|
|
55
|
+
setAuthTransport({
|
|
56
|
+
changePasswordFromReset: async input =>
|
|
57
|
+
await changePasswordFromResetFn({ data: input }),
|
|
58
|
+
completeSso: async input => await completeSsoFn({ data: input }),
|
|
59
|
+
readSession: async () => await readSessionFn(),
|
|
60
|
+
requestPasswordReset: async input =>
|
|
61
|
+
await requestPasswordResetFn({ data: input }),
|
|
62
|
+
signIn: async input => await signInFn({ data: input }),
|
|
63
|
+
signOut: async input => await signOutFn({ data: input }),
|
|
64
|
+
signUp: async input => await signUpFn({ data: input }),
|
|
65
|
+
startSso: async input => await startSsoFn({ data: input }),
|
|
66
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildContentFrontendRegistry,
|
|
3
|
+
setContentFrontendRegistry,
|
|
4
|
+
} from "@vitnode/core/content";
|
|
5
|
+
|
|
6
|
+
import { pluginContentTypes } from "#/content-registry.gen";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* This installation's Content Engine registry - every content type its
|
|
10
|
+
* configured plugins register, with their editing screens attached.
|
|
11
|
+
*
|
|
12
|
+
* Two lines, and both of them are host-only work by necessity. Which plugins are
|
|
13
|
+
* installed is a property of this application, and `src/content-registry.gen.ts`
|
|
14
|
+
* is where the build writes the answer: one literal import per configured plugin
|
|
15
|
+
* that exports an `admin/content` module. Every *rule* about what a registration
|
|
16
|
+
* is - which paths are legal, which two of them collide, how they are ordered,
|
|
17
|
+
* how one is found by id or by `admin.path` - is `@vitnode/core`'s, in
|
|
18
|
+
* `buildContentFrontendRegistry`. Nothing about the Content Engine is decided
|
|
19
|
+
* here.
|
|
20
|
+
*
|
|
21
|
+
* ## Why it is not read from `vitnode.config.ts`
|
|
22
|
+
*
|
|
23
|
+
* That config is server-side on purpose (`vitnode.shell.config.ts` explains the
|
|
24
|
+
* split): it carries message loaders and API wiring, which a browser bundle has
|
|
25
|
+
* no business holding. The generated projection is the browser-safe half - the
|
|
26
|
+
* definitions, the icons and the override components - so the AdminCP gets the
|
|
27
|
+
* content screens without the server config. The same arrangement
|
|
28
|
+
* `src/lib/admin-nav.ts` uses for the sidebar, one layer deeper.
|
|
29
|
+
*
|
|
30
|
+
* ## Registration, and where it belongs in the import graph
|
|
31
|
+
*
|
|
32
|
+
* `setContentFrontendRegistry` fills a module-scope slot in `@vitnode/core`, so
|
|
33
|
+
* the package's own content code finds the registry without being handed it as
|
|
34
|
+
* a prop - the same shape as `setAdminTransport`. Module scope means *per
|
|
35
|
+
* bundle*: the browser has one instance and the server has one, and each
|
|
36
|
+
* registers its own.
|
|
37
|
+
*
|
|
38
|
+
* This module is reached through a `() => import(...)` that `/admin/content`'s
|
|
39
|
+
* loader awaits, never through a static import, and that is deliberate.
|
|
40
|
+
* Registration only has to happen before a content screen runs, and deferring it
|
|
41
|
+
* to the route is what lets Rollup put this whole graph - every plugin's field
|
|
42
|
+
* components, table cells and form layouts, plus `zod` and the Content Engine
|
|
43
|
+
* itself - in that route's chunk instead of in the bundle every page of the site
|
|
44
|
+
* loads first. `router.tsx` holds the thunk because that is where the route tree
|
|
45
|
+
* is composed; what it does *not* hold is the value. A plugin's own heavier
|
|
46
|
+
* parts stay lazier still: `@vitnode/blog` draws a `React.lazy` boundary around
|
|
47
|
+
* its Tiptap editor, so even opening the article list does not fetch it.
|
|
48
|
+
*/
|
|
49
|
+
export const contentRegistry = buildContentFrontendRegistry(pluginContentTypes);
|
|
50
|
+
|
|
51
|
+
setContentFrontendRegistry(contentRegistry);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a VitNode document response may say about being stored.
|
|
3
|
+
*
|
|
4
|
+
* `private, no-store`, and it is not a precaution - it is a description of what
|
|
5
|
+
* is in the body. Every page this application renders streams a dehydrated Query
|
|
6
|
+
* cache into its HTML, and that cache always holds `["vitnode","session"]`: the
|
|
7
|
+
* visitor's own name, avatar and `isAdmin` flag. Inside `/admin` it also holds
|
|
8
|
+
* `["vitnode","admin-session"]`, which is that administrator's entire permission
|
|
9
|
+
* set. `tanstack/auth/session-query` and `tanstack/admin/session-query` both say
|
|
10
|
+
* so in their own words - "that document is personalised and must not be served
|
|
11
|
+
* from a shared cache" - and until now nothing on the response said it back.
|
|
12
|
+
*
|
|
13
|
+
* Nothing caches these documents today. That is a property of the current
|
|
14
|
+
* deployment, not of the application: the moment a CDN, a reverse proxy or a
|
|
15
|
+
* `Cache-Control`-respecting edge sits in front of the Node server, an absent
|
|
16
|
+
* directive is an invitation to store one visitor's HTML and serve it to the
|
|
17
|
+
* next. Stage 15 is when that becomes likely, so the header belongs here now.
|
|
18
|
+
*
|
|
19
|
+
* ## It is an invariant, not a default
|
|
20
|
+
*
|
|
21
|
+
* This started as a fallback for documents that said nothing, and that was
|
|
22
|
+
* wrong. A default is something a route may override, and at this architecture
|
|
23
|
+
* level there is no override a route could correctly choose: the dehydrated
|
|
24
|
+
* cache is written into the stream by `setupRouterSsrQueryIntegration` for
|
|
25
|
+
* *every* document, so a route opting into `public, max-age=60` would be
|
|
26
|
+
* publishing whichever visitor rendered first to everyone who asked next. The
|
|
27
|
+
* route cannot know that, because the private payload is not something the route
|
|
28
|
+
* put there.
|
|
29
|
+
*
|
|
30
|
+
* So the directive is forced rather than filled in, and a route that sets its
|
|
31
|
+
* own is overwritten rather than obeyed. That is the whole difference between
|
|
32
|
+
* this being a hardening measure and it being a security invariant.
|
|
33
|
+
*
|
|
34
|
+
* Public document caching is not forbidden forever - it is forbidden *while the
|
|
35
|
+
* session is dehydrated into the document*. Introducing it later is a separate,
|
|
36
|
+
* explicit piece of architecture in which the private state is kept out of the
|
|
37
|
+
* shared body (a public shell fetching its session client-side, say), and the
|
|
38
|
+
* invariant here would move with it rather than being quietly relaxed by a route
|
|
39
|
+
* that wanted a faster page.
|
|
40
|
+
*
|
|
41
|
+
* `private` bars a shared cache. `no-store` bars every cache, including the
|
|
42
|
+
* browser's own disk cache - which is the half that matters on a shared machine,
|
|
43
|
+
* where the previous person's permission set should not be recoverable from
|
|
44
|
+
* `chrome://cache` after they sign out. The pair is the standard spelling for
|
|
45
|
+
* "this body belongs to exactly one person, once".
|
|
46
|
+
*
|
|
47
|
+
* The known cost is the back/forward cache. `no-store` used to make a page
|
|
48
|
+
* outright ineligible for bfcache in Chromium; current versions keep such pages
|
|
49
|
+
* eligible but evict them when cookies change - which for this app means a
|
|
50
|
+
* sign-in or sign-out invalidates a back-navigation that would otherwise have
|
|
51
|
+
* restored a page rendered for the previous session. That is the correct trade
|
|
52
|
+
* and the outcome anybody would want, but it is a real difference and it is
|
|
53
|
+
* worth a look during a manual pass rather than a surprise later.
|
|
54
|
+
*/
|
|
55
|
+
export const DOCUMENT_CACHE_CONTROL = "private, no-store";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether this response is one of the documents the rule above describes.
|
|
59
|
+
*
|
|
60
|
+
* One question, and it is what keeps the API out of it. `/api/*` is served by
|
|
61
|
+
* the Hono bridge through this same middleware, and a bare `GET` from it carries
|
|
62
|
+
* no `Cache-Control` of its own - so a rule that applied to every response would
|
|
63
|
+
* quietly forbid clients from caching the API. An HTML content-type is the
|
|
64
|
+
* honest way to ask "is this a page", it needs no path list to be kept in step
|
|
65
|
+
* with the router, and it cannot be wrong about a response that has already been
|
|
66
|
+
* produced.
|
|
67
|
+
*
|
|
68
|
+
* It deliberately does *not* ask whether a directive is already present. That
|
|
69
|
+
* used to be the second half of this predicate, and it is exactly the exemption
|
|
70
|
+
* the invariant above cannot afford - see {@link applyDocumentCacheControl}.
|
|
71
|
+
*
|
|
72
|
+
* A redirect is deliberately not matched here - it has no content type - and is
|
|
73
|
+
* handled by {@link applyRedirectCacheControl} instead, which wants a narrower
|
|
74
|
+
* rule.
|
|
75
|
+
*/
|
|
76
|
+
const isRenderedDocument = (headers: Headers): boolean =>
|
|
77
|
+
(headers.get("content-type") ?? "").toLowerCase().startsWith("text/html");
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Says what a rendered document is, on the response about to be sent.
|
|
81
|
+
*
|
|
82
|
+
* `set` rather than a conditional fill, and that is the fix: whatever the
|
|
83
|
+
* response was carrying is replaced. A route cannot opt out, because a route is
|
|
84
|
+
* not in a position to know what is in the body it is opting out for.
|
|
85
|
+
*
|
|
86
|
+
* Only `text/html` is touched. Everything else the middleware sees - the API,
|
|
87
|
+
* assets, client chunks, a `204` with no content type at all - keeps whatever it
|
|
88
|
+
* had, including nothing.
|
|
89
|
+
*
|
|
90
|
+
* Mutates rather than returning a new `Response`, because the middleware already
|
|
91
|
+
* holds the one Start produced and rebuilding it would mean copying a stream.
|
|
92
|
+
* The same reason `set-cookie` is appended in place a few lines away.
|
|
93
|
+
*/
|
|
94
|
+
export const applyDocumentCacheControl = (response: Response): void => {
|
|
95
|
+
if (!isRenderedDocument(response.headers)) return;
|
|
96
|
+
|
|
97
|
+
response.headers.set("cache-control", DOCUMENT_CACHE_CONTROL);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The same for a locale redirect, but only when it is carrying a cookie.
|
|
102
|
+
*
|
|
103
|
+
* A `308` from `/en/discover` to `/discover` is a fact about URLs, identical for
|
|
104
|
+
* every visitor, and permanently cacheable - which is most of the point of
|
|
105
|
+
* answering with one. So it keeps that property by default.
|
|
106
|
+
*
|
|
107
|
+
* The exception is the redirect that also writes the locale cookie, which is
|
|
108
|
+
* what `/pl/admin` produces: a stored copy of that would hand the next visitor
|
|
109
|
+
* through the same shared cache a `Set-Cookie` chosen by somebody else, and
|
|
110
|
+
* quietly switch their language. Shared caches are generally expected to refuse
|
|
111
|
+
* a `Set-Cookie` response, but "generally expected" is not a property this
|
|
112
|
+
* application can assert about somebody else's proxy, and one visitor's cookie
|
|
113
|
+
* reaching another's browser is not the kind of thing to leave to convention.
|
|
114
|
+
*
|
|
115
|
+
* So the cookie-carrying case is forced, for the same reason the document is: a
|
|
116
|
+
* `public` directive already on such a redirect is overwritten rather than
|
|
117
|
+
* respected, because the thing that makes it unsafe to share is the `Set-Cookie`
|
|
118
|
+
* beside it and not whatever the directive claims.
|
|
119
|
+
*
|
|
120
|
+
* The cookie-less case keeps its existing semantics untouched, directive and
|
|
121
|
+
* all. It carries no private state, so there is nothing here to protect and
|
|
122
|
+
* nothing to override.
|
|
123
|
+
*/
|
|
124
|
+
export const applyRedirectCacheControl = (response: Response): void => {
|
|
125
|
+
if (!response.headers.has("set-cookie")) return;
|
|
126
|
+
|
|
127
|
+
response.headers.set("cache-control", DOCUMENT_CACHE_CONTROL);
|
|
128
|
+
};
|