minutework 0.1.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 (203) hide show
  1. package/EXTERNAL_ALPHA.md +74 -0
  2. package/README.md +57 -0
  3. package/assets/claude-local/CLAUDE.md.template +45 -0
  4. package/assets/claude-local/bundle.json +22 -0
  5. package/assets/claude-local/skills/README.md +6 -0
  6. package/assets/claude-local/skills/app-pack-authoring.md +8 -0
  7. package/assets/claude-local/skills/event-bus.md +8 -0
  8. package/assets/claude-local/skills/ontology-mapping.md +8 -0
  9. package/assets/claude-local/skills/openclaw-skill-importer.md +7 -0
  10. package/assets/claude-local/skills/schema-engine.md +8 -0
  11. package/assets/claude-local/skills/secrets-runtime-bridge.md +9 -0
  12. package/assets/claude-local/skills/sidecar-generation.md +9 -0
  13. package/assets/templates/fastapi-sidecar/.env.example +8 -0
  14. package/assets/templates/fastapi-sidecar/README.md +77 -0
  15. package/assets/templates/fastapi-sidecar/poetry.lock +757 -0
  16. package/assets/templates/fastapi-sidecar/pyproject.toml +42 -0
  17. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/__init__.py +3 -0
  18. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/auth.py +70 -0
  19. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/bridge/__init__.py +3 -0
  20. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/bridge/client.py +71 -0
  21. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/logging_utils.py +25 -0
  22. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/main.py +85 -0
  23. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/receipts.py +24 -0
  24. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/settings.py +41 -0
  25. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/template_validation.py +26 -0
  26. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/worker.py +33 -0
  27. package/assets/templates/fastapi-sidecar/template.json +43 -0
  28. package/assets/templates/fastapi-sidecar/template.schema.json +160 -0
  29. package/assets/templates/fastapi-sidecar/tests/conftest.py +36 -0
  30. package/assets/templates/fastapi-sidecar/tests/test_app.py +39 -0
  31. package/assets/templates/fastapi-sidecar/tests/test_auth.py +32 -0
  32. package/assets/templates/fastapi-sidecar/tests/test_bridge_client.py +31 -0
  33. package/assets/templates/fastapi-sidecar/tests/test_materialization.py +55 -0
  34. package/assets/templates/fastapi-sidecar/tests/test_template_contract.py +49 -0
  35. package/assets/templates/fastapi-sidecar/tests/test_worker.py +7 -0
  36. package/assets/templates/fastapi-sidecar/tools/template/validate_template.py +20 -0
  37. package/assets/templates/next-tenant-app/.env.example +8 -0
  38. package/assets/templates/next-tenant-app/.storybook/main.ts +19 -0
  39. package/assets/templates/next-tenant-app/.storybook/preview.tsx +38 -0
  40. package/assets/templates/next-tenant-app/README.md +115 -0
  41. package/assets/templates/next-tenant-app/components.json +21 -0
  42. package/assets/templates/next-tenant-app/eslint.config.mjs +41 -0
  43. package/assets/templates/next-tenant-app/next-env.d.ts +6 -0
  44. package/assets/templates/next-tenant-app/next.config.ts +8 -0
  45. package/assets/templates/next-tenant-app/package-lock.json +9682 -0
  46. package/assets/templates/next-tenant-app/package.json +59 -0
  47. package/assets/templates/next-tenant-app/pnpm-lock.yaml +6062 -0
  48. package/assets/templates/next-tenant-app/postcss.config.mjs +8 -0
  49. package/assets/templates/next-tenant-app/src/app/api/auth/context/route.test.ts +90 -0
  50. package/assets/templates/next-tenant-app/src/app/api/auth/context/route.ts +78 -0
  51. package/assets/templates/next-tenant-app/src/app/api/auth/login/route.ts +31 -0
  52. package/assets/templates/next-tenant-app/src/app/api/auth/logout/route.ts +16 -0
  53. package/assets/templates/next-tenant-app/src/app/api/auth/password-change/route.test.ts +79 -0
  54. package/assets/templates/next-tenant-app/src/app/api/auth/password-change/route.ts +40 -0
  55. package/assets/templates/next-tenant-app/src/app/api/auth/password-status/route.test.ts +42 -0
  56. package/assets/templates/next-tenant-app/src/app/api/auth/password-status/route.ts +29 -0
  57. package/assets/templates/next-tenant-app/src/app/api/auth/session/route.ts +26 -0
  58. package/assets/templates/next-tenant-app/src/app/api/gateway/commands/[runId]/route.test.ts +40 -0
  59. package/assets/templates/next-tenant-app/src/app/api/gateway/commands/[runId]/route.ts +47 -0
  60. package/assets/templates/next-tenant-app/src/app/api/gateway/commands/route.test.ts +43 -0
  61. package/assets/templates/next-tenant-app/src/app/api/gateway/commands/route.ts +45 -0
  62. package/assets/templates/next-tenant-app/src/app/app/examples/runtime-commands/page.test.ts +83 -0
  63. package/assets/templates/next-tenant-app/src/app/app/examples/runtime-commands/page.tsx +30 -0
  64. package/assets/templates/next-tenant-app/src/app/app/layout.tsx +20 -0
  65. package/assets/templates/next-tenant-app/src/app/app/page.test.ts +62 -0
  66. package/assets/templates/next-tenant-app/src/app/app/page.tsx +24 -0
  67. package/assets/templates/next-tenant-app/src/app/blog/[slug]/page.test.ts +70 -0
  68. package/assets/templates/next-tenant-app/src/app/blog/[slug]/page.tsx +57 -0
  69. package/assets/templates/next-tenant-app/src/app/blog/page.test.ts +42 -0
  70. package/assets/templates/next-tenant-app/src/app/blog/page.tsx +37 -0
  71. package/assets/templates/next-tenant-app/src/app/docs/[...slug]/page.test.ts +70 -0
  72. package/assets/templates/next-tenant-app/src/app/docs/[...slug]/page.tsx +55 -0
  73. package/assets/templates/next-tenant-app/src/app/docs/page.test.ts +42 -0
  74. package/assets/templates/next-tenant-app/src/app/docs/page.tsx +37 -0
  75. package/assets/templates/next-tenant-app/src/app/globals.css +70 -0
  76. package/assets/templates/next-tenant-app/src/app/layout.tsx +69 -0
  77. package/assets/templates/next-tenant-app/src/app/login/page.test.ts +55 -0
  78. package/assets/templates/next-tenant-app/src/app/login/page.tsx +33 -0
  79. package/assets/templates/next-tenant-app/src/app/page.test.ts +56 -0
  80. package/assets/templates/next-tenant-app/src/app/page.tsx +35 -0
  81. package/assets/templates/next-tenant-app/src/app/pricing/page.test.ts +55 -0
  82. package/assets/templates/next-tenant-app/src/app/pricing/page.tsx +35 -0
  83. package/assets/templates/next-tenant-app/src/app/providers.tsx +25 -0
  84. package/assets/templates/next-tenant-app/src/app/robots.test.ts +20 -0
  85. package/assets/templates/next-tenant-app/src/app/robots.ts +18 -0
  86. package/assets/templates/next-tenant-app/src/app/sitemap.test.ts +49 -0
  87. package/assets/templates/next-tenant-app/src/app/sitemap.ts +54 -0
  88. package/assets/templates/next-tenant-app/src/components/ui/button.tsx +59 -0
  89. package/assets/templates/next-tenant-app/src/components/ui/input.tsx +21 -0
  90. package/assets/templates/next-tenant-app/src/design-system/docs/governance.mdx +26 -0
  91. package/assets/templates/next-tenant-app/src/design-system/patterns/panel-frame.stories.tsx +48 -0
  92. package/assets/templates/next-tenant-app/src/design-system/patterns/panel-frame.tsx +26 -0
  93. package/assets/templates/next-tenant-app/src/design-system/patterns/status-badge.stories.tsx +26 -0
  94. package/assets/templates/next-tenant-app/src/design-system/patterns/status-badge.tsx +35 -0
  95. package/assets/templates/next-tenant-app/src/design-system/patterns/theme-mode-toggle.stories.tsx +21 -0
  96. package/assets/templates/next-tenant-app/src/design-system/patterns/theme-mode-toggle.tsx +75 -0
  97. package/assets/templates/next-tenant-app/src/design-system/primitives/button.stories.tsx +37 -0
  98. package/assets/templates/next-tenant-app/src/design-system/primitives/button.ts +1 -0
  99. package/assets/templates/next-tenant-app/src/design-system/primitives/input.stories.tsx +26 -0
  100. package/assets/templates/next-tenant-app/src/design-system/primitives/input.ts +1 -0
  101. package/assets/templates/next-tenant-app/src/design-system/recipes/chrome.ts +28 -0
  102. package/assets/templates/next-tenant-app/src/design-system/tokens/foundation.css +31 -0
  103. package/assets/templates/next-tenant-app/src/design-system/tokens/index.css +3 -0
  104. package/assets/templates/next-tenant-app/src/design-system/tokens/manifest.json +85 -0
  105. package/assets/templates/next-tenant-app/src/design-system/tokens/manifest.ts +87 -0
  106. package/assets/templates/next-tenant-app/src/design-system/tokens/semantic.css +105 -0
  107. package/assets/templates/next-tenant-app/src/design-system/tokens/theme.css +59 -0
  108. package/assets/templates/next-tenant-app/src/design-system/tokens/tokens.stories.tsx +71 -0
  109. package/assets/templates/next-tenant-app/src/features/auth/components/login-screen.tsx +198 -0
  110. package/assets/templates/next-tenant-app/src/features/dashboard/components/tenant-dashboard.tsx +153 -0
  111. package/assets/templates/next-tenant-app/src/features/examples/runtime-command-demo/components/runtime-command-demo.tsx +342 -0
  112. package/assets/templates/next-tenant-app/src/features/public-shell/components/content-article.tsx +66 -0
  113. package/assets/templates/next-tenant-app/src/features/public-shell/components/content-collection.tsx +108 -0
  114. package/assets/templates/next-tenant-app/src/features/public-shell/components/marketing-page-canvas.tsx +111 -0
  115. package/assets/templates/next-tenant-app/src/features/public-shell/components/public-site-shell.tsx +111 -0
  116. package/assets/templates/next-tenant-app/src/features/shell/components/private-app-shell.tsx +624 -0
  117. package/assets/templates/next-tenant-app/src/lib/app-routes.test.ts +20 -0
  118. package/assets/templates/next-tenant-app/src/lib/app-routes.ts +59 -0
  119. package/assets/templates/next-tenant-app/src/lib/content/__fixtures__/public-site-snapshot.ts +189 -0
  120. package/assets/templates/next-tenant-app/src/lib/content/adapter.server.test.ts +318 -0
  121. package/assets/templates/next-tenant-app/src/lib/content/adapter.server.ts +232 -0
  122. package/assets/templates/next-tenant-app/src/lib/content/contracts.ts +339 -0
  123. package/assets/templates/next-tenant-app/src/lib/content/custom-adapter.ts +5 -0
  124. package/assets/templates/next-tenant-app/src/lib/content/empty-state.ts +96 -0
  125. package/assets/templates/next-tenant-app/src/lib/platform/auth.server.test.ts +75 -0
  126. package/assets/templates/next-tenant-app/src/lib/platform/auth.server.ts +25 -0
  127. package/assets/templates/next-tenant-app/src/lib/platform/client.server.test.ts +170 -0
  128. package/assets/templates/next-tenant-app/src/lib/platform/client.server.ts +661 -0
  129. package/assets/templates/next-tenant-app/src/lib/platform/contracts.ts +131 -0
  130. package/assets/templates/next-tenant-app/src/lib/platform/endpoints.server.ts +34 -0
  131. package/assets/templates/next-tenant-app/src/lib/platform/env.server.test.ts +102 -0
  132. package/assets/templates/next-tenant-app/src/lib/platform/env.server.ts +87 -0
  133. package/assets/templates/next-tenant-app/src/lib/platform/route-response.ts +33 -0
  134. package/assets/templates/next-tenant-app/src/lib/platform/session.server.ts +108 -0
  135. package/assets/templates/next-tenant-app/src/lib/public-site.test.ts +20 -0
  136. package/assets/templates/next-tenant-app/src/lib/public-site.ts +49 -0
  137. package/assets/templates/next-tenant-app/src/lib/theme-config.ts +10 -0
  138. package/assets/templates/next-tenant-app/src/lib/theme.tsx +159 -0
  139. package/assets/templates/next-tenant-app/src/lib/utils.ts +6 -0
  140. package/assets/templates/next-tenant-app/template.json +27 -0
  141. package/assets/templates/next-tenant-app/template.schema.json +160 -0
  142. package/assets/templates/next-tenant-app/test/server-only-stub.ts +1 -0
  143. package/assets/templates/next-tenant-app/tools/design-system/build-token-manifest.mjs +3 -0
  144. package/assets/templates/next-tenant-app/tools/design-system/check-imports.mjs +9 -0
  145. package/assets/templates/next-tenant-app/tools/design-system/check-stories.mjs +9 -0
  146. package/assets/templates/next-tenant-app/tools/design-system/check-values.mjs +9 -0
  147. package/assets/templates/next-tenant-app/tools/design-system/checks.mjs +238 -0
  148. package/assets/templates/next-tenant-app/tools/design-system/eslint-plugin-design-system.mjs +184 -0
  149. package/assets/templates/next-tenant-app/tools/design-system/playwright.config.mjs +34 -0
  150. package/assets/templates/next-tenant-app/tools/design-system/run-checks.mjs +22 -0
  151. package/assets/templates/next-tenant-app/tools/design-system/shared.mjs +166 -0
  152. package/assets/templates/next-tenant-app/tools/design-system/visual.spec.ts +41 -0
  153. package/assets/templates/next-tenant-app/tools/template/validate-route-contract.mjs +39 -0
  154. package/assets/templates/next-tenant-app/tools/template/validate-template.mjs +45 -0
  155. package/assets/templates/next-tenant-app/tsconfig.json +42 -0
  156. package/assets/templates/next-tenant-app/vitest.config.ts +25 -0
  157. package/bin/minutework.js +40 -0
  158. package/dist/auth.d.ts +59 -0
  159. package/dist/auth.js +338 -0
  160. package/dist/auth.js.map +1 -0
  161. package/dist/browser.d.ts +1 -0
  162. package/dist/browser.js +26 -0
  163. package/dist/browser.js.map +1 -0
  164. package/dist/cli.d.ts +2 -0
  165. package/dist/cli.js +5 -0
  166. package/dist/cli.js.map +1 -0
  167. package/dist/compile.d.ts +20 -0
  168. package/dist/compile.js +121 -0
  169. package/dist/compile.js.map +1 -0
  170. package/dist/config.d.ts +25 -0
  171. package/dist/config.js +102 -0
  172. package/dist/config.js.map +1 -0
  173. package/dist/deploy-state.d.ts +35 -0
  174. package/dist/deploy-state.js +30 -0
  175. package/dist/deploy-state.js.map +1 -0
  176. package/dist/deploy.d.ts +22 -0
  177. package/dist/deploy.js +308 -0
  178. package/dist/deploy.js.map +1 -0
  179. package/dist/developer-client.d.ts +88 -0
  180. package/dist/developer-client.js +78 -0
  181. package/dist/developer-client.js.map +1 -0
  182. package/dist/index.d.ts +27 -0
  183. package/dist/index.js +290 -0
  184. package/dist/index.js.map +1 -0
  185. package/dist/init.d.ts +22 -0
  186. package/dist/init.js +421 -0
  187. package/dist/init.js.map +1 -0
  188. package/dist/launcher.d.ts +1 -0
  189. package/dist/launcher.js +50 -0
  190. package/dist/launcher.js.map +1 -0
  191. package/dist/paths.d.ts +12 -0
  192. package/dist/paths.js +33 -0
  193. package/dist/paths.js.map +1 -0
  194. package/dist/sandbox.d.ts +30 -0
  195. package/dist/sandbox.js +852 -0
  196. package/dist/sandbox.js.map +1 -0
  197. package/dist/state.d.ts +46 -0
  198. package/dist/state.js +82 -0
  199. package/dist/state.js.map +1 -0
  200. package/dist/tokens.d.ts +14 -0
  201. package/dist/tokens.js +293 -0
  202. package/dist/tokens.js.map +1 -0
  203. package/package.json +43 -0
@@ -0,0 +1,131 @@
1
+ import { z } from "zod";
2
+
3
+ export const commandTemplateKeys = [
4
+ "python_version",
5
+ "echo_hello",
6
+ "system_info",
7
+ "list_apps",
8
+ ] as const;
9
+
10
+ export const commandTemplateKeySchema = z.enum(commandTemplateKeys);
11
+
12
+ export const loginRequestSchema = z.object({
13
+ username: z.string().trim().min(1),
14
+ password: z.string().min(1),
15
+ });
16
+
17
+ export const sessionMembershipSchema = z.object({
18
+ tenant_id: z.string().min(1),
19
+ tenant_slug: z.string().min(1),
20
+ tenant_name: z.string().min(1),
21
+ role: z.string().min(1),
22
+ });
23
+
24
+ export const activeTenantSchema = sessionMembershipSchema;
25
+
26
+ export const platformUserSchema = z.object({
27
+ id: z.string().min(1),
28
+ username: z.string().min(1),
29
+ email: z.string().min(1),
30
+ });
31
+
32
+ export const upstreamSessionPayloadSchema = z
33
+ .object({
34
+ user: platformUserSchema,
35
+ active_tenant_id: z.string().min(1),
36
+ active_tenant: activeTenantSchema,
37
+ memberships: z.array(sessionMembershipSchema),
38
+ })
39
+ .passthrough();
40
+
41
+ export const anonymousPlatformSessionSchema = z.object({
42
+ authenticated: z.literal(false),
43
+ });
44
+
45
+ export const authenticatedPlatformSessionSchema = z.object({
46
+ authenticated: z.literal(true),
47
+ user: platformUserSchema,
48
+ active_tenant_id: z.string().min(1),
49
+ active_tenant: activeTenantSchema,
50
+ memberships: z.array(sessionMembershipSchema),
51
+ });
52
+
53
+ export const platformSessionSchema = z.union([
54
+ anonymousPlatformSessionSchema,
55
+ authenticatedPlatformSessionSchema,
56
+ ]);
57
+
58
+ export const platformErrorPayloadSchema = z
59
+ .object({
60
+ detail: z.string().min(1).optional(),
61
+ error: z.string().min(1).optional(),
62
+ code: z.string().min(1).optional(),
63
+ })
64
+ .passthrough();
65
+
66
+ export const commandDispatchRequestSchema = z.object({
67
+ tenant_id: z.string().min(1),
68
+ template_key: commandTemplateKeySchema,
69
+ });
70
+
71
+ export const tenantSessionContextRequestSchema = z.object({
72
+ tenant_id: z.string().min(1),
73
+ });
74
+
75
+ export const passwordStatusSchema = z.object({
76
+ has_password: z.boolean(),
77
+ });
78
+
79
+ export const passwordChangeRequestSchema = z
80
+ .object({
81
+ current_password: z.string().optional().default(""),
82
+ new_password: z.string().min(1),
83
+ confirm_password: z.string().min(1),
84
+ })
85
+ .superRefine((value, context) => {
86
+ if (value.new_password !== value.confirm_password) {
87
+ context.addIssue({
88
+ code: z.ZodIssueCode.custom,
89
+ path: ["confirm_password"],
90
+ message: "Passwords must match.",
91
+ });
92
+ }
93
+ });
94
+
95
+ export const upstreamPasswordChangeRequestSchema = z.object({
96
+ current_password: z.string().optional(),
97
+ new_password: z.string().min(1),
98
+ });
99
+
100
+ export const commandRunSchema = z.object({
101
+ run_id: z.string().min(1),
102
+ tenant_id: z.string().min(1),
103
+ runtime_id: z.string().min(1),
104
+ template_key: z.string().min(1),
105
+ status: z.string().min(1),
106
+ safe_summary: z.string(),
107
+ exit_code: z.number().int().nullable(),
108
+ stdout_tail: z.string(),
109
+ stderr_tail: z.string(),
110
+ source_ref: z.string(),
111
+ dispatch_error: z.string(),
112
+ });
113
+
114
+ export type LoginRequest = z.infer<typeof loginRequestSchema>;
115
+ export type SessionMembership = z.infer<typeof sessionMembershipSchema>;
116
+ export type ActiveTenant = z.infer<typeof activeTenantSchema>;
117
+ export type PlatformUser = z.infer<typeof platformUserSchema>;
118
+ export type PlatformSession = z.infer<typeof platformSessionSchema>;
119
+ export type AuthenticatedPlatformSession = z.infer<
120
+ typeof authenticatedPlatformSessionSchema
121
+ >;
122
+ export type CommandTemplateKey = z.infer<typeof commandTemplateKeySchema>;
123
+ export type CommandDispatchRequest = z.infer<
124
+ typeof commandDispatchRequestSchema
125
+ >;
126
+ export type CommandRun = z.infer<typeof commandRunSchema>;
127
+ export type TenantSessionContextRequest = z.infer<
128
+ typeof tenantSessionContextRequestSchema
129
+ >;
130
+ export type PasswordStatus = z.infer<typeof passwordStatusSchema>;
131
+ export type PasswordChangeRequest = z.infer<typeof passwordChangeRequestSchema>;
@@ -0,0 +1,34 @@
1
+ import "server-only";
2
+
3
+ import { env } from "@/lib/platform/env.server";
4
+
5
+ const platformBaseUrl = new URL(
6
+ env.MW_PLATFORM_BASE_URL.endsWith("/")
7
+ ? env.MW_PLATFORM_BASE_URL
8
+ : `${env.MW_PLATFORM_BASE_URL}/`,
9
+ );
10
+
11
+ function buildPlatformEndpoint(path: string) {
12
+ return new URL(path.replace(/^\//, ""), platformBaseUrl).toString();
13
+ }
14
+
15
+ export const platformAuthEndpoints = {
16
+ login: buildPlatformEndpoint("/api/v1/session/login/"),
17
+ logout: buildPlatformEndpoint("/api/v1/session/logout/"),
18
+ currentSession: buildPlatformEndpoint("/api/v1/session/me/"),
19
+ sessionContext: buildPlatformEndpoint("/api/v1/session/context/"),
20
+ passwordStatus: buildPlatformEndpoint("/api/v1/session/password-status/"),
21
+ passwordChange: buildPlatformEndpoint("/api/v1/session/password-change/"),
22
+ operatorConsole: buildPlatformEndpoint("/ops/login/"),
23
+ };
24
+
25
+ export const platformGatewayEndpoints = {
26
+ tenantCommands: buildPlatformEndpoint("/api/v1/tenant/commands/"),
27
+ tenantCommandRun(runId: string, tenantId: string) {
28
+ const url = new URL(
29
+ buildPlatformEndpoint(`/api/v1/tenant/commands/${encodeURIComponent(runId)}/`),
30
+ );
31
+ url.searchParams.set("tenant_id", tenantId);
32
+ return url.toString();
33
+ },
34
+ };
@@ -0,0 +1,102 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const originalEnv = { ...process.env };
4
+
5
+ function restoreProcessEnv() {
6
+ for (const key of Object.keys(process.env)) {
7
+ if (!(key in originalEnv)) {
8
+ delete process.env[key];
9
+ }
10
+ }
11
+
12
+ Object.assign(process.env, originalEnv);
13
+ }
14
+
15
+ function applyServerEnv(overrides: Record<string, string | undefined>) {
16
+ restoreProcessEnv();
17
+ process.env.MW_PLATFORM_BASE_URL = "http://127.0.0.1:8000";
18
+ process.env.MW_CONTENT_API_TOKEN = "test-content-token";
19
+ process.env.MW_PUBLIC_BASE_URL = "http://127.0.0.1:3000";
20
+ process.env.MW_PUBLIC_SITE_PROPERTY_KEY = "main-site";
21
+ process.env.MW_ENABLE_RUNTIME_COMMAND_EXAMPLE = "false";
22
+
23
+ for (const [key, value] of Object.entries(overrides)) {
24
+ if (value === undefined) {
25
+ delete process.env[key];
26
+ } else {
27
+ process.env[key] = value;
28
+ }
29
+ }
30
+ }
31
+
32
+ describe("server env", () => {
33
+ afterEach(() => {
34
+ restoreProcessEnv();
35
+ vi.resetModules();
36
+ });
37
+
38
+ it("defaults MW_PUBLIC_SITE_ENV to preview", async () => {
39
+ applyServerEnv({
40
+ MW_PUBLIC_SITE_ENV: undefined,
41
+ });
42
+
43
+ const { env } = await import("./env.server");
44
+
45
+ expect(env.MW_PUBLIC_SITE_ENV).toBe("preview");
46
+ });
47
+
48
+ it("accepts MW_PUBLIC_SITE_ENV=live", async () => {
49
+ applyServerEnv({
50
+ MW_PUBLIC_SITE_ENV: "live",
51
+ });
52
+
53
+ const { env } = await import("./env.server");
54
+
55
+ expect(env.MW_PUBLIC_SITE_ENV).toBe("live");
56
+ });
57
+
58
+ it("requires MW_PUBLIC_BASE_URL", async () => {
59
+ applyServerEnv({
60
+ MW_PUBLIC_BASE_URL: undefined,
61
+ });
62
+
63
+ await expect(import("./env.server")).rejects.toThrow("MW_PUBLIC_BASE_URL");
64
+ });
65
+
66
+ it("requires MW_CONTENT_API_TOKEN", async () => {
67
+ applyServerEnv({
68
+ MW_CONTENT_API_TOKEN: undefined,
69
+ });
70
+
71
+ await expect(import("./env.server")).rejects.toThrow("MW_CONTENT_API_TOKEN");
72
+ });
73
+
74
+ it("requires MW_PUBLIC_SITE_PROPERTY_KEY", async () => {
75
+ applyServerEnv({
76
+ MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
77
+ });
78
+
79
+ await expect(import("./env.server")).rejects.toThrow(
80
+ "MW_PUBLIC_SITE_PROPERTY_KEY",
81
+ );
82
+ });
83
+
84
+ it("requires MW_PLATFORM_BASE_URL in production", async () => {
85
+ applyServerEnv({
86
+ MW_PLATFORM_BASE_URL: undefined,
87
+ NODE_ENV: "production",
88
+ });
89
+
90
+ await expect(import("./env.server")).rejects.toThrow(
91
+ "MW_PLATFORM_BASE_URL must be set in production.",
92
+ );
93
+ });
94
+
95
+ it("rejects unsupported public-site environments", async () => {
96
+ applyServerEnv({
97
+ MW_PUBLIC_SITE_ENV: "draft",
98
+ });
99
+
100
+ await expect(import("./env.server")).rejects.toThrow("MW_PUBLIC_SITE_ENV");
101
+ });
102
+ });
@@ -0,0 +1,87 @@
1
+ import "server-only";
2
+
3
+ import { z } from "zod";
4
+
5
+ const DEFAULT_LOCAL_PLATFORM_BASE_URL = "http://127.0.0.1:8000";
6
+
7
+ const exampleFlagSchema = z.preprocess(
8
+ (value) => value ?? "false",
9
+ z.enum(["true", "false"]).transform((value) => value === "true"),
10
+ );
11
+
12
+ const requiredStringEnvSchema = z.preprocess((value) => {
13
+ if (typeof value !== "string") {
14
+ return value;
15
+ }
16
+
17
+ const normalized = value.trim();
18
+ return normalized.length > 0 ? normalized : undefined;
19
+ }, z.string().min(1));
20
+
21
+ const requiredUrlEnvSchema = z.preprocess((value) => {
22
+ if (typeof value !== "string") {
23
+ return value;
24
+ }
25
+
26
+ const normalized = value.trim();
27
+ return normalized.length > 0 ? normalized : undefined;
28
+ }, z.string().url());
29
+
30
+ const publicSiteEnvironmentSchema = z.enum(["preview", "live"]);
31
+
32
+ const envSchema = z.object({
33
+ MW_PLATFORM_BASE_URL: z.string().url(),
34
+ MW_CONTENT_API_TOKEN: requiredStringEnvSchema,
35
+ MW_TEMPLATE_APP_NAME: z.preprocess(
36
+ (value) => value ?? "MinuteWork Combined Starter",
37
+ z.string().trim().min(1),
38
+ ),
39
+ MW_PUBLIC_BASE_URL: requiredUrlEnvSchema,
40
+ MW_PUBLIC_SITE_PROPERTY_KEY: requiredStringEnvSchema,
41
+ MW_PUBLIC_SITE_ENV: z.preprocess(
42
+ (value) => value ?? "preview",
43
+ publicSiteEnvironmentSchema,
44
+ ),
45
+ MW_ENABLE_RUNTIME_COMMAND_EXAMPLE: exampleFlagSchema,
46
+ NODE_ENV: z
47
+ .enum(["development", "test", "production"])
48
+ .default("development"),
49
+ });
50
+
51
+ const defaultPlatformBaseUrl =
52
+ process.env.MW_PLATFORM_BASE_URL ??
53
+ (process.env.NODE_ENV === "production"
54
+ ? undefined
55
+ : DEFAULT_LOCAL_PLATFORM_BASE_URL);
56
+
57
+ const parsedEnv = envSchema.safeParse({
58
+ MW_PLATFORM_BASE_URL: defaultPlatformBaseUrl,
59
+ MW_CONTENT_API_TOKEN: process.env.MW_CONTENT_API_TOKEN,
60
+ MW_TEMPLATE_APP_NAME: process.env.MW_TEMPLATE_APP_NAME,
61
+ MW_PUBLIC_BASE_URL: process.env.MW_PUBLIC_BASE_URL,
62
+ MW_PUBLIC_SITE_PROPERTY_KEY: process.env.MW_PUBLIC_SITE_PROPERTY_KEY,
63
+ MW_PUBLIC_SITE_ENV: process.env.MW_PUBLIC_SITE_ENV,
64
+ MW_ENABLE_RUNTIME_COMMAND_EXAMPLE:
65
+ process.env.MW_ENABLE_RUNTIME_COMMAND_EXAMPLE,
66
+ NODE_ENV: process.env.NODE_ENV,
67
+ });
68
+
69
+ if (!parsedEnv.success) {
70
+ if (
71
+ process.env.NODE_ENV === "production" &&
72
+ !process.env.MW_PLATFORM_BASE_URL
73
+ ) {
74
+ throw new Error("MW_PLATFORM_BASE_URL must be set in production.");
75
+ }
76
+
77
+ const issues = parsedEnv.error.issues
78
+ .map((issue) => {
79
+ const path = issue.path.join(".");
80
+ return path.length > 0 ? `${path}: ${issue.message}` : issue.message;
81
+ })
82
+ .join("; ");
83
+
84
+ throw new Error(`Invalid server environment for next-tenant-app: ${issues}`);
85
+ }
86
+
87
+ export const env = parsedEnv.data;
@@ -0,0 +1,33 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ import { PlatformClientError } from "@/lib/platform/client.server";
4
+ import type { PlatformAuthState } from "@/lib/platform/session.server";
5
+ import { syncPlatformAuthStateToResponse } from "@/lib/platform/session.server";
6
+
7
+ export function toPlatformErrorResponse(
8
+ error: unknown,
9
+ fallbackDetail: string,
10
+ previousAuthState?: PlatformAuthState,
11
+ ) {
12
+ if (error instanceof PlatformClientError) {
13
+ const response = NextResponse.json(
14
+ {
15
+ detail: error.message,
16
+ code: error.code,
17
+ },
18
+ { status: error.status },
19
+ );
20
+
21
+ if (previousAuthState && error.authState) {
22
+ syncPlatformAuthStateToResponse(
23
+ response,
24
+ previousAuthState,
25
+ error.authState,
26
+ );
27
+ }
28
+
29
+ return response;
30
+ }
31
+
32
+ return NextResponse.json({ detail: fallbackDetail }, { status: 500 });
33
+ }
@@ -0,0 +1,108 @@
1
+ import "server-only";
2
+
3
+ import { cookies } from "next/headers";
4
+ import type { NextResponse } from "next/server";
5
+
6
+ import { env } from "@/lib/platform/env.server";
7
+
8
+ export const PLATFORM_SESSION_COOKIE = "mw_platform_session";
9
+ export const PLATFORM_CSRF_COOKIE = "mw_platform_csrf";
10
+
11
+ export type PlatformAuthState = {
12
+ sessionId: string | null;
13
+ csrfToken: string | null;
14
+ };
15
+
16
+ function platformCookie(name: string, value: string) {
17
+ return {
18
+ name,
19
+ value,
20
+ httpOnly: true,
21
+ sameSite: "lax" as const,
22
+ path: "/",
23
+ secure: env.NODE_ENV === "production",
24
+ };
25
+ }
26
+
27
+ function platformSessionCookie(value: string) {
28
+ return platformCookie(PLATFORM_SESSION_COOKIE, value);
29
+ }
30
+
31
+ function platformCsrfCookie(value: string) {
32
+ return platformCookie(PLATFORM_CSRF_COOKIE, value);
33
+ }
34
+
35
+ export async function readPlatformAuthState(): Promise<PlatformAuthState> {
36
+ const cookieStore = await cookies();
37
+
38
+ return {
39
+ sessionId: cookieStore.get(PLATFORM_SESSION_COOKIE)?.value ?? null,
40
+ csrfToken: cookieStore.get(PLATFORM_CSRF_COOKIE)?.value ?? null,
41
+ };
42
+ }
43
+
44
+ export async function readPlatformSessionId() {
45
+ return (await readPlatformAuthState()).sessionId;
46
+ }
47
+
48
+ export async function readPlatformCsrfToken() {
49
+ return (await readPlatformAuthState()).csrfToken;
50
+ }
51
+
52
+ export function applyPlatformSessionToResponse(
53
+ response: NextResponse,
54
+ sessionId: string,
55
+ ) {
56
+ response.cookies.set(platformSessionCookie(sessionId));
57
+ }
58
+
59
+ export function applyPlatformCsrfToResponse(
60
+ response: NextResponse,
61
+ csrfToken: string,
62
+ ) {
63
+ response.cookies.set(platformCsrfCookie(csrfToken));
64
+ }
65
+
66
+ export function applyPlatformAuthStateToResponse(
67
+ response: NextResponse,
68
+ authState: PlatformAuthState,
69
+ ) {
70
+ if (authState.sessionId) {
71
+ applyPlatformSessionToResponse(response, authState.sessionId);
72
+ }
73
+
74
+ if (authState.csrfToken) {
75
+ applyPlatformCsrfToResponse(response, authState.csrfToken);
76
+ }
77
+ }
78
+
79
+ export function syncPlatformAuthStateToResponse(
80
+ response: NextResponse,
81
+ previousAuthState: PlatformAuthState,
82
+ nextAuthState: PlatformAuthState,
83
+ ) {
84
+ if (previousAuthState.sessionId !== nextAuthState.sessionId) {
85
+ if (nextAuthState.sessionId) {
86
+ applyPlatformSessionToResponse(response, nextAuthState.sessionId);
87
+ } else {
88
+ response.cookies.delete(PLATFORM_SESSION_COOKIE);
89
+ }
90
+ }
91
+
92
+ if (previousAuthState.csrfToken !== nextAuthState.csrfToken) {
93
+ if (nextAuthState.csrfToken) {
94
+ applyPlatformCsrfToResponse(response, nextAuthState.csrfToken);
95
+ } else {
96
+ response.cookies.delete(PLATFORM_CSRF_COOKIE);
97
+ }
98
+ }
99
+ }
100
+
101
+ export function clearPlatformSessionFromResponse(response: NextResponse) {
102
+ response.cookies.delete(PLATFORM_SESSION_COOKIE);
103
+ response.cookies.delete(PLATFORM_CSRF_COOKIE);
104
+ }
105
+
106
+ export function clearPlatformCsrfFromResponse(response: NextResponse) {
107
+ response.cookies.delete(PLATFORM_CSRF_COOKIE);
108
+ }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { buildPublicMetadata, resolvePublicSiteUrl } from "@/lib/public-site";
4
+
5
+ describe("public-site helpers", () => {
6
+ it("builds canonical urls from MW_PUBLIC_BASE_URL", () => {
7
+ expect(process.env.MW_PUBLIC_BASE_URL).toBe("http://127.0.0.1:3000");
8
+
9
+ const metadata = buildPublicMetadata({
10
+ title: "Docs",
11
+ description: "Starter documentation",
12
+ path: "/docs",
13
+ siteName: "MinuteWork Combined Starter",
14
+ });
15
+
16
+ expect(resolvePublicSiteUrl("/docs").toString()).toBe("http://127.0.0.1:3000/docs");
17
+ expect(metadata.alternates?.canonical).toBe("http://127.0.0.1:3000/docs");
18
+ expect(metadata.openGraph?.url).toBe("http://127.0.0.1:3000/docs");
19
+ });
20
+ });
@@ -0,0 +1,49 @@
1
+ import "server-only";
2
+
3
+ import type { Metadata } from "next";
4
+
5
+ import { env } from "@/lib/platform/env.server";
6
+
7
+ export function resolvePublicMetadataBase() {
8
+ return new URL(
9
+ env.MW_PUBLIC_BASE_URL.endsWith("/")
10
+ ? env.MW_PUBLIC_BASE_URL
11
+ : `${env.MW_PUBLIC_BASE_URL}/`,
12
+ );
13
+ }
14
+
15
+ export function resolvePublicSiteUrl(pathname = "/") {
16
+ return new URL(pathname.replace(/^\/*/, "/"), resolvePublicMetadataBase());
17
+ }
18
+
19
+ export function buildPublicMetadata(input: {
20
+ title: string;
21
+ description: string;
22
+ path: string;
23
+ siteName?: string;
24
+ type?: "website" | "article";
25
+ publishedTime?: string | null;
26
+ }): Metadata {
27
+ const canonicalUrl = resolvePublicSiteUrl(input.path).toString();
28
+
29
+ return {
30
+ title: input.title,
31
+ description: input.description,
32
+ alternates: {
33
+ canonical: canonicalUrl,
34
+ },
35
+ openGraph: {
36
+ title: input.title,
37
+ description: input.description,
38
+ url: canonicalUrl,
39
+ siteName: input.siteName ?? env.MW_TEMPLATE_APP_NAME,
40
+ type: input.type ?? "website",
41
+ ...(input.publishedTime ? { publishedTime: input.publishedTime } : {}),
42
+ },
43
+ twitter: {
44
+ card: "summary_large_image",
45
+ title: input.title,
46
+ description: input.description,
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,10 @@
1
+ export type ThemeMode = "light" | "dark" | "system";
2
+ export type ResolvedTheme = "light" | "dark";
3
+
4
+ export const THEME_STORAGE_KEY = "next-tenant-app-theme";
5
+ export const THEME_COOKIE_NAME = "next-tenant-app-theme";
6
+ export const SYSTEM_THEME_QUERY = "(prefers-color-scheme: dark)";
7
+
8
+ export function isThemeMode(value: string | null | undefined): value is ThemeMode {
9
+ return value === "light" || value === "dark" || value === "system";
10
+ }