@micacms/runtime 0.5.0-rc.2 → 0.6.0-rc.1
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/README.md +3 -1
- package/dist/app.js +1 -1
- package/dist/app.js.map +1 -1
- package/dist/config.d.ts +13 -13
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +9 -9
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +2 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +9 -3
- package/dist/context.js.map +1 -1
- package/dist/http.d.ts +1 -3
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +2 -9
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/errors.d.ts +1 -2
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +0 -2
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/id.d.ts +0 -10
- package/dist/lib/id.d.ts.map +1 -1
- package/dist/lib/id.js +0 -50
- package/dist/lib/id.js.map +1 -1
- package/dist/lib/rate-limit.d.ts +0 -2
- package/dist/lib/rate-limit.d.ts.map +1 -1
- package/dist/lib/rate-limit.js +0 -3
- package/dist/lib/rate-limit.js.map +1 -1
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +2 -2
- package/dist/middleware/index.js.map +1 -1
- package/dist/routes/admin-content.d.ts.map +1 -1
- package/dist/routes/admin-content.js +4 -3
- package/dist/routes/admin-content.js.map +1 -1
- package/dist/routes/admin-media.d.ts.map +1 -1
- package/dist/routes/admin-media.js +6 -1
- package/dist/routes/admin-media.js.map +1 -1
- package/dist/routes/admin-system.d.ts.map +1 -1
- package/dist/routes/admin-system.js +6 -4
- package/dist/routes/admin-system.js.map +1 -1
- package/dist/schema.d.ts +15 -3
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +63 -10
- package/dist/schema.js.map +1 -1
- package/dist/services/changes.d.ts.map +1 -1
- package/dist/services/changes.js +89 -13
- package/dist/services/changes.js.map +1 -1
- package/dist/services/content-store.d.ts +6 -1
- package/dist/services/content-store.d.ts.map +1 -1
- package/dist/services/content-store.js +25 -15
- package/dist/services/content-store.js.map +1 -1
- package/dist/services/staging.d.ts +2 -0
- package/dist/services/staging.d.ts.map +1 -1
- package/dist/services/staging.js +15 -4
- package/dist/services/staging.js.map +1 -1
- package/dist/workspace/tree.d.ts +6 -0
- package/dist/workspace/tree.d.ts.map +1 -1
- package/dist/workspace/tree.js +28 -0
- package/dist/workspace/tree.js.map +1 -1
- package/package.json +12 -4
- package/src/app.ts +127 -0
- package/src/config.ts +113 -0
- package/src/context.ts +167 -0
- package/src/http.ts +46 -0
- package/src/index.ts +141 -0
- package/src/lib/errors.ts +84 -0
- package/src/lib/file-type.ts +108 -0
- package/src/lib/id.ts +6 -0
- package/src/lib/logger.ts +87 -0
- package/src/lib/ports.ts +17 -0
- package/src/lib/rate-limit.ts +72 -0
- package/src/lib/schema-stats.ts +183 -0
- package/src/lib/version.ts +29 -0
- package/src/middleware/index.ts +234 -0
- package/src/routes/admin-content.ts +197 -0
- package/src/routes/admin-media.ts +96 -0
- package/src/routes/admin-spa.ts +82 -0
- package/src/routes/admin-staging.ts +126 -0
- package/src/routes/admin-system.ts +146 -0
- package/src/routes/health.ts +67 -0
- package/src/schema.ts +217 -0
- package/src/server.ts +68 -0
- package/src/services/changes.ts +605 -0
- package/src/services/content-store.ts +492 -0
- package/src/services/media.ts +229 -0
- package/src/services/staging.ts +330 -0
- package/src/workspace/developer.ts +46 -0
- package/src/workspace/files.ts +243 -0
- package/src/workspace/git.ts +108 -0
- package/src/workspace/locks.ts +103 -0
- package/src/workspace/policy.ts +77 -0
- package/src/workspace/tree.ts +314 -0
package/package.json
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@micacms/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0-rc.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Mica single-site runtime: the Hono API, application services and staging orchestration, authentication-neutral. Composed by @micacms/editor locally and @micacms/self-hosted behind OIDC.",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"homepage": "https://micacms.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/micacms/mica.git",
|
|
11
|
+
"directory": "packages/runtime"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/micacms/mica/issues",
|
|
7
14
|
"author": "Alexander Müdespacher",
|
|
8
15
|
"keywords": [
|
|
9
16
|
"mica",
|
|
@@ -22,14 +29,15 @@
|
|
|
22
29
|
}
|
|
23
30
|
},
|
|
24
31
|
"files": [
|
|
25
|
-
"dist"
|
|
32
|
+
"dist",
|
|
33
|
+
"src"
|
|
26
34
|
],
|
|
27
35
|
"dependencies": {
|
|
28
36
|
"@hono/node-server": "^1.13.8",
|
|
29
37
|
"hono": "^4.13.7",
|
|
30
38
|
"image-size": "^2.0.3",
|
|
31
39
|
"zod": "^4.3.6",
|
|
32
|
-
"@micacms/core": "0.
|
|
40
|
+
"@micacms/core": "0.6.0-rc.1"
|
|
33
41
|
},
|
|
34
42
|
"devDependencies": {
|
|
35
43
|
"@types/node": "^22.20.1",
|
|
@@ -37,7 +45,7 @@
|
|
|
37
45
|
"typescript": "^5.7.3"
|
|
38
46
|
},
|
|
39
47
|
"engines": {
|
|
40
|
-
"node": ">=
|
|
48
|
+
"node": ">=24"
|
|
41
49
|
},
|
|
42
50
|
"scripts": {
|
|
43
51
|
"build": "tsc -p tsconfig.json",
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assembles the app: health, the admin API and the admin SPA. There is no
|
|
3
|
+
* public content API and no `/media/*`; a website builds from the repository,
|
|
4
|
+
* not from this server. Authentication comes from the composing app.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import { cors } from "hono/cors";
|
|
9
|
+
import type { MiddlewareHandler } from "hono";
|
|
10
|
+
|
|
11
|
+
import type { RuntimeContext } from "./context.js";
|
|
12
|
+
import type { Env } from "./http.js";
|
|
13
|
+
import {
|
|
14
|
+
allowedOrigins,
|
|
15
|
+
errorHandler,
|
|
16
|
+
originGuard,
|
|
17
|
+
requestSizeLimit,
|
|
18
|
+
rateLimit,
|
|
19
|
+
requestContext,
|
|
20
|
+
securityHeaders,
|
|
21
|
+
} from "./middleware/index.js";
|
|
22
|
+
import { adminContentRoutes } from "./routes/admin-content.js";
|
|
23
|
+
import { adminMediaRoutes } from "./routes/admin-media.js";
|
|
24
|
+
import { adminSpaRoutes, adminAssetsExist } from "./routes/admin-spa.js";
|
|
25
|
+
import { adminStagingRoutes } from "./routes/admin-staging.js";
|
|
26
|
+
import { adminSystemRoutes } from "./routes/admin-system.js";
|
|
27
|
+
import { healthRoutes } from "./routes/health.js";
|
|
28
|
+
|
|
29
|
+
/** What a composing app contributes around the auth-neutral runtime. */
|
|
30
|
+
export interface AuthSurface {
|
|
31
|
+
/** Mounted at `/api/auth`: sign-in, callback, logout, whatever auth needs. */
|
|
32
|
+
routes: Hono<Env>;
|
|
33
|
+
/** Applied before every `/api/admin` route. Must set `access` or refuse the request. */
|
|
34
|
+
gate: MiddlewareHandler<Env>[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Allows every request with no identity. Safe only because the local editor is
|
|
39
|
+
* loopback-bound and Host-guarded.
|
|
40
|
+
*/
|
|
41
|
+
export function allowAll(): MiddlewareHandler<Env> {
|
|
42
|
+
return async (c, next) => {
|
|
43
|
+
c.set("access", { identity: null });
|
|
44
|
+
await next();
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `/api/auth/config` with nothing behind it: the SPA's cue to skip sign-in. */
|
|
49
|
+
function unauthenticatedSurface(): AuthSurface {
|
|
50
|
+
const routes = new Hono<Env>();
|
|
51
|
+
routes.get("/config", (c) => c.json({ authRequired: false, provider: null }));
|
|
52
|
+
return { routes, gate: [allowAll()] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CreateAppOptions {
|
|
56
|
+
/** Absent means the unauthenticated local surface. */
|
|
57
|
+
auth?: AuthSurface;
|
|
58
|
+
/**
|
|
59
|
+
* Middleware in front of everything, so the local editor's Host allowlist
|
|
60
|
+
* also guards the health endpoints.
|
|
61
|
+
*/
|
|
62
|
+
guards?: MiddlewareHandler<Env>[];
|
|
63
|
+
adminDir?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createApp(ctx: RuntimeContext, options: CreateAppOptions = {}): Hono<Env> {
|
|
67
|
+
const app = new Hono<Env>();
|
|
68
|
+
const auth = options.auth ?? unauthenticatedSurface();
|
|
69
|
+
|
|
70
|
+
app.use("*", requestContext(ctx));
|
|
71
|
+
for (const guard of options.guards ?? []) app.use("*", guard);
|
|
72
|
+
app.use("*", securityHeaders());
|
|
73
|
+
app.onError(errorHandler());
|
|
74
|
+
|
|
75
|
+
app.route("/", healthRoutes());
|
|
76
|
+
|
|
77
|
+
// Only for the Vite dev server on another origin; in production the SPA is same-origin.
|
|
78
|
+
if (ctx.config.adminOrigins.length > 0) {
|
|
79
|
+
app.use(
|
|
80
|
+
"/api/*",
|
|
81
|
+
cors({
|
|
82
|
+
origin: allowedOrigins(ctx.config),
|
|
83
|
+
credentials: true,
|
|
84
|
+
allowHeaders: ["content-type", "x-csrf-token"],
|
|
85
|
+
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
app.use("/api/*", rateLimit());
|
|
91
|
+
app.use("/api/*", originGuard());
|
|
92
|
+
app.use("/api/*", requestSizeLimit());
|
|
93
|
+
|
|
94
|
+
app.route("/api/auth", auth.routes);
|
|
95
|
+
|
|
96
|
+
// One gate and no roles: whoever the surface admits is an editor of this site.
|
|
97
|
+
for (const middleware of auth.gate) app.use("/api/admin/*", middleware);
|
|
98
|
+
|
|
99
|
+
app.route("/api/admin", adminSystemRoutes());
|
|
100
|
+
app.route("/api/admin", adminContentRoutes());
|
|
101
|
+
app.route("/api/admin", adminStagingRoutes());
|
|
102
|
+
app.route("/api/admin/media", adminMediaRoutes());
|
|
103
|
+
|
|
104
|
+
app.all("/api/*", (c) =>
|
|
105
|
+
c.json({ error: { code: "not_found" as const, message: "No such endpoint." } }, 404),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const adminDir = options.adminDir ?? ctx.config.MICA_ADMIN_DIR;
|
|
109
|
+
|
|
110
|
+
if (adminDir && adminAssetsExist(adminDir)) {
|
|
111
|
+
app.route("/admin", adminSpaRoutes(adminDir));
|
|
112
|
+
} else {
|
|
113
|
+
app.get("/admin/*", (c) =>
|
|
114
|
+
c.text(
|
|
115
|
+
"The admin interface has not been built.\n" +
|
|
116
|
+
"Run `rush build`, or use `rush dev` and open the Vite dev server.",
|
|
117
|
+
503,
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
app.get("/", (c) => c.redirect("/admin/"));
|
|
123
|
+
|
|
124
|
+
app.notFound((c) => c.text("Not found", 404));
|
|
125
|
+
|
|
126
|
+
return app;
|
|
127
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment variables the runtime reads. There is no `loadConfig`: each
|
|
3
|
+
* app builds its own schema from `runtimeEnv`, because the variables and
|
|
4
|
+
* defaults are what make one app different from another.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
import { DEFAULT_BASE_URL, DEFAULT_PORT } from "./lib/ports.js";
|
|
11
|
+
|
|
12
|
+
/** In Zod 4, `.default` takes the output value and skips the transform. */
|
|
13
|
+
export const bool = (fallback: boolean) =>
|
|
14
|
+
z
|
|
15
|
+
.enum(["true", "false", "1", "0"])
|
|
16
|
+
.transform((value) => value === "true" || value === "1")
|
|
17
|
+
.default(fallback);
|
|
18
|
+
|
|
19
|
+
/** Splits a comma-separated list, trims entries and drops empty ones. */
|
|
20
|
+
export const list = (value: string | undefined): string[] =>
|
|
21
|
+
(value ?? "")
|
|
22
|
+
.split(",")
|
|
23
|
+
.map((entry) => entry.trim())
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
|
|
26
|
+
export class ConfigError extends Error {
|
|
27
|
+
override name = "ConfigError";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Hostnames that are this machine, and nowhere else. */
|
|
31
|
+
export const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1", "0:0:0:0:0:0:0:1"]);
|
|
32
|
+
|
|
33
|
+
/** The variables the runtime itself reads. Apps spread this into their schema. */
|
|
34
|
+
export const runtimeEnv = {
|
|
35
|
+
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
|
36
|
+
MICA_LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "silent"]).default("info"),
|
|
37
|
+
|
|
38
|
+
/** The listen address. The app sets the default. */
|
|
39
|
+
HOST: z.string().optional(),
|
|
40
|
+
PORT: z.coerce.number().int().min(1).max(65535).default(DEFAULT_PORT),
|
|
41
|
+
|
|
42
|
+
/** Public origin of the CMS itself. Used for cookies, CORS and media URLs. */
|
|
43
|
+
MICA_PUBLIC_URL: z.string().url().default(DEFAULT_BASE_URL),
|
|
44
|
+
|
|
45
|
+
/** The content root this Mica serves: a directory in a Git working tree. Each app sets it. */
|
|
46
|
+
MICA_CONTENT_ROOT: z.string(),
|
|
47
|
+
|
|
48
|
+
/** `false` disables commit and push: Publish shows the pending changes read-only. */
|
|
49
|
+
MICA_LOCAL_PUBLISH: bool(true),
|
|
50
|
+
|
|
51
|
+
MICA_MAX_UPLOAD_BYTES: z.coerce
|
|
52
|
+
.number()
|
|
53
|
+
.int()
|
|
54
|
+
.positive()
|
|
55
|
+
.default(25 * 1024 * 1024),
|
|
56
|
+
|
|
57
|
+
/** Read the client IP from X-Forwarded-For. Only enable behind a proxy. */
|
|
58
|
+
MICA_TRUST_PROXY: bool(false),
|
|
59
|
+
/** Extra browser origins allowed to call the admin API (dev Vite server). */
|
|
60
|
+
MICA_ADMIN_ORIGINS: z.string().optional(),
|
|
61
|
+
/** Overrides where the built admin SPA is served from. */
|
|
62
|
+
MICA_ADMIN_DIR: z.string().optional(),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const runtimeSchema = z.object(runtimeEnv);
|
|
66
|
+
export type RuntimeEnv = z.infer<typeof runtimeSchema>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Adds the derived values to the parsed environment. Each app calls it from its
|
|
70
|
+
* own `transform`.
|
|
71
|
+
*/
|
|
72
|
+
export function deriveRuntime<T extends RuntimeEnv>(env: T) {
|
|
73
|
+
return {
|
|
74
|
+
...env,
|
|
75
|
+
isProduction: env.NODE_ENV === "production",
|
|
76
|
+
contentRoot: path.resolve(env.MICA_CONTENT_ROOT),
|
|
77
|
+
adminOrigins: list(env.MICA_ADMIN_ORIGINS),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What the runtime requires of a finished configuration. */
|
|
82
|
+
export type RuntimeConfig = ReturnType<typeof deriveRuntime<RuntimeEnv>> & {
|
|
83
|
+
/** Resolved by the app: the runtime never guesses an address. */
|
|
84
|
+
HOST: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Loads `./.env` into `process.env`. Variables that are already set win. */
|
|
88
|
+
export function loadEnvFile(): void {
|
|
89
|
+
try {
|
|
90
|
+
process.loadEnvFile();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// A missing `.env` is normal; an unreadable or malformed one must not start
|
|
93
|
+
// the server with defaults.
|
|
94
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
95
|
+
throw new ConfigError(`Could not read .env: ${(error as Error).message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Treats a blank variable as unset, so a verbatim copy of `.env.example` with blanks is valid. */
|
|
101
|
+
export function withoutBlanks(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
102
|
+
return Object.fromEntries(
|
|
103
|
+
Object.entries(env).filter(([, value]) => value !== undefined && value.trim() !== ""),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Formats a Zod refusal the way a person at a terminal reads it. */
|
|
108
|
+
export function describeConfigIssues(error: z.ZodError): string {
|
|
109
|
+
const details = error.issues
|
|
110
|
+
.map((issue) => ` ${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
111
|
+
.join("\n");
|
|
112
|
+
return `Invalid environment configuration:\n${details}`;
|
|
113
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the runtime's services for one site, with no module-level singletons.
|
|
3
|
+
* The app supplies the working tree's files and the owner policy, so the
|
|
4
|
+
* runtime stays authentication-neutral.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Site } from "@micacms/core";
|
|
8
|
+
|
|
9
|
+
import type { RuntimeConfig } from "./config.js";
|
|
10
|
+
import { createLogger, type Logger } from "./lib/logger.js";
|
|
11
|
+
import { createRateLimiter, type RateLimiter } from "./lib/rate-limit.js";
|
|
12
|
+
import { readOnlyParts } from "./schema.js";
|
|
13
|
+
import { createMediaService, type MediaService } from "./services/media.js";
|
|
14
|
+
import type { ContentStagingService } from "./services/changes.js";
|
|
15
|
+
import { createContentStore, type ContentStore } from "./services/content-store.js";
|
|
16
|
+
import { createWorkingTreeStagingService } from "./services/staging.js";
|
|
17
|
+
import { createWorkspaceLocks, lockedFiles, type WorkspaceLocks } from "./workspace/locks.js";
|
|
18
|
+
import type { OwnerPolicy } from "./workspace/policy.js";
|
|
19
|
+
import type { ContentFiles } from "./workspace/files.js";
|
|
20
|
+
import type { GitTree } from "./workspace/tree.js";
|
|
21
|
+
|
|
22
|
+
/** The one site this process serves. Several sites means several processes. */
|
|
23
|
+
export interface SiteContext {
|
|
24
|
+
/** Identifies this site in logs. */
|
|
25
|
+
id: string;
|
|
26
|
+
/** The content model, read from the repository at startup. */
|
|
27
|
+
model: Site;
|
|
28
|
+
/** The working tree's files behind the workspace locks. Only Publish makes a commit. */
|
|
29
|
+
contentRepository: ContentFiles;
|
|
30
|
+
/**
|
|
31
|
+
* Revision of `.mica/schema.json` when the model was loaded. The publish
|
|
32
|
+
* drift gate compares against it.
|
|
33
|
+
*/
|
|
34
|
+
schemaRevision: string | null;
|
|
35
|
+
/** Collections and singletons this build cannot write, from `readOnlyParts`, with the reason. */
|
|
36
|
+
readOnly: ReadonlyMap<string, string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_SITE_ID = "default";
|
|
40
|
+
|
|
41
|
+
/** Facts about the composition that the System page shows and the runtime cannot know. */
|
|
42
|
+
export interface OperationalInfo {
|
|
43
|
+
/** How this deployment authenticates, in words. */
|
|
44
|
+
auth: string;
|
|
45
|
+
/** What decides who may edit, in the operator's own words. */
|
|
46
|
+
admission?: string;
|
|
47
|
+
dataDir?: string;
|
|
48
|
+
sessionTtlHours?: number;
|
|
49
|
+
sessionIdleHours?: number;
|
|
50
|
+
/** Names of the secrets this process generated for itself. */
|
|
51
|
+
generatedSecrets?: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What /ready asks the composition. */
|
|
55
|
+
export interface RuntimeHealth {
|
|
56
|
+
/**
|
|
57
|
+
* Whether the site can be edited, with short reason codes. Never names the
|
|
58
|
+
* repository: the endpoint is public.
|
|
59
|
+
*/
|
|
60
|
+
ready?: () => { ok: boolean; reasons: string[] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RuntimeContext {
|
|
64
|
+
config: RuntimeConfig;
|
|
65
|
+
site: SiteContext;
|
|
66
|
+
logger: Logger;
|
|
67
|
+
services: {
|
|
68
|
+
content: ContentStore;
|
|
69
|
+
media: MediaService;
|
|
70
|
+
staging: ContentStagingService;
|
|
71
|
+
};
|
|
72
|
+
limiters: {
|
|
73
|
+
api: RateLimiter;
|
|
74
|
+
};
|
|
75
|
+
operational(): OperationalInfo;
|
|
76
|
+
health: RuntimeHealth;
|
|
77
|
+
shutdown(): Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface CreateRuntimeContextOptions {
|
|
81
|
+
config: RuntimeConfig;
|
|
82
|
+
model: Site;
|
|
83
|
+
/** The working tree's files, without locks. The app builds it. */
|
|
84
|
+
files: ContentFiles;
|
|
85
|
+
/** Revision of the manifest the model was read from. Null when unknown. */
|
|
86
|
+
schemaRevision?: string | null;
|
|
87
|
+
siteId?: string;
|
|
88
|
+
logger?: Logger;
|
|
89
|
+
operational?: () => OperationalInfo;
|
|
90
|
+
health?: RuntimeHealth;
|
|
91
|
+
/** Who owns the working tree. Defaults to the developer. */
|
|
92
|
+
policy?: OwnerPolicy;
|
|
93
|
+
/** Shared with the owner's own work on the tree, such as a sync. */
|
|
94
|
+
locks?: WorkspaceLocks;
|
|
95
|
+
/** See `WorkingTreeStagingDeps.tree`. */
|
|
96
|
+
tree?: GitTree;
|
|
97
|
+
onShutdown?: () => void | Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createRuntimeContext(options: CreateRuntimeContextOptions): RuntimeContext {
|
|
101
|
+
const { config, model } = options;
|
|
102
|
+
|
|
103
|
+
const logger =
|
|
104
|
+
options.logger ??
|
|
105
|
+
createLogger({
|
|
106
|
+
level: config.MICA_LOG_LEVEL,
|
|
107
|
+
pretty: !config.isProduction,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const schemaRevision = options.schemaRevision ?? null;
|
|
111
|
+
|
|
112
|
+
// Services write through the lock wrapper; staging takes the locks itself.
|
|
113
|
+
const locks = options.locks ?? createWorkspaceLocks();
|
|
114
|
+
|
|
115
|
+
const readOnly = readOnlyParts(model);
|
|
116
|
+
for (const reason of readOnly.values()) logger.warn("Read-only in this version of Mica", { reason });
|
|
117
|
+
|
|
118
|
+
const site: SiteContext = {
|
|
119
|
+
id: options.siteId ?? DEFAULT_SITE_ID,
|
|
120
|
+
model,
|
|
121
|
+
contentRepository: lockedFiles(options.files, locks.tree),
|
|
122
|
+
schemaRevision,
|
|
123
|
+
readOnly,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const services = {
|
|
127
|
+
content: createContentStore(model, site.contentRepository, readOnly),
|
|
128
|
+
media: createMediaService({
|
|
129
|
+
files: site.contentRepository,
|
|
130
|
+
maxBytes: config.MICA_MAX_UPLOAD_BYTES,
|
|
131
|
+
}),
|
|
132
|
+
staging: createWorkingTreeStagingService({
|
|
133
|
+
model,
|
|
134
|
+
files: options.files,
|
|
135
|
+
contentRoot: config.contentRoot,
|
|
136
|
+
schemaRevision,
|
|
137
|
+
readOnly,
|
|
138
|
+
publishEnabled: config.MICA_LOCAL_PUBLISH,
|
|
139
|
+
logger,
|
|
140
|
+
locks,
|
|
141
|
+
...(options.policy ? { policy: options.policy } : {}),
|
|
142
|
+
...(options.tree ? { tree: options.tree } : {}),
|
|
143
|
+
}),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const limiters = {
|
|
147
|
+
// Generous: it stops runaway scripts, it does not shape traffic.
|
|
148
|
+
api: createRateLimiter({ limit: 600, windowMs: 60_000 }),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
config,
|
|
153
|
+
site,
|
|
154
|
+
logger,
|
|
155
|
+
services,
|
|
156
|
+
limiters,
|
|
157
|
+
operational:
|
|
158
|
+
options.operational ??
|
|
159
|
+
(() => ({ auth: "unspecified" })),
|
|
160
|
+
health: options.health ?? {},
|
|
161
|
+
|
|
162
|
+
async shutdown() {
|
|
163
|
+
limiters.api.stop();
|
|
164
|
+
await options.onShutdown?.();
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
|
|
3
|
+
import type { AccessContext } from "@micacms/core";
|
|
4
|
+
|
|
5
|
+
import type { RuntimeContext } from "./context.js";
|
|
6
|
+
import type { Logger } from "./lib/logger.js";
|
|
7
|
+
|
|
8
|
+
export interface Env {
|
|
9
|
+
Variables: {
|
|
10
|
+
ctx: RuntimeContext;
|
|
11
|
+
logger: Logger;
|
|
12
|
+
requestId: string;
|
|
13
|
+
/**
|
|
14
|
+
* Who this request acts as. The auth surface's gate sets it on every admin
|
|
15
|
+
* route; the runtime never sets it.
|
|
16
|
+
*/
|
|
17
|
+
access: AccessContext;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Client IP. Reads `X-Forwarded-For` only with MICA_TRUST_PROXY, or a client could
|
|
23
|
+
* spoof its address past the rate limit. The right-most entry is the address
|
|
24
|
+
* the proxy saw; the entries to its left come from the client.
|
|
25
|
+
*/
|
|
26
|
+
export function clientIp(c: Context<Env>): string {
|
|
27
|
+
const { config } = c.get("ctx");
|
|
28
|
+
|
|
29
|
+
if (config.MICA_TRUST_PROXY) {
|
|
30
|
+
const forwarded = c.req.header("x-forwarded-for");
|
|
31
|
+
const last = forwarded?.split(",").at(-1)?.trim();
|
|
32
|
+
if (last) return last;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const info = c.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined;
|
|
36
|
+
return info?.incoming?.socket?.remoteAddress ?? "unknown";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The commit author. Null in the local editor, where the commit keeps the
|
|
41
|
+
* developer's own Git identity.
|
|
42
|
+
*/
|
|
43
|
+
export function actorOf(c: Context<Env>): { id: string; email: string; name: string } | null {
|
|
44
|
+
const identity = c.get("access")?.identity;
|
|
45
|
+
return identity ? { id: identity.subject, email: identity.email, name: identity.name } : null;
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @micacms/runtime: the single-site, authentication-neutral Mica runtime.
|
|
3
|
+
* It must never import an auth implementation; the composing app supplies the `AuthSurface`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { createApp, allowAll, type AuthSurface, type CreateAppOptions } from "./app.js";
|
|
7
|
+
export {
|
|
8
|
+
createRuntimeContext,
|
|
9
|
+
DEFAULT_SITE_ID,
|
|
10
|
+
type CreateRuntimeContextOptions,
|
|
11
|
+
type OperationalInfo,
|
|
12
|
+
type RuntimeContext,
|
|
13
|
+
type RuntimeHealth,
|
|
14
|
+
type SiteContext,
|
|
15
|
+
} from "./context.js";
|
|
16
|
+
export {
|
|
17
|
+
bool,
|
|
18
|
+
ConfigError,
|
|
19
|
+
deriveRuntime,
|
|
20
|
+
describeConfigIssues,
|
|
21
|
+
list,
|
|
22
|
+
loadEnvFile,
|
|
23
|
+
LOOPBACK,
|
|
24
|
+
runtimeEnv,
|
|
25
|
+
withoutBlanks,
|
|
26
|
+
type RuntimeConfig,
|
|
27
|
+
type RuntimeEnv,
|
|
28
|
+
} from "./config.js";
|
|
29
|
+
export { startServer, type StartServerOptions } from "./server.js";
|
|
30
|
+
export { actorOf, clientIp, type Env } from "./http.js";
|
|
31
|
+
export {
|
|
32
|
+
checkSchema,
|
|
33
|
+
layoutProblems,
|
|
34
|
+
loadSchema,
|
|
35
|
+
readOnlyParts,
|
|
36
|
+
SchemaError,
|
|
37
|
+
schemaFingerprint,
|
|
38
|
+
schemaOrigin,
|
|
39
|
+
} from "./schema.js";
|
|
40
|
+
export {
|
|
41
|
+
allowedOrigins,
|
|
42
|
+
errorHandler,
|
|
43
|
+
originGuard,
|
|
44
|
+
rateLimit,
|
|
45
|
+
requestContext,
|
|
46
|
+
securityHeaders,
|
|
47
|
+
} from "./middleware/index.js";
|
|
48
|
+
export { adminAssetsExist } from "./routes/admin-spa.js";
|
|
49
|
+
export {
|
|
50
|
+
ApiError,
|
|
51
|
+
badRequest,
|
|
52
|
+
conflict,
|
|
53
|
+
forbidden,
|
|
54
|
+
notFound,
|
|
55
|
+
unauthenticated,
|
|
56
|
+
type ApiErrorBody,
|
|
57
|
+
type ErrorCode,
|
|
58
|
+
} from "./lib/errors.js";
|
|
59
|
+
export { createLogger, type Logger } from "./lib/logger.js";
|
|
60
|
+
export { createRateLimiter, type RateLimiter } from "./lib/rate-limit.js";
|
|
61
|
+
export { randomToken } from "./lib/id.js";
|
|
62
|
+
export { DEFAULT_BASE_URL, DEFAULT_PORT } from "./lib/ports.js";
|
|
63
|
+
export { VERSION } from "./lib/version.js";
|
|
64
|
+
export {
|
|
65
|
+
analyseChanges,
|
|
66
|
+
classifyPath,
|
|
67
|
+
describeProblems,
|
|
68
|
+
flattenProblems,
|
|
69
|
+
type AnnotatedChange,
|
|
70
|
+
type ChangeOwner,
|
|
71
|
+
type ContentStagingService,
|
|
72
|
+
type RepositoryInfo,
|
|
73
|
+
type PublishInput,
|
|
74
|
+
type StagingProblem,
|
|
75
|
+
type StagingStatus,
|
|
76
|
+
type StagingValidation,
|
|
77
|
+
} from "./services/changes.js";
|
|
78
|
+
export {
|
|
79
|
+
createWorkingTreeStagingService,
|
|
80
|
+
type WorkingTreeStagingDeps,
|
|
81
|
+
} from "./services/staging.js";
|
|
82
|
+
export { createMediaService, formatBytes, type MediaFile, type MediaService } from "./services/media.js";
|
|
83
|
+
export {
|
|
84
|
+
createContentStore,
|
|
85
|
+
ContentError,
|
|
86
|
+
ValidationError,
|
|
87
|
+
type ContentStore,
|
|
88
|
+
type SavedEntry,
|
|
89
|
+
type SaveEntryInput,
|
|
90
|
+
} from "./services/content-store.js";
|
|
91
|
+
export {
|
|
92
|
+
checkChangePaths,
|
|
93
|
+
ConflictError,
|
|
94
|
+
contentFiles,
|
|
95
|
+
FilesError,
|
|
96
|
+
type Author,
|
|
97
|
+
type ContentFiles,
|
|
98
|
+
type ContentFilesOptions,
|
|
99
|
+
type FileChange,
|
|
100
|
+
type FileChanges,
|
|
101
|
+
} from "./workspace/files.js";
|
|
102
|
+
export {
|
|
103
|
+
developerEnvironment,
|
|
104
|
+
firstLine,
|
|
105
|
+
GitError,
|
|
106
|
+
gitRunner,
|
|
107
|
+
type GitCallOptions,
|
|
108
|
+
type GitRunner,
|
|
109
|
+
type GitRunnerOptions,
|
|
110
|
+
} from "./workspace/git.js";
|
|
111
|
+
export {
|
|
112
|
+
createMutex,
|
|
113
|
+
createReadWriteLock,
|
|
114
|
+
createWorkspaceLocks,
|
|
115
|
+
lockedFiles,
|
|
116
|
+
type Mutex,
|
|
117
|
+
type ReadWriteLock,
|
|
118
|
+
type WorkspaceLocks,
|
|
119
|
+
} from "./workspace/locks.js";
|
|
120
|
+
export type {
|
|
121
|
+
OwnerPolicy,
|
|
122
|
+
PublishAvailability,
|
|
123
|
+
RepositoryDescription,
|
|
124
|
+
SyncConflict,
|
|
125
|
+
SyncStatus,
|
|
126
|
+
} from "./workspace/policy.js";
|
|
127
|
+
export { developerPolicy, type DeveloperPolicyOptions } from "./workspace/developer.js";
|
|
128
|
+
export {
|
|
129
|
+
commitPaths,
|
|
130
|
+
headRevision,
|
|
131
|
+
headSource,
|
|
132
|
+
inRepo,
|
|
133
|
+
listTreeChanges,
|
|
134
|
+
LITERAL,
|
|
135
|
+
locateTree,
|
|
136
|
+
parseStatus,
|
|
137
|
+
recommitPaths,
|
|
138
|
+
type GitTree,
|
|
139
|
+
type TreeChange,
|
|
140
|
+
} from "./workspace/tree.js";
|
|
141
|
+
export { schemaStats } from "./lib/schema-stats.js";
|