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.
Files changed (90) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +78 -0
  3. package/copy-of-vitnode-app/.vscode/settings.json +10 -0
  4. package/copy-of-vitnode-app/README.md +40 -0
  5. package/copy-of-vitnode-app/api/.gitignore_template +32 -0
  6. package/copy-of-vitnode-app/api/drizzle.config.ts +12 -0
  7. package/copy-of-vitnode-app/api/src/i18n.ts +38 -0
  8. package/copy-of-vitnode-app/api/src/index.ts +27 -0
  9. package/copy-of-vitnode-app/api/src/vitnode.api.config.ts +40 -0
  10. package/copy-of-vitnode-app/api/tsconfig.json +17 -0
  11. package/copy-of-vitnode-app/api-bun/src/index.ts +16 -0
  12. package/copy-of-vitnode-app/api-single-app/drizzle.config.ts +12 -0
  13. package/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts +32 -0
  14. package/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts +22 -0
  15. package/copy-of-vitnode-app/api-single-app/src/server/vitnode-api.server.ts +29 -0
  16. package/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts +37 -0
  17. package/copy-of-vitnode-app/docker/docker-compose.yml +30 -0
  18. package/copy-of-vitnode-app/eslint/.prettierrc.mjs +11 -0
  19. package/copy-of-vitnode-app/eslint/eslint.config.mjs +19 -0
  20. package/copy-of-vitnode-app/eslint-react/.prettierrc.mjs +11 -0
  21. package/copy-of-vitnode-app/eslint-react/eslint.config.mjs +19 -0
  22. package/copy-of-vitnode-app/monorepo/.gitignore_template +41 -0
  23. package/copy-of-vitnode-app/monorepo/apps/api/.env.example +12 -0
  24. package/copy-of-vitnode-app/monorepo/apps/web/.env.example +12 -0
  25. package/copy-of-vitnode-app/monorepo/turbo.json +53 -0
  26. package/copy-of-vitnode-app/root/.env.example +22 -0
  27. package/copy-of-vitnode-app/root/.gitignore_template +41 -0
  28. package/copy-of-vitnode-app/root/global.d.ts +28 -0
  29. package/copy-of-vitnode-app/root/src/components/admin-shell.tsx +72 -0
  30. package/copy-of-vitnode-app/root/src/components/main-header.tsx +33 -0
  31. package/copy-of-vitnode-app/root/src/i18n.ts +36 -0
  32. package/copy-of-vitnode-app/root/src/lib/admin-auth.ts +11 -0
  33. package/copy-of-vitnode-app/root/src/lib/admin-nav.ts +43 -0
  34. package/copy-of-vitnode-app/root/src/lib/admin-search.ts +37 -0
  35. package/copy-of-vitnode-app/root/src/lib/auth.ts +66 -0
  36. package/copy-of-vitnode-app/root/src/lib/content-registry.ts +51 -0
  37. package/copy-of-vitnode-app/root/src/lib/document-headers.ts +128 -0
  38. package/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts +75 -0
  39. package/copy-of-vitnode-app/root/src/lib/i18n/shared.ts +20 -0
  40. package/copy-of-vitnode-app/root/src/lib/navigation.ts +24 -0
  41. package/copy-of-vitnode-app/root/src/lib/page-head.ts +19 -0
  42. package/copy-of-vitnode-app/root/src/locales/app.ts +28 -0
  43. package/copy-of-vitnode-app/root/src/locales/packages.ts +43 -0
  44. package/copy-of-vitnode-app/root/src/router.tsx +227 -0
  45. package/copy-of-vitnode-app/root/src/routes/__root.tsx +212 -0
  46. package/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx +70 -0
  47. package/copy-of-vitnode-app/root/src/routes/_admin.tsx +258 -0
  48. package/copy-of-vitnode-app/root/src/routes/_main/index.tsx +60 -0
  49. package/copy-of-vitnode-app/root/src/routes/_main.tsx +64 -0
  50. package/copy-of-vitnode-app/root/src/server/messages.server.ts +29 -0
  51. package/copy-of-vitnode-app/root/src/start.ts +88 -0
  52. package/copy-of-vitnode-app/root/src/styles.css +171 -0
  53. package/copy-of-vitnode-app/root/src/vitnode.config.ts +58 -0
  54. package/copy-of-vitnode-app/root/src/vitnode.shell.config.ts +35 -0
  55. package/copy-of-vitnode-app/root/tsconfig.json +29 -0
  56. package/copy-of-vitnode-app/root/tsr.config.json +3 -0
  57. package/copy-of-vitnode-app/root/vite.config.ts +119 -0
  58. package/copy-of-vitnode-plugin/root/.swcrc +26 -0
  59. package/copy-of-vitnode-plugin/root/global.d.ts +21 -0
  60. package/copy-of-vitnode-plugin/root/npmignore.template +17 -0
  61. package/copy-of-vitnode-plugin/root/tsconfig.build.json +5 -0
  62. package/copy-of-vitnode-plugin/root/tsconfig.json +25 -0
  63. package/dist/src/create/create-package-json.js +276 -0
  64. package/dist/src/create/create-vitnode.js +204 -0
  65. package/dist/src/create/package-versions.js +49 -0
  66. package/dist/src/helpers/get-available-package-managers.js +24 -0
  67. package/dist/src/helpers/get-package-json.js +2 -0
  68. package/dist/src/helpers/get-package-manager-from-root.js +15 -0
  69. package/dist/src/helpers/get-vitnode-package-version.js +9 -0
  70. package/dist/src/helpers/init-vitnode.js +34 -0
  71. package/dist/src/helpers/install-dependencies.js +71 -0
  72. package/dist/src/helpers/is-folder-empty.js +48 -0
  73. package/dist/src/helpers/is-online.js +38 -0
  74. package/dist/src/helpers/is-writeable.js +11 -0
  75. package/dist/src/helpers/packages-json.js +1 -0
  76. package/dist/src/helpers/validate-pkg.js +14 -0
  77. package/dist/src/helpers/with-If.js +1 -0
  78. package/dist/src/index.js +68 -0
  79. package/dist/src/plugin/create/add-plugin-to-workspace.js +74 -0
  80. package/dist/src/plugin/create/create-package-json.js +54 -0
  81. package/dist/src/plugin/create/create-plugin-vitnode.js +80 -0
  82. package/dist/src/plugin/create/route-templates.js +144 -0
  83. package/dist/src/plugin/index.js +29 -0
  84. package/dist/src/plugin/questions.js +14 -0
  85. package/dist/src/plugin/validation.js +76 -0
  86. package/dist/src/prepare/prepare.js +19 -0
  87. package/dist/src/questions.js +83 -0
  88. package/dist/src/validation.js +40 -0
  89. package/dist/tsconfig.build.tsbuildinfo +1 -0
  90. 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
+ };