@podosoft/podokit 0.4.0 → 0.5.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 (141) hide show
  1. package/README.md +2 -2
  2. package/dist/add.d.ts +2 -0
  3. package/dist/add.js +5 -1
  4. package/dist/assemble.d.ts +14 -0
  5. package/dist/assemble.js +60 -0
  6. package/dist/create.d.ts +2 -0
  7. package/dist/create.js +7 -0
  8. package/dist/eject.d.ts +11 -0
  9. package/dist/eject.js +27 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +122 -1
  12. package/dist/inspect.d.ts +43 -0
  13. package/dist/inspect.js +82 -0
  14. package/dist/lockfile.d.ts +80 -0
  15. package/dist/lockfile.js +193 -0
  16. package/dist/templates/base/dot-env.example +11 -6
  17. package/dist/templates/fullstack-nest-svelte/README.md +2 -2
  18. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
  19. package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +6 -3
  20. package/dist/templates/fullstack-nest-svelte/apps/api/src/common/app-exception.ts +4 -12
  21. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/env.validation.ts +1 -1
  22. package/dist/templates/fullstack-nest-svelte/apps/api/src/main.ts +1 -1
  23. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +2 -2
  24. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-content.svelte +1 -1
  25. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-item.svelte +1 -1
  26. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/index.ts +7 -0
  27. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
  28. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +1 -1
  29. package/dist/templates/fullstack-nest-svelte/dot-env.example +10 -5
  30. package/dist/templates/fullstack-nest-svelte/infra/docker/docker-compose.yml +37 -0
  31. package/dist/templates/fullstack-nest-svelte/infra/docker/sms-sink.mjs +40 -0
  32. package/dist/templates/fullstack-nest-svelte/tests/README.md +1 -1
  33. package/dist/templates/fullstack-nest-svelte/tests/package.json +2 -1
  34. package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +1 -1
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +7 -1
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +149 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-pagination.svelte +21 -22
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +93 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +224 -8
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +22 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +19 -3
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/accept-invitation/[id]/+page.svelte +36 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/+page.svelte +21 -1
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +360 -49
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.server.ts +2 -4
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +82 -37
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.server.ts +6 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +264 -0
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.server.ts +3 -4
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +79 -45
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +26 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +322 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.server.ts +3 -5
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +254 -108
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/forgot-password/+page.svelte +8 -1
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +14 -0
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +127 -5
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +18 -2
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/verify-email/+page.svelte +63 -0
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +54 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +51 -2
  63. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +80 -0
  64. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +264 -1
  65. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +120 -0
  66. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +10 -10
  67. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +2 -2
  68. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +49 -0
  69. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +35 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/helpers/totp.ts +9 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +1 -1
  72. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +12 -1
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +77 -6
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +1 -1
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -0
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +60 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +42 -0
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +1 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +57 -0
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +52 -0
  81. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +36 -0
  82. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +9 -2
  83. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +34 -0
  84. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +32 -0
  85. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +28 -12
  86. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +35 -3
  87. package/dist/templates/modules/admin-dashboard/module.manifest.json +13 -7
  88. package/dist/templates/modules/api-key-auth/module.manifest.json +3 -3
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +16 -0
  90. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +4 -0
  91. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.interceptor.ts +2 -0
  92. package/dist/templates/modules/audit-log/module.manifest.json +15 -9
  93. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +78 -13
  94. package/dist/templates/modules/auth/files/apps/api/src/account/account.module.ts +2 -1
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +73 -0
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +197 -52
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +12 -0
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +56 -0
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/org-permissions.ts +22 -0
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/permissions.ts +23 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.controller.ts +33 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.entity.ts +26 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.module.ts +12 -0
  104. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +118 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/mail/mailer.ts +65 -0
  106. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +38 -0
  107. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +27 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/settings/app-setting.entity.ts +16 -0
  109. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +25 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.module.ts +11 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +57 -0
  112. package/dist/templates/modules/auth/files/apps/api/src/sms/sms.ts +23 -0
  113. package/dist/templates/modules/auth/module.manifest.json +54 -20
  114. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +6 -3
  115. package/dist/templates/modules/bullmq/module.manifest.json +4 -4
  116. package/dist/templates/modules/file-upload/module.manifest.json +3 -3
  117. package/dist/templates/modules/job-progress/module.manifest.json +9 -9
  118. package/dist/templates/modules/logging/module.manifest.json +3 -3
  119. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -3
  120. package/dist/templates/modules/object-storage-s3/module.manifest.json +5 -5
  121. package/dist/templates/modules/rate-limit/module.manifest.json +2 -2
  122. package/dist/templates/modules/redis/module.manifest.json +4 -4
  123. package/dist/templates/modules/sse/module.manifest.json +4 -4
  124. package/dist/templates/todo/README.md +2 -2
  125. package/dist/templates/todo/apps/api/package.json +1 -0
  126. package/dist/templates/todo/apps/api/src/app.module.ts +6 -3
  127. package/dist/templates/todo/apps/api/src/common/app-exception.ts +4 -12
  128. package/dist/templates/todo/apps/api/src/config/env.validation.ts +1 -1
  129. package/dist/templates/todo/apps/api/src/main.ts +1 -1
  130. package/dist/templates/todo/apps/web/package.json +2 -2
  131. package/dist/templates/todo/apps/web/src/lib/components/ui/switch/index.ts +7 -0
  132. package/dist/templates/todo/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
  133. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +1 -1
  134. package/dist/templates/todo/dot-env.example +10 -5
  135. package/dist/templates/todo/infra/docker/docker-compose.yml +4 -0
  136. package/dist/templates/todo/tests/README.md +1 -1
  137. package/dist/templates/todo/tests/package.json +2 -1
  138. package/dist/templates/todo/tests/playwright.config.ts +1 -1
  139. package/dist/update.d.ts +49 -0
  140. package/dist/update.js +158 -0
  141. package/package.json +2 -2
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_OWNED_GLOBS = exports.LOCK_SCHEMA_VERSION = void 0;
4
+ exports.manifestPath = manifestPath;
5
+ exports.filesLockPath = filesLockPath;
6
+ exports.readManifest = readManifest;
7
+ exports.writeManifest = writeManifest;
8
+ exports.writeFilesLock = writeFilesLock;
9
+ exports.podokitVersion = podokitVersion;
10
+ exports.matchGlob = matchGlob;
11
+ exports.classifyTier = classifyTier;
12
+ exports.walkFiles = walkFiles;
13
+ exports.computeFilesLock = computeFilesLock;
14
+ exports.readFilesLock = readFilesLock;
15
+ exports.computeDrift = computeDrift;
16
+ exports.initLockfile = initLockfile;
17
+ exports.recordModules = recordModules;
18
+ const node_fs_1 = require("node:fs");
19
+ const node_path_1 = require("node:path");
20
+ const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
21
+ /**
22
+ * The generation lockfile (`.podokit/`) records how a project was assembled so
23
+ * `podo update` can rebuild the same inputs and detect user edits. See
24
+ * ADR-0009 (update mechanism) and ADR-0010 (ownership model).
25
+ */
26
+ exports.LOCK_SCHEMA_VERSION = 1;
27
+ const PODOKIT_DIR = ".podokit";
28
+ const MANIFEST_FILE = "manifest.json";
29
+ const FILES_LOCK_FILE = "files.lock";
30
+ /** Marker that flags a file as assembled from base + module injections. */
31
+ const INJECTION_MARKER = "// podokit:";
32
+ /** Directories that never belong in the lockfile (generated / vendored). */
33
+ const WALK_IGNORE = new Set([
34
+ "node_modules",
35
+ ".git",
36
+ PODOKIT_DIR,
37
+ "dist",
38
+ "build",
39
+ ".svelte-kit",
40
+ ".turbo",
41
+ "coverage",
42
+ ]);
43
+ /** Default user-owned globs seeded into a new project. See ADR-0010. */
44
+ exports.DEFAULT_OWNED_GLOBS = [
45
+ "apps/web/src/routes/**",
46
+ "apps/web/src/lib/components/ui/**",
47
+ ];
48
+ const TEXT_EXT = /\.(json|md|ts|tsx|js|mjs|cjs|css|html|yml|yaml|txt|env|svelte|gitignore|example)$/i;
49
+ function podokitDir(projectRoot) {
50
+ return (0, node_path_1.join)(projectRoot, PODOKIT_DIR);
51
+ }
52
+ function manifestPath(projectRoot) {
53
+ return (0, node_path_1.join)(podokitDir(projectRoot), MANIFEST_FILE);
54
+ }
55
+ function filesLockPath(projectRoot) {
56
+ return (0, node_path_1.join)(podokitDir(projectRoot), FILES_LOCK_FILE);
57
+ }
58
+ function readManifest(projectRoot) {
59
+ const file = manifestPath(projectRoot);
60
+ return (0, node_fs_1.existsSync)(file) ? JSON.parse((0, node_fs_1.readFileSync)(file, "utf8")) : null;
61
+ }
62
+ function writeManifest(projectRoot, manifest) {
63
+ (0, node_fs_1.mkdirSync)(podokitDir(projectRoot), { recursive: true });
64
+ (0, node_fs_1.writeFileSync)(manifestPath(projectRoot), `${JSON.stringify(manifest, null, 2)}\n`);
65
+ }
66
+ function writeFilesLock(projectRoot, lock) {
67
+ (0, node_fs_1.mkdirSync)(podokitDir(projectRoot), { recursive: true });
68
+ (0, node_fs_1.writeFileSync)(filesLockPath(projectRoot), `${JSON.stringify(lock, null, 2)}\n`);
69
+ }
70
+ /** Read the PodoKit CLI's own version to stamp into the manifest. */
71
+ function podokitVersion() {
72
+ try {
73
+ const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, "..", "package.json"), "utf8"));
74
+ return pkg.version ?? "0.0.0";
75
+ }
76
+ catch {
77
+ return "0.0.0";
78
+ }
79
+ }
80
+ /** Match a POSIX relative path against a simple glob supporting `*` and `**`. */
81
+ function matchGlob(path, glob) {
82
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
83
+ // Single pass so replacement output is never re-scanned.
84
+ const pattern = escaped.replace(/\/\*\*$|\*\*\/|\*\*|\*/g, (token) => {
85
+ if (token === "/**")
86
+ return "(?:/.*)?"; // trailing `/**`: the dir itself or anything under it
87
+ if (token === "**/")
88
+ return "(?:.*/)?"; // `**/`: zero or more leading segments
89
+ if (token === "**")
90
+ return ".*"; // `**`: any characters
91
+ return "[^/]*"; // `*`: within a single segment
92
+ });
93
+ return new RegExp(`^${pattern}$`).test(path);
94
+ }
95
+ function isTextPath(relPath) {
96
+ return relPath.startsWith("dot-") || TEXT_EXT.test(relPath);
97
+ }
98
+ /** Classify a file's ownership tier. See ADR-0010 priority order. */
99
+ function classifyTier(relPath, content, ownedGlobs) {
100
+ if (ownedGlobs.some((glob) => matchGlob(relPath, glob)))
101
+ return "owned";
102
+ if (isTextPath(relPath) && content.toString("utf8").includes(INJECTION_MARKER))
103
+ return "assembled";
104
+ return "managed";
105
+ }
106
+ /** List project files as POSIX-relative paths, skipping generated/vendored dirs. */
107
+ function walkFiles(projectRoot, prefix = "") {
108
+ const out = [];
109
+ for (const entry of (0, node_fs_1.readdirSync)((0, node_path_1.join)(projectRoot, prefix))) {
110
+ if (WALK_IGNORE.has(entry))
111
+ continue;
112
+ const rel = prefix ? `${prefix}/${entry}` : entry;
113
+ if ((0, node_fs_1.statSync)((0, node_path_1.join)(projectRoot, rel)).isDirectory()) {
114
+ out.push(...walkFiles(projectRoot, rel));
115
+ }
116
+ else {
117
+ out.push(rel);
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ /** Recompute the files.lock by hashing and classifying every project file. */
123
+ function computeFilesLock(projectRoot, ownedGlobs) {
124
+ const files = {};
125
+ for (const rel of walkFiles(projectRoot).sort()) {
126
+ const content = (0, node_fs_1.readFileSync)((0, node_path_1.join)(projectRoot, rel));
127
+ files[rel] = { tier: classifyTier(rel, content, ownedGlobs), outHash: (0, podokit_template_engine_1.hashContent)(content) };
128
+ }
129
+ return { schemaVersion: exports.LOCK_SCHEMA_VERSION, files };
130
+ }
131
+ function readFilesLock(projectRoot) {
132
+ const file = filesLockPath(projectRoot);
133
+ return (0, node_fs_1.existsSync)(file) ? JSON.parse((0, node_fs_1.readFileSync)(file, "utf8")) : null;
134
+ }
135
+ /**
136
+ * Compare the current disk against the lock: report managed/assembled files the
137
+ * user has edited (hash mismatch) or deleted. Owned files are never reported —
138
+ * they belong to the user. Returns empty drift when there is no lock.
139
+ */
140
+ function computeDrift(projectRoot) {
141
+ const lock = readFilesLock(projectRoot);
142
+ const drifted = [];
143
+ const missing = [];
144
+ if (!lock)
145
+ return { drifted, missing };
146
+ for (const [rel, entry] of Object.entries(lock.files)) {
147
+ if (entry.tier === "owned")
148
+ continue;
149
+ const abs = (0, node_path_1.join)(projectRoot, rel);
150
+ if (!(0, node_fs_1.existsSync)(abs)) {
151
+ missing.push(rel);
152
+ continue;
153
+ }
154
+ if ((0, podokit_template_engine_1.hashContent)((0, node_fs_1.readFileSync)(abs)) !== entry.outHash)
155
+ drifted.push(rel);
156
+ }
157
+ return { drifted: drifted.sort(), missing: missing.sort() };
158
+ }
159
+ /** Write the initial `.podokit/` lockfile for a freshly created project. */
160
+ function initLockfile(projectRoot, options) {
161
+ const ownedGlobs = options.ownedGlobs ?? exports.DEFAULT_OWNED_GLOBS;
162
+ const manifest = {
163
+ schemaVersion: exports.LOCK_SCHEMA_VERSION,
164
+ podokitVersion: options.version ?? podokitVersion(),
165
+ template: options.template,
166
+ packageManager: options.packageManager,
167
+ answers: options.answers,
168
+ modules: [],
169
+ ownedGlobs,
170
+ };
171
+ writeManifest(projectRoot, manifest);
172
+ writeFilesLock(projectRoot, computeFilesLock(projectRoot, ownedGlobs));
173
+ }
174
+ /**
175
+ * Record applied modules in the manifest and recompute the files.lock. Modules
176
+ * already present are left in place (idempotent); the lock always reflects disk.
177
+ * No-op on projects without a manifest (pre-lockfile projects; see `podo adopt`).
178
+ */
179
+ function recordModules(projectRoot, moduleNames, version) {
180
+ const manifest = readManifest(projectRoot);
181
+ if (!manifest)
182
+ return;
183
+ const stampedWith = version ?? podokitVersion();
184
+ const known = new Set(manifest.modules.map((m) => m.name));
185
+ for (const name of moduleNames) {
186
+ if (known.has(name))
187
+ continue;
188
+ manifest.modules.push({ name, order: manifest.modules.length, addedWith: stampedWith });
189
+ known.add(name);
190
+ }
191
+ writeManifest(projectRoot, manifest);
192
+ writeFilesLock(projectRoot, computeFilesLock(projectRoot, manifest.ownedGlobs));
193
+ }
@@ -1,15 +1,20 @@
1
1
  # Copy to .env and adjust. Never commit real secrets.
2
+
3
+ # ── Required (runtime) ───────────────────────────────────────────────
2
4
  POSTGRES_HOST=localhost
3
5
  POSTGRES_PORT=5432
4
6
  POSTGRES_USER=podokit
5
7
  POSTGRES_PASSWORD=podokit
6
8
  POSTGRES_DB=podokit
7
9
 
10
+ APP_BASE_URL=http://localhost:5001
11
+ API_BASE_URL=http://localhost:5002
12
+ BACKEND_INTERNAL_URL=http://localhost:5002
13
+ CORS_ORIGIN=http://localhost:5001
14
+ PORT=5002
15
+
16
+ # ── Optional / development ───────────────────────────────────────────
17
+ # Redis — only needed by cache/queue modules. Start it with:
18
+ # docker compose --profile cache up
8
19
  REDIS_HOST=localhost
9
20
  REDIS_PORT=6379
10
-
11
- APP_BASE_URL=http://localhost:5173
12
- API_BASE_URL=http://localhost:3000
13
- BACKEND_INTERNAL_URL=http://localhost:3000
14
- CORS_ORIGIN=http://localhost:5173
15
- PORT=3000
@@ -18,8 +18,8 @@ docker compose -f infra/docker/docker-compose.yml up -d
18
18
  {{packageManager}} run dev
19
19
  ```
20
20
 
21
- - API: http://localhost:3000 — health at `/health`, docs at `/api-docs`
22
- - Web: http://localhost:5173
21
+ - API: http://localhost:5002 — health at `/health`, docs at `/api-docs`
22
+ - Web: http://localhost:5001
23
23
 
24
24
  ## Database & migrations
25
25
 
@@ -14,6 +14,7 @@
14
14
  "migration:generate": "npm run typeorm -- migration:generate"
15
15
  },
16
16
  "dependencies": {
17
+ "@podosoft/podokit-contracts": "^0.1.0",
17
18
  "@nestjs/common": "^11.1.27",
18
19
  "@nestjs/config": "^4.0.4",
19
20
  "@nestjs/core": "^11.1.27",
@@ -4,17 +4,20 @@ import { TypeOrmModule } from "@nestjs/typeorm";
4
4
  import { validateEnv } from "./config/env.validation";
5
5
  import { dataSourceOptions } from "./database/data-source";
6
6
  import { HealthModule } from "./health/health.module";
7
- // podokit:imports
7
+ // podokit:begin:imports
8
+ // podokit:end:imports
8
9
 
9
10
  @Module({
10
11
  imports: [
11
12
  ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
12
13
  TypeOrmModule.forRoot(dataSourceOptions),
13
14
  HealthModule,
14
- // podokit:module-imports
15
+ // podokit:begin:module-imports
16
+ // podokit:end:module-imports
15
17
  ],
16
18
  providers: [
17
- // podokit:providers
19
+ // podokit:begin:providers
20
+ // podokit:end:providers
18
21
  ],
19
22
  })
20
23
  export class AppModule {}
@@ -1,12 +1,4 @@
1
- // Stable, language-independent error codes. The frontend branches on `code`,
2
- // not on the human-readable message.
3
- export class AppException extends Error {
4
- constructor(
5
- readonly code: string,
6
- message: string,
7
- readonly statusCode = 400,
8
- ) {
9
- super(message);
10
- this.name = "AppException";
11
- }
12
- }
1
+ // AppException is a shared contract (stable, language-independent error `code`
2
+ // the frontend branches on). Re-exported from @podosoft/podokit-contracts so the
3
+ // backend and any other layer reference one definition.
4
+ export { AppException } from "@podosoft/podokit-contracts";
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  // Schema-validated environment. Fails fast at boot if something is wrong.
4
4
  const schema = z.object({
5
5
  NODE_ENV: z.string().default("development"),
6
- PORT: z.coerce.number().default(3000),
6
+ PORT: z.coerce.number().default(5002),
7
7
  CORS_ORIGIN: z.string().optional(),
8
8
  POSTGRES_HOST: z.string().default("localhost"),
9
9
  POSTGRES_PORT: z.coerce.number().default(5432),
@@ -22,7 +22,7 @@ async function bootstrap(): Promise<void> {
22
22
  .build();
23
23
  SwaggerModule.setup("api-docs", app, SwaggerModule.createDocument(app, config));
24
24
 
25
- const port = Number(process.env.PORT ?? 3000);
25
+ const port = Number(process.env.PORT ?? 5002);
26
26
  await app.listen(port);
27
27
  }
28
28
 
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "vite dev --port 5173",
7
+ "dev": "vite dev --port 5001",
8
8
  "build": "vite build",
9
9
  "preview": "vite preview",
10
10
  "lint": "svelte-check --tsconfig ./tsconfig.json",
@@ -13,7 +13,7 @@
13
13
  "dependencies": {
14
14
  "@internationalized/date": "^3.12.2",
15
15
  "@lucide/svelte": "^1.23.0",
16
- "@podosoft/podokit-api-client": "^0.4.0",
16
+ "@podosoft/podokit-api-client": "^0.5.0",
17
17
  "bits-ui": "^2.18.1",
18
18
  "clsx": "^2.1.1",
19
19
  "mode-watcher": "^1.1.0",
@@ -27,7 +27,7 @@
27
27
  {preventScroll}
28
28
  data-slot="select-content"
29
29
  class={cn(
30
- "bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 overflow-x-hidden overflow-y-auto",
30
+ "bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-96 overflow-x-hidden overflow-y-auto",
31
31
  className
32
32
  )}
33
33
  {...restProps}
@@ -26,7 +26,7 @@
26
26
  {#snippet children({ selected, highlighted })}
27
27
  <span class="absolute end-2 flex size-3.5 items-center justify-center">
28
28
  {#if selected}
29
- <CheckIcon class="cn-select-item-indicator-icon" />
29
+ <CheckIcon class="size-3.5" />
30
30
  {/if}
31
31
  </span>
32
32
  <span class="flex flex-1 gap-2 shrink-0 whitespace-nowrap">
@@ -0,0 +1,7 @@
1
+ import Root from "./switch.svelte";
2
+
3
+ export {
4
+ Root,
5
+ //
6
+ Root as Switch,
7
+ };
@@ -0,0 +1,29 @@
1
+ <script lang="ts">
2
+ import { Switch as SwitchPrimitive } from "bits-ui";
3
+ import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
4
+
5
+ let {
6
+ ref = $bindable(null),
7
+ checked = $bindable(false),
8
+ class: className,
9
+ ...restProps
10
+ }: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
11
+ </script>
12
+
13
+ <SwitchPrimitive.Root
14
+ bind:ref
15
+ data-slot="switch"
16
+ class={cn(
17
+ "peer focus-visible:border-ring focus-visible:ring-ring/50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
18
+ className
19
+ )}
20
+ bind:checked
21
+ {...restProps}
22
+ >
23
+ <SwitchPrimitive.Thumb
24
+ data-slot="switch-thumb"
25
+ class={cn(
26
+ "bg-background pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
27
+ )}
28
+ />
29
+ </SwitchPrimitive.Root>
@@ -5,7 +5,7 @@ const FORWARDED_HEADERS = ["authorization", "cookie", "content-type", "accept",
5
5
  const RELAYED_RESPONSE_HEADERS = ["content-type", "location", "cache-control"];
6
6
 
7
7
  export function backendBaseUrl(): string {
8
- return process.env.BACKEND_INTERNAL_URL ?? "http://localhost:3000";
8
+ return process.env.BACKEND_INTERNAL_URL ?? "http://localhost:5002";
9
9
  }
10
10
 
11
11
  // Normalize the resolved client IP for the forwarded header. better-auth stores
@@ -1,6 +1,8 @@
1
1
  # Copy to .env and adjust. Never commit real secrets.
2
+
3
+ # ── Required (runtime) ───────────────────────────────────────────────
2
4
  NODE_ENV=development
3
- PORT=3000
5
+ PORT=5002
4
6
 
5
7
  POSTGRES_HOST=localhost
6
8
  POSTGRES_PORT=5432
@@ -8,9 +10,12 @@ POSTGRES_USER=podokit
8
10
  POSTGRES_PASSWORD=podokit
9
11
  POSTGRES_DB=podokit
10
12
 
13
+ # Web -> API boundary (server-side only; not exposed to the browser)
14
+ BACKEND_INTERNAL_URL=http://localhost:5002
15
+ CORS_ORIGIN=http://localhost:5001
16
+
17
+ # ── Optional / development ───────────────────────────────────────────
18
+ # Redis — only needed by cache/queue modules (redis, bullmq, rate-limit,
19
+ # job-progress, sse). Start it with: docker compose --profile cache up
11
20
  REDIS_HOST=localhost
12
21
  REDIS_PORT=6379
13
-
14
- # Web -> API boundary (server-side only; not exposed to the browser)
15
- BACKEND_INTERNAL_URL=http://localhost:3000
16
- CORS_ORIGIN=http://localhost:5173
@@ -1,4 +1,5 @@
1
1
  services:
2
+ # --- Runtime (always started by `docker compose up`) ---
2
3
  postgres:
3
4
  image: postgres:16-alpine
4
5
  environment:
@@ -15,8 +16,11 @@ services:
15
16
  timeout: 5s
16
17
  retries: 5
17
18
 
19
+ # --- Runtime, but only needed by cache/queue modules (redis, bullmq,
20
+ # rate-limit, job-progress, sse). Start with: docker compose --profile cache up
18
21
  redis:
19
22
  image: redis:7-alpine
23
+ profiles: ["cache"]
20
24
  ports:
21
25
  - "${REDIS_PORT:-6379}:6379"
22
26
  healthcheck:
@@ -25,5 +29,38 @@ services:
25
29
  timeout: 5s
26
30
  retries: 5
27
31
 
32
+ # --- Development / testing tools (NOT for production) ---
33
+ # Start with: docker compose --profile dev up
34
+ # Local email catcher: SMTP on 1025, web UI + REST API on 8025 (http://localhost:8025).
35
+ mailpit:
36
+ image: axllent/mailpit:latest
37
+ profiles: ["dev"]
38
+ ports:
39
+ - "${SMTP_PORT:-1025}:1025"
40
+ - "${MAILPIT_UI_PORT:-8025}:8025"
41
+ healthcheck:
42
+ test: ["CMD", "wget", "-qO-", "http://localhost:8025/readyz"]
43
+ interval: 5s
44
+ timeout: 5s
45
+ retries: 5
46
+
47
+ # Local SMS catcher (the "Mailpit for SMS"): the app posts outgoing SMS here via
48
+ # SMS_WEBHOOK_URL and tests read them over REST. Development only.
49
+ sms-sink:
50
+ image: node:20-alpine
51
+ profiles: ["dev"]
52
+ command: ["node", "/app/sms-sink.mjs"]
53
+ environment:
54
+ PORT: "8095"
55
+ ports:
56
+ - "${SMS_SINK_PORT:-8095}:8095"
57
+ volumes:
58
+ - ./sms-sink.mjs:/app/sms-sink.mjs:ro
59
+ healthcheck:
60
+ test: ["CMD", "wget", "-qO-", "http://localhost:8095/readyz"]
61
+ interval: 5s
62
+ timeout: 5s
63
+ retries: 5
64
+
28
65
  volumes:
29
66
  pgdata:
@@ -0,0 +1,40 @@
1
+ // Dev/test SMS sink — the "Mailpit for SMS". The app posts outgoing SMS here
2
+ // (via SMS_WEBHOOK_URL) instead of a real provider, and tests read them back
3
+ // over REST. Development only; never runs in production (see the `dev` profile
4
+ // in docker-compose.yml). Uses only Node built-ins, run on a stock node image.
5
+ import { createServer } from "node:http";
6
+
7
+ const PORT = Number(process.env.PORT ?? 8095);
8
+ /** @type {{to: string, body: string, receivedAt: string}[]} */
9
+ let messages = [];
10
+
11
+ const send = (res, status, body) => {
12
+ res.writeHead(status, { "content-type": "application/json" });
13
+ res.end(JSON.stringify(body));
14
+ };
15
+
16
+ createServer((req, res) => {
17
+ const { method, url } = req;
18
+ if (method === "GET" && url === "/readyz") return send(res, 200, { status: "ok" });
19
+ if (method === "GET" && url === "/messages") return send(res, 200, messages);
20
+ if (method === "DELETE" && url === "/messages") {
21
+ messages = [];
22
+ return send(res, 200, { cleared: true });
23
+ }
24
+ if (method === "POST" && url === "/sms") {
25
+ let raw = "";
26
+ req.on("data", (c) => (raw += c));
27
+ req.on("end", () => {
28
+ let payload = {};
29
+ try {
30
+ payload = raw ? JSON.parse(raw) : {};
31
+ } catch {
32
+ payload = { body: raw };
33
+ }
34
+ messages.unshift({ to: payload.to ?? payload.phoneNumber ?? "", body: payload.body ?? payload.code ?? raw, receivedAt: new Date().toISOString() });
35
+ send(res, 202, { accepted: true });
36
+ });
37
+ return;
38
+ }
39
+ send(res, 404, { error: "not found" });
40
+ }).listen(PORT, () => console.log(`[sms-sink] listening on ${PORT}`));
@@ -10,6 +10,6 @@ npm run test:e2e:report # open the HTML report
10
10
  ```
11
11
 
12
12
  The suite runs against a live stack (web on `E2E_BASE_URL`, default
13
- `http://localhost:5173`, proxying `/api/*` to the API). Start PostgreSQL, run the
13
+ `http://localhost:5001`, proxying `/api/*` to the API). Start PostgreSQL, run the
14
14
  auth migration, and start the API + web first. Files ending in `*.api.spec.ts` run
15
15
  in the request-only `api` project; `*.ui.spec.ts` run in the chromium `ui` project.
@@ -9,6 +9,7 @@
9
9
  "test:e2e:report": "playwright show-report"
10
10
  },
11
11
  "devDependencies": {
12
- "@playwright/test": "^1.61.1"
12
+ "@playwright/test": "^1.61.1",
13
+ "otpauth": "^9.5.1"
13
14
  }
14
15
  }
@@ -4,7 +4,7 @@ import { defineConfig, devices } from "@playwright/test";
4
4
  // the API). Start it first (see docs/testing.md) — locally via the dev harness,
5
5
  // in CI via scripts/e2e-ci.mjs. Two projects: `api` (request-only, backend e2e)
6
6
  // and `ui` (chromium, pages). Files are routed by suffix (*.api/*.ui.spec.ts).
7
- const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5173";
7
+ const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
8
8
 
9
9
  export default defineConfig({
10
10
  testDir: ".",
@@ -8,6 +8,9 @@ export type SessionUser = {
8
8
  image?: string | null;
9
9
  emailVerified?: boolean;
10
10
  twoFactorEnabled?: boolean | null;
11
+ username?: string | null;
12
+ phoneNumber?: string | null;
13
+ phoneNumberVerified?: boolean | null;
11
14
  };
12
15
 
13
16
  declare global {
@@ -6,12 +6,14 @@
6
6
  import UsersIcon from "@lucide/svelte/icons/users";
7
7
  import MonitorSmartphoneIcon from "@lucide/svelte/icons/monitor-smartphone";
8
8
  import ScrollTextIcon from "@lucide/svelte/icons/scroll-text";
9
+ import Building2Icon from "@lucide/svelte/icons/building-2";
10
+ import SettingsIcon from "@lucide/svelte/icons/settings";
9
11
  import { getI18n } from "$lib/i18n";
10
12
  import type { SessionUser } from "../../app.d.ts";
11
13
  import type { Messages } from "$lib/i18n/messages";
12
14
  import type { Component } from "svelte";
13
15
 
14
- let { user, capabilities }: { user: SessionUser; capabilities?: { auditLog?: boolean } } = $props();
16
+ let { user, capabilities }: { user: SessionUser; capabilities?: { auditLog?: boolean; organization?: boolean } } = $props();
15
17
  const i18n = getI18n();
16
18
 
17
19
  type NavItem = { href: string; key: keyof Messages["nav"]; icon: Component; adminOnly?: boolean };
@@ -19,9 +21,13 @@
19
21
  { href: "/admin", key: "overview", icon: LayoutDashboardIcon },
20
22
  { href: "/admin/users", key: "users", icon: UsersIcon, adminOnly: true },
21
23
  { href: "/admin/sessions", key: "sessions", icon: MonitorSmartphoneIcon, adminOnly: true },
24
+ ...(capabilities?.organization
25
+ ? [{ href: "/admin/organizations", key: "organizations", icon: Building2Icon, adminOnly: true } as NavItem]
26
+ : []),
22
27
  ...(capabilities?.auditLog
23
28
  ? [{ href: "/admin/audit", key: "audit", icon: ScrollTextIcon, adminOnly: true } as NavItem]
24
29
  : []),
30
+ { href: "/admin/settings", key: "settings", icon: SettingsIcon, adminOnly: true },
25
31
  ]);
26
32
  const visible = $derived(items.filter((item) => !item.adminOnly || user.role === "admin"));
27
33
  </script>