@micacms/runtime 0.5.0 → 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.
Files changed (87) 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/id.d.ts +0 -10
  23. package/dist/lib/id.d.ts.map +1 -1
  24. package/dist/lib/id.js +0 -50
  25. package/dist/lib/id.js.map +1 -1
  26. package/dist/lib/rate-limit.d.ts +0 -2
  27. package/dist/lib/rate-limit.d.ts.map +1 -1
  28. package/dist/lib/rate-limit.js +0 -3
  29. package/dist/lib/rate-limit.js.map +1 -1
  30. package/dist/middleware/index.d.ts +1 -1
  31. package/dist/middleware/index.js +2 -2
  32. package/dist/middleware/index.js.map +1 -1
  33. package/dist/routes/admin-content.d.ts.map +1 -1
  34. package/dist/routes/admin-content.js +3 -2
  35. package/dist/routes/admin-content.js.map +1 -1
  36. package/dist/routes/admin-system.js +4 -4
  37. package/dist/routes/admin-system.js.map +1 -1
  38. package/dist/schema.d.ts +6 -0
  39. package/dist/schema.d.ts.map +1 -1
  40. package/dist/schema.js +19 -1
  41. package/dist/schema.js.map +1 -1
  42. package/dist/services/changes.d.ts.map +1 -1
  43. package/dist/services/changes.js +89 -13
  44. package/dist/services/changes.js.map +1 -1
  45. package/dist/services/content-store.d.ts.map +1 -1
  46. package/dist/services/content-store.js +9 -10
  47. package/dist/services/content-store.js.map +1 -1
  48. package/dist/services/staging.d.ts.map +1 -1
  49. package/dist/services/staging.js +7 -4
  50. package/dist/services/staging.js.map +1 -1
  51. package/dist/workspace/tree.d.ts +6 -0
  52. package/dist/workspace/tree.d.ts.map +1 -1
  53. package/dist/workspace/tree.js +28 -0
  54. package/dist/workspace/tree.js.map +1 -1
  55. package/package.json +12 -4
  56. package/src/app.ts +127 -0
  57. package/src/config.ts +113 -0
  58. package/src/context.ts +167 -0
  59. package/src/http.ts +46 -0
  60. package/src/index.ts +141 -0
  61. package/src/lib/errors.ts +84 -0
  62. package/src/lib/file-type.ts +108 -0
  63. package/src/lib/id.ts +6 -0
  64. package/src/lib/logger.ts +87 -0
  65. package/src/lib/ports.ts +17 -0
  66. package/src/lib/rate-limit.ts +72 -0
  67. package/src/lib/schema-stats.ts +183 -0
  68. package/src/lib/version.ts +29 -0
  69. package/src/middleware/index.ts +234 -0
  70. package/src/routes/admin-content.ts +197 -0
  71. package/src/routes/admin-media.ts +96 -0
  72. package/src/routes/admin-spa.ts +82 -0
  73. package/src/routes/admin-staging.ts +126 -0
  74. package/src/routes/admin-system.ts +146 -0
  75. package/src/routes/health.ts +67 -0
  76. package/src/schema.ts +217 -0
  77. package/src/server.ts +68 -0
  78. package/src/services/changes.ts +605 -0
  79. package/src/services/content-store.ts +492 -0
  80. package/src/services/media.ts +229 -0
  81. package/src/services/staging.ts +330 -0
  82. package/src/workspace/developer.ts +46 -0
  83. package/src/workspace/files.ts +243 -0
  84. package/src/workspace/git.ts +108 -0
  85. package/src/workspace/locks.ts +103 -0
  86. package/src/workspace/policy.ts +77 -0
  87. package/src/workspace/tree.ts +314 -0
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,108 @@
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
+ const IMAGE_TYPES = new Set([
81
+ "image/jpeg",
82
+ "image/png",
83
+ "image/gif",
84
+ "image/webp",
85
+ "image/avif",
86
+ "image/heic",
87
+ ]);
88
+
89
+ const DOCUMENT_TYPES = new Set(["application/pdf"]);
90
+
91
+ export const isImageType = (mimeType: string): boolean => IMAGE_TYPES.has(mimeType);
92
+
93
+ /**
94
+ * Whether a detected type is acceptable for a field's `accept` setting.
95
+ * `any` deliberately does not mean "anything": only the types listed here can
96
+ * be stored at all.
97
+ */
98
+ export function isAccepted(mimeType: string, accept: "image" | "document" | "any"): boolean {
99
+ if (accept === "image") return IMAGE_TYPES.has(mimeType);
100
+ if (accept === "document") return DOCUMENT_TYPES.has(mimeType);
101
+ return IMAGE_TYPES.has(mimeType) || DOCUMENT_TYPES.has(mimeType) || mimeType === "video/mp4";
102
+ }
103
+
104
+ /**
105
+ * SVG can carry scripts, so Mica neither stores nor shows it (#86). It is still
106
+ * detected, so that the refusal can say why.
107
+ */
108
+ 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
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Structured logging.
3
+ *
4
+ * JSON lines in production so a log shipper can parse them; a compact
5
+ * human-readable form in development.
6
+ */
7
+
8
+ export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
9
+
10
+ const ORDER: Record<LogLevel, number> = {
11
+ debug: 10,
12
+ info: 20,
13
+ warn: 30,
14
+ error: 40,
15
+ silent: 100,
16
+ };
17
+
18
+ export interface Logger {
19
+ debug(message: string, fields?: Record<string, unknown>): void;
20
+ info(message: string, fields?: Record<string, unknown>): void;
21
+ warn(message: string, fields?: Record<string, unknown>): void;
22
+ error(message: string, fields?: Record<string, unknown>): void;
23
+ child(bindings: Record<string, unknown>): Logger;
24
+ }
25
+
26
+ const COLORS: Record<Exclude<LogLevel, "silent">, string> = {
27
+ debug: "",
28
+ info: "",
29
+ warn: "",
30
+ error: "",
31
+ };
32
+
33
+ export function createLogger(options: {
34
+ level: LogLevel;
35
+ pretty: boolean;
36
+ bindings?: Record<string, unknown>;
37
+ }): Logger {
38
+ const threshold = ORDER[options.level];
39
+ const bindings = options.bindings ?? {};
40
+
41
+ const write = (level: Exclude<LogLevel, "silent">, message: string, fields?: Record<string, unknown>) => {
42
+ if (ORDER[level] < threshold) return;
43
+
44
+ const payload = { ...bindings, ...fields };
45
+ const stream = level === "error" || level === "warn" ? process.stderr : process.stdout;
46
+
47
+ if (options.pretty) {
48
+ const extra = Object.keys(payload).length > 0 ? ` ${formatFields(payload)}` : "";
49
+ stream.write(`${COLORS[level]}${level.padEnd(5)} ${message}${extra}\n`);
50
+ return;
51
+ }
52
+
53
+ stream.write(
54
+ `${JSON.stringify({
55
+ time: new Date().toISOString(),
56
+ level,
57
+ message,
58
+ ...payload,
59
+ })}\n`,
60
+ );
61
+ };
62
+
63
+ return {
64
+ debug: (message, fields) => write("debug", message, fields),
65
+ info: (message, fields) => write("info", message, fields),
66
+ warn: (message, fields) => write("warn", message, fields),
67
+ error: (message, fields) => write("error", message, fields),
68
+ child: (extra) =>
69
+ createLogger({ ...options, bindings: { ...bindings, ...extra } }),
70
+ };
71
+ }
72
+
73
+ function formatFields(fields: Record<string, unknown>): string {
74
+ return Object.entries(fields)
75
+ .map(([key, value]) => `${key}=${format(value)}`)
76
+ .join(" ");
77
+ }
78
+
79
+ function format(value: unknown): string {
80
+ if (typeof value === "string") return value;
81
+ if (value instanceof Error) return value.stack ?? value.message;
82
+ try {
83
+ return JSON.stringify(value) ?? String(value);
84
+ } catch {
85
+ return String(value);
86
+ }
87
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The port Mica listens on when nobody says otherwise.
3
+ *
4
+ * 6422 is `MICA` on a telephone keypad, which is the only reason anybody will
5
+ * remember it. What matters more is what it is not: 3000, 4000, 4173, 4200,
6
+ * 4321, 5000, 5173, 8000, 8080 and 1313 all belong to something a developer is
7
+ * likely to be running beside Mica — Astro's dev server most of all — and IANA
8
+ * has 6420 and 6421 assigned but not 6422, so nothing on the machine is
9
+ * expected to want it.
10
+ *
11
+ * A port collision is a bad first minute: the command fails, and the reason is
12
+ * somebody else's software. Picking an unfashionable number is the whole fix.
13
+ */
14
+ export const DEFAULT_PORT = 6422;
15
+
16
+ /** The address the CLI prints and the browser opens. */
17
+ export const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`;