@micacms/runtime 0.5.0 → 0.6.0-rc.2

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 (94) hide show
  1. package/README.md +3 -1
  2. package/dist/app.js +1 -1
  3. package/dist/app.js.map +1 -1
  4. package/dist/config.d.ts +13 -13
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +9 -9
  7. package/dist/config.js.map +1 -1
  8. package/dist/context.js +2 -2
  9. package/dist/context.js.map +1 -1
  10. package/dist/http.d.ts +1 -3
  11. package/dist/http.d.ts.map +1 -1
  12. package/dist/http.js +2 -9
  13. package/dist/http.js.map +1 -1
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/lib/errors.d.ts +1 -2
  19. package/dist/lib/errors.d.ts.map +1 -1
  20. package/dist/lib/errors.js +0 -2
  21. package/dist/lib/errors.js.map +1 -1
  22. package/dist/lib/file-type.d.ts.map +1 -1
  23. package/dist/lib/file-type.js +2 -8
  24. package/dist/lib/file-type.js.map +1 -1
  25. package/dist/lib/id.d.ts +0 -10
  26. package/dist/lib/id.d.ts.map +1 -1
  27. package/dist/lib/id.js +0 -50
  28. package/dist/lib/id.js.map +1 -1
  29. package/dist/lib/rate-limit.d.ts +0 -2
  30. package/dist/lib/rate-limit.d.ts.map +1 -1
  31. package/dist/lib/rate-limit.js +0 -3
  32. package/dist/lib/rate-limit.js.map +1 -1
  33. package/dist/middleware/index.d.ts +1 -1
  34. package/dist/middleware/index.js +2 -2
  35. package/dist/middleware/index.js.map +1 -1
  36. package/dist/routes/admin-content.d.ts.map +1 -1
  37. package/dist/routes/admin-content.js +3 -2
  38. package/dist/routes/admin-content.js.map +1 -1
  39. package/dist/routes/admin-system.js +4 -4
  40. package/dist/routes/admin-system.js.map +1 -1
  41. package/dist/schema.d.ts +6 -0
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +19 -1
  44. package/dist/schema.js.map +1 -1
  45. package/dist/services/changes.d.ts.map +1 -1
  46. package/dist/services/changes.js +89 -13
  47. package/dist/services/changes.js.map +1 -1
  48. package/dist/services/content-store.d.ts.map +1 -1
  49. package/dist/services/content-store.js +9 -10
  50. package/dist/services/content-store.js.map +1 -1
  51. package/dist/services/staging.d.ts.map +1 -1
  52. package/dist/services/staging.js +7 -4
  53. package/dist/services/staging.js.map +1 -1
  54. package/dist/workspace/developer.d.ts +2 -1
  55. package/dist/workspace/developer.d.ts.map +1 -1
  56. package/dist/workspace/developer.js +17 -3
  57. package/dist/workspace/developer.js.map +1 -1
  58. package/dist/workspace/tree.d.ts +6 -0
  59. package/dist/workspace/tree.d.ts.map +1 -1
  60. package/dist/workspace/tree.js +28 -0
  61. package/dist/workspace/tree.js.map +1 -1
  62. package/package.json +12 -4
  63. package/src/app.ts +127 -0
  64. package/src/config.ts +113 -0
  65. package/src/context.ts +167 -0
  66. package/src/http.ts +46 -0
  67. package/src/index.ts +141 -0
  68. package/src/lib/errors.ts +84 -0
  69. package/src/lib/file-type.ts +102 -0
  70. package/src/lib/id.ts +6 -0
  71. package/src/lib/logger.ts +87 -0
  72. package/src/lib/ports.ts +17 -0
  73. package/src/lib/rate-limit.ts +72 -0
  74. package/src/lib/schema-stats.ts +183 -0
  75. package/src/lib/version.ts +29 -0
  76. package/src/middleware/index.ts +234 -0
  77. package/src/routes/admin-content.ts +197 -0
  78. package/src/routes/admin-media.ts +96 -0
  79. package/src/routes/admin-spa.ts +82 -0
  80. package/src/routes/admin-staging.ts +126 -0
  81. package/src/routes/admin-system.ts +146 -0
  82. package/src/routes/health.ts +67 -0
  83. package/src/schema.ts +217 -0
  84. package/src/server.ts +68 -0
  85. package/src/services/changes.ts +605 -0
  86. package/src/services/content-store.ts +492 -0
  87. package/src/services/media.ts +229 -0
  88. package/src/services/staging.ts +330 -0
  89. package/src/workspace/developer.ts +58 -0
  90. package/src/workspace/files.ts +243 -0
  91. package/src/workspace/git.ts +108 -0
  92. package/src/workspace/locks.ts +103 -0
  93. package/src/workspace/policy.ts +77 -0
  94. package/src/workspace/tree.ts +314 -0
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";
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Error taxonomy.
3
+ *
4
+ * Every failure the API reports on purpose is an `ApiError` carrying a stable
5
+ * machine-readable `code`. Anything else that reaches the error middleware is
6
+ * treated as a bug: logged with its stack, reported as a bare 500.
7
+ */
8
+
9
+ import type { FieldIssue } from "@micacms/core";
10
+
11
+ export type ErrorCode =
12
+ | "bad_request"
13
+ | "validation_failed"
14
+ | "unauthenticated"
15
+ | "forbidden"
16
+ | "not_found"
17
+ | "conflict"
18
+ | "payload_too_large"
19
+ | "unsupported_media_type"
20
+ | "rate_limited"
21
+ | "internal_error"
22
+ /** An identity provider Mica depends on could not be reached. */
23
+ | "service_unavailable";
24
+
25
+ const STATUS: Record<ErrorCode, number> = {
26
+ bad_request: 400,
27
+ validation_failed: 422,
28
+ unauthenticated: 401,
29
+ forbidden: 403,
30
+ not_found: 404,
31
+ conflict: 409,
32
+ payload_too_large: 413,
33
+ unsupported_media_type: 415,
34
+ rate_limited: 429,
35
+ internal_error: 500,
36
+ service_unavailable: 503,
37
+ };
38
+
39
+ export interface ApiErrorBody {
40
+ error: {
41
+ code: ErrorCode;
42
+ message: string;
43
+ issues?: FieldIssue[];
44
+ };
45
+ }
46
+
47
+ export class ApiError extends Error {
48
+ override name = "ApiError";
49
+ readonly code: ErrorCode;
50
+ readonly status: number;
51
+ readonly issues?: FieldIssue[];
52
+ /** Extra response headers, e.g. `Retry-After` on a rate limit. */
53
+ readonly headers?: Record<string, string>;
54
+
55
+ constructor(
56
+ code: ErrorCode,
57
+ message: string,
58
+ options: { issues?: FieldIssue[]; headers?: Record<string, string>; cause?: unknown } = {},
59
+ ) {
60
+ super(message, { cause: options.cause });
61
+ this.code = code;
62
+ this.status = STATUS[code];
63
+ this.issues = options.issues;
64
+ this.headers = options.headers;
65
+ }
66
+
67
+ toBody(): ApiErrorBody {
68
+ return {
69
+ error: {
70
+ code: this.code,
71
+ message: this.message,
72
+ ...(this.issues ? { issues: this.issues } : {}),
73
+ },
74
+ };
75
+ }
76
+ }
77
+
78
+ export const badRequest = (message: string) => new ApiError("bad_request", message);
79
+ export const unauthenticated = (message = "Authentication required.") =>
80
+ new ApiError("unauthenticated", message);
81
+ export const forbidden = (message = "You do not have access to this resource.") =>
82
+ new ApiError("forbidden", message);
83
+ export const notFound = (message = "Not found.") => new ApiError("not_found", message);
84
+ export const conflict = (message: string) => new ApiError("conflict", message);
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Content sniffing for uploads.
3
+ *
4
+ * The browser-supplied MIME type is attacker-controlled and must not be
5
+ * trusted: an executable renamed to `.jpg` arrives as `image/jpeg`. The stored
6
+ * type is derived from the bytes instead, and the extension is regenerated
7
+ * from it rather than taken from the filename.
8
+ */
9
+
10
+ export interface DetectedType {
11
+ mimeType: string;
12
+ extension: string;
13
+ }
14
+
15
+ const startsWith = (bytes: Uint8Array, signature: number[], offset = 0): boolean =>
16
+ signature.every((byte, index) => bytes[offset + index] === byte);
17
+
18
+ const ascii = (bytes: Uint8Array, offset: number, length: number): string =>
19
+ Array.from(bytes.slice(offset, offset + length), (byte) => String.fromCharCode(byte)).join("");
20
+
21
+ export function detectFileType(bytes: Uint8Array): DetectedType | null {
22
+ if (bytes.length < 12) return null;
23
+
24
+ if (startsWith(bytes, [0xff, 0xd8, 0xff])) {
25
+ return { mimeType: "image/jpeg", extension: "jpg" };
26
+ }
27
+
28
+ if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
29
+ return { mimeType: "image/png", extension: "png" };
30
+ }
31
+
32
+ const header = ascii(bytes, 0, 6);
33
+ if (header === "GIF87a" || header === "GIF89a") {
34
+ return { mimeType: "image/gif", extension: "gif" };
35
+ }
36
+
37
+ if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP") {
38
+ return { mimeType: "image/webp", extension: "webp" };
39
+ }
40
+
41
+ // ISO base media formats declare their brand at offset 8, after the box size.
42
+ if (ascii(bytes, 4, 4) === "ftyp") {
43
+ const brand = ascii(bytes, 8, 4);
44
+ if (brand === "avif" || brand === "avis") {
45
+ return { mimeType: "image/avif", extension: "avif" };
46
+ }
47
+ if (brand === "heic" || brand === "heix" || brand === "mif1") {
48
+ return { mimeType: "image/heic", extension: "heic" };
49
+ }
50
+ if (brand === "isom" || brand === "mp42" || brand === "M4V ") {
51
+ return { mimeType: "video/mp4", extension: "mp4" };
52
+ }
53
+ }
54
+
55
+ if (ascii(bytes, 0, 5) === "%PDF-") {
56
+ return { mimeType: "application/pdf", extension: "pdf" };
57
+ }
58
+
59
+ if (isSvg(bytes)) {
60
+ return { mimeType: "image/svg+xml", extension: "svg" };
61
+ }
62
+
63
+ return null;
64
+ }
65
+
66
+ /** SVG has no magic number, so look for a root `<svg>` in the leading text. */
67
+ function isSvg(bytes: Uint8Array): boolean {
68
+ const head = new TextDecoder("utf-8", { fatal: false })
69
+ .decode(bytes.slice(0, 1024))
70
+ .trimStart()
71
+ .toLowerCase();
72
+
73
+ if (head.startsWith("<svg")) return true;
74
+ if (head.startsWith("<?xml") || head.startsWith("<!doctype svg")) {
75
+ return head.includes("<svg");
76
+ }
77
+ return false;
78
+ }
79
+
80
+ // No HEIC: Astro's image service cannot read it, so a site could not show it.
81
+ const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp", "image/avif"]);
82
+
83
+ const DOCUMENT_TYPES = new Set(["application/pdf"]);
84
+
85
+ export const isImageType = (mimeType: string): boolean => IMAGE_TYPES.has(mimeType);
86
+
87
+ /**
88
+ * Whether a detected type is acceptable for a field's `accept` setting.
89
+ * `any` deliberately does not mean "anything": only the types listed here can
90
+ * be stored at all.
91
+ */
92
+ export function isAccepted(mimeType: string, accept: "image" | "document" | "any"): boolean {
93
+ if (accept === "image") return IMAGE_TYPES.has(mimeType);
94
+ if (accept === "document") return DOCUMENT_TYPES.has(mimeType);
95
+ return IMAGE_TYPES.has(mimeType) || DOCUMENT_TYPES.has(mimeType) || mimeType === "video/mp4";
96
+ }
97
+
98
+ /**
99
+ * SVG can carry scripts, so Mica neither stores nor shows it (#86). It is still
100
+ * detected, so that the refusal can say why.
101
+ */
102
+ export const SVG_TYPE = "image/svg+xml";
package/src/lib/id.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ /** URL-safe random token for sessions and one-time links. */
4
+ export function randomToken(bytes = 32): string {
5
+ return randomBytes(bytes).toString("base64url");
6
+ }