create-astram-app 0.1.2 → 0.2.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 (101) hide show
  1. package/dist/add-frontend.js +107 -0
  2. package/dist/add-frontend.js.map +1 -0
  3. package/dist/index.js +132 -33
  4. package/dist/index.js.map +1 -1
  5. package/package.json +3 -2
  6. package/template/CLAUDE.md +51 -0
  7. package/template/README.md +43 -44
  8. package/template/_gitignore +6 -0
  9. package/template/frontend/FRONTEND.md +92 -0
  10. package/template/frontend/README.md +16 -0
  11. package/template/frontend/_env.example +6 -0
  12. package/template/frontend/_gitignore +9 -0
  13. package/template/frontend/eslint.config.js +71 -0
  14. package/template/frontend/index.html +13 -0
  15. package/template/frontend/package.json +52 -0
  16. package/template/frontend/public/astram-logo-full.png +0 -0
  17. package/template/frontend/public/astram-logo-white.png +0 -0
  18. package/template/frontend/public/favicon.png +0 -0
  19. package/template/frontend/src/App.jsx +167 -0
  20. package/template/frontend/src/auth/AuthContext.jsx +338 -0
  21. package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
  22. package/template/frontend/src/components/EntityFormModal.jsx +154 -0
  23. package/template/frontend/src/components/EntityList.jsx +480 -0
  24. package/template/frontend/src/components/PageHeader.jsx +54 -0
  25. package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
  26. package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
  27. package/template/frontend/src/components/ui/alert.tsx +83 -0
  28. package/template/frontend/src/components/ui/avatar.tsx +51 -0
  29. package/template/frontend/src/components/ui/badge.tsx +56 -0
  30. package/template/frontend/src/components/ui/button.tsx +67 -0
  31. package/template/frontend/src/components/ui/card.tsx +92 -0
  32. package/template/frontend/src/components/ui/checkbox.tsx +30 -0
  33. package/template/frontend/src/components/ui/date-field.tsx +47 -0
  34. package/template/frontend/src/components/ui/dialog.tsx +214 -0
  35. package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
  36. package/template/frontend/src/components/ui/input-otp.tsx +81 -0
  37. package/template/frontend/src/components/ui/input.tsx +21 -0
  38. package/template/frontend/src/components/ui/label.tsx +46 -0
  39. package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
  40. package/template/frontend/src/components/ui/select.tsx +199 -0
  41. package/template/frontend/src/components/ui/separator.tsx +26 -0
  42. package/template/frontend/src/components/ui/sheet.tsx +139 -0
  43. package/template/frontend/src/components/ui/skeleton.tsx +13 -0
  44. package/template/frontend/src/components/ui/sonner.tsx +23 -0
  45. package/template/frontend/src/components/ui/table.tsx +181 -0
  46. package/template/frontend/src/components/ui/tooltip.tsx +59 -0
  47. package/template/frontend/src/config/branding.js +80 -0
  48. package/template/frontend/src/hooks/useMobile.tsx +21 -0
  49. package/template/frontend/src/index.css +265 -0
  50. package/template/frontend/src/lib/utils.js +7 -0
  51. package/template/frontend/src/main.jsx +27 -0
  52. package/template/frontend/src/pages/Dashboard.jsx +127 -0
  53. package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
  54. package/template/frontend/src/pages/Login.jsx +393 -0
  55. package/template/frontend/src/pages/ResetPassword.jsx +159 -0
  56. package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
  57. package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
  58. package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
  59. package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
  60. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
  61. package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
  62. package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
  63. package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
  64. package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
  65. package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
  66. package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
  67. package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
  68. package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
  69. package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
  70. package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
  71. package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
  72. package/template/frontend/src/pages/users/UserForm.jsx +167 -0
  73. package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
  74. package/template/frontend/src/platform/httpClient.js +125 -0
  75. package/template/frontend/src/platform/returnPathStore.js +43 -0
  76. package/template/frontend/src/platform/sessionStore.js +29 -0
  77. package/template/frontend/src/platform/shieldTransport.js +172 -0
  78. package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
  79. package/template/frontend/src/shell/AppShell.jsx +237 -0
  80. package/template/frontend/src/shell/Sidebar.jsx +257 -0
  81. package/template/frontend/src/shell/nav.js +90 -0
  82. package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
  83. package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
  84. package/template/frontend/tsconfig.json +22 -0
  85. package/template/frontend/vite.config.js +30 -0
  86. package/template/server/_env.example +45 -0
  87. package/template/server/eslint.config.js +95 -0
  88. package/template/server/package.json +49 -0
  89. package/template/server/scripts/dev.mjs +51 -0
  90. package/template/server/scripts/sync-astram-guide.mjs +63 -0
  91. package/template/_env.example +0 -33
  92. package/template/package.json +0 -37
  93. /package/template/{src → server/src}/app.module.ts +0 -0
  94. /package/template/{src → server/src}/features/_gitkeep +0 -0
  95. /package/template/{src → server/src}/health/demo.controller.ts +0 -0
  96. /package/template/{src → server/src}/health/health.controller.ts +0 -0
  97. /package/template/{src → server/src}/main.ts +0 -0
  98. /package/template/{test → server/test}/enforcement.e2e.spec.ts +0 -0
  99. /package/template/{tsconfig.build.json → server/tsconfig.build.json} +0 -0
  100. /package/template/{tsconfig.json → server/tsconfig.json} +0 -0
  101. /package/template/{vitest.config.ts → server/vitest.config.ts} +0 -0
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Sidebar — the collapsible icon-rail navigation. THEME-ADAPTIVE: a light-gray panel
3
+ * in light mode, deep navy in dark (driven by the `--sidebar*` tokens in index.css),
4
+ * so every surface/border/hover reads correctly in both themes. Desktop rail toggles
5
+ * w-64 ↔ w-14; mobile = drawer (AppShell handles the overlay).
6
+ *
7
+ * All colors come from `sidebar-*` tokens (bg-sidebar, text-sidebar-foreground,
8
+ * border-sidebar-border, bg-sidebar-accent, …) — NO hardcoded `white/x` literals, which
9
+ * only looked right on the old always-dark rail.
10
+ */
11
+ import { NavLink, useNavigate } from 'react-router-dom';
12
+ import { LogOut, Zap } from 'lucide-react';
13
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
14
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
15
+ import { cn } from '@/lib/utils';
16
+ import { branding } from '@/config/branding';
17
+ import { useAuth } from '@/auth/AuthContext';
18
+ import { NAV_SECTIONS } from './nav';
19
+
20
+ /** Two-letter initials from a display name (fallback 'U'), never a UUID. */
21
+ function initialsOf(name) {
22
+ if (!name) return 'U';
23
+ const clean = name.includes('@') ? name.split('@')[0] : name;
24
+ const parts = clean.trim().split(/[\s._-]+/).filter(Boolean);
25
+ const letters = parts.length >= 2 ? parts[0][0] + parts[1][0] : clean.slice(0, 2);
26
+ return letters.replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || 'U';
27
+ }
28
+
29
+ /**
30
+ * The signed-in user's profile card (OCA `.dash-profile-card` pattern): a CENTERED,
31
+ * vertical, CLICKABLE card — round avatar over name over role — that navigates to
32
+ * Account → Profile, with a hover tint and a bottom divider. Identity comes from the
33
+ * /auth/me profile (display NAMES, never UUIDs); falls back to the interim login hint.
34
+ * Collapses to just the avatar in the icon-rail (with a tooltip carrying the identity).
35
+ */
36
+ function UserCard({ collapsed, onNavigate }) {
37
+ const { profile, identityHint } = useAuth();
38
+ const navigate = useNavigate();
39
+ const name = profile?.name || (identityHint ? identityHint.split('@')[0] : 'Signed in');
40
+ const company = profile?.company?.name || '';
41
+ const role = profile?.roles?.[0]?.name || '';
42
+ const initials = initialsOf(profile?.name || identityHint);
43
+
44
+ const goProfile = () => {
45
+ navigate('/account/profile');
46
+ onNavigate?.();
47
+ };
48
+
49
+ if (collapsed) {
50
+ // Rail: just the avatar, tooltip carries the identity; still clicks to the profile.
51
+ return (
52
+ <div className="flex justify-center border-b border-sidebar-border py-3">
53
+ <Tooltip>
54
+ <TooltipTrigger asChild>
55
+ <button className="rounded-lg" aria-label="Account" onClick={goProfile}>
56
+ <Avatar className="size-9 shrink-0 rounded-full ring-1 ring-sidebar-border">
57
+ <AvatarFallback className="rounded-full bg-primary/15 text-[11px] font-semibold text-primary">
58
+ {initials}
59
+ </AvatarFallback>
60
+ </Avatar>
61
+ </button>
62
+ </TooltipTrigger>
63
+ <TooltipContent side="right" className="text-xs">
64
+ <div className="font-semibold">{name}</div>
65
+ {company && <div className="opacity-70">{company}</div>}
66
+ {role && <div className="opacity-70">{role}</div>}
67
+ </TooltipContent>
68
+ </Tooltip>
69
+ </div>
70
+ );
71
+ }
72
+
73
+ return (
74
+ <button
75
+ type="button"
76
+ onClick={goProfile}
77
+ aria-label="Open profile"
78
+ className="flex w-full flex-col items-center gap-1.5 border-b border-sidebar-border px-3 py-3.5 text-center transition-colors hover:bg-sidebar-accent"
79
+ >
80
+ {/* Big round avatar (OCA 56px). Brand-tinted fallback with the initials. */}
81
+ <Avatar className="size-14 rounded-full ring-1 ring-sidebar-border">
82
+ <AvatarFallback className="rounded-full bg-primary/15 text-base font-semibold text-primary">
83
+ {initials}
84
+ </AvatarFallback>
85
+ </Avatar>
86
+ <span className="mt-0.5 truncate text-sm font-semibold text-sidebar-foreground">{name}</span>
87
+ {company && (
88
+ <span className="truncate text-[11px] text-sidebar-foreground/60">{company}</span>
89
+ )}
90
+ {role && (
91
+ <span className="truncate font-mono text-[0.6rem] uppercase tracking-[0.08em] text-sidebar-foreground/50">
92
+ {role}
93
+ </span>
94
+ )}
95
+ </button>
96
+ );
97
+ }
98
+
99
+ function BrandTile({ collapsed }) {
100
+ return (
101
+ <div
102
+ className={cn(
103
+ // Logo is CENTER-aligned in both states (collapsed rail + expanded).
104
+ 'flex items-center justify-center border-b border-sidebar-border py-3.5',
105
+ collapsed ? 'px-0' : 'px-4',
106
+ )}
107
+ >
108
+ {collapsed ? (
109
+ // The rail is too narrow for the wordmark — show the compact accent mark.
110
+ <div className="accent-glow flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary">
111
+ <Zap className="size-4 text-white" />
112
+ </div>
113
+ ) : (
114
+ // Theme-adaptive logo: the COLOR logo on the light rail, the WHITE variant on
115
+ // the dark rail (the sidebar surface now follows the theme).
116
+ <>
117
+ <img src={branding.logo.light} alt={branding.appName} className="h-7 w-auto dark:hidden" />
118
+ <img
119
+ src={branding.logo.dark}
120
+ alt={branding.appName}
121
+ className="hidden h-7 w-auto dark:block"
122
+ />
123
+ </>
124
+ )}
125
+ </div>
126
+ );
127
+ }
128
+
129
+ function Item({ item, collapsed, onNavigate }) {
130
+ const Icon = item.icon;
131
+ // Use NavLink's render-prop CHILDREN form (bulletproof across versions) and apply
132
+ // the active styling to an INNER element — avoids relying on the className-function
133
+ // form (which was rendering as a literal string in this setup).
134
+ const link = (
135
+ // `end` for exact-match items; items with `matchPrefix` (e.g. Account, whose real
136
+ // routes are /account/*) stay active across all their sub-routes.
137
+ <NavLink to={item.path} end={!item.matchPrefix} onClick={onNavigate} className="block">
138
+ {({ isActive }) => (
139
+ <span
140
+ className={cn(
141
+ 'group flex min-h-9 items-center rounded-lg text-sm font-medium transition-all',
142
+ collapsed ? 'mx-auto size-9 justify-center' : 'gap-3 px-2.5 py-2',
143
+ isActive
144
+ ? 'bg-primary text-white shadow-lg shadow-primary/25'
145
+ : 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground',
146
+ )}
147
+ >
148
+ <Icon className="size-4 shrink-0" />
149
+ {!collapsed && <span className="truncate">{item.label}</span>}
150
+ {!collapsed && item.badge && (
151
+ <span
152
+ className={cn(
153
+ 'ml-auto rounded border px-1.5 py-px text-[10px] font-semibold tracking-wide',
154
+ // Themed badge — reads on both the light and dark rail.
155
+ isActive
156
+ ? 'border-white/30 bg-white/20 text-white'
157
+ : 'border-primary/40 bg-primary/10 text-primary',
158
+ )}
159
+ >
160
+ {item.badge}
161
+ </span>
162
+ )}
163
+ </span>
164
+ )}
165
+ </NavLink>
166
+ );
167
+
168
+ if (!collapsed) return link;
169
+ return (
170
+ <Tooltip>
171
+ <TooltipTrigger asChild>{link}</TooltipTrigger>
172
+ <TooltipContent side="right" className="flex items-center gap-2 text-xs">
173
+ {item.label}
174
+ {item.badge && <span className="opacity-60">{item.badge}</span>}
175
+ </TooltipContent>
176
+ </Tooltip>
177
+ );
178
+ }
179
+
180
+ export default function Sidebar({ collapsed, onLogout, onNavigate, mobile = false }) {
181
+ const isRail = collapsed && !mobile;
182
+ // Phase E slice 1: `can` (not the `useCan` hook) is used here because it's a plain
183
+ // function applied inside a `.filter()` over a static per-render array — calling a
184
+ // HOOK conditionally per array item would violate the Rules of Hooks. One hook call
185
+ // (`useAuth()`) up front, then `can(item.gate)` per item is safe. An item with no
186
+ // `gate` (Dashboard/Notes/Cross-tenant/Account) is never filtered — only admin nav
187
+ // items declare a gate. This is a UX convenience (hide items the caller can't use);
188
+ // the server's decide() on each page's list route remains the real enforcement.
189
+ const { can } = useAuth();
190
+ const visibleSections = NAV_SECTIONS.map((section) => ({
191
+ ...section,
192
+ items: section.items.filter((item) => !item.gate || can(item.gate)),
193
+ })).filter((section) => section.items.length > 0);
194
+
195
+ return (
196
+ <TooltipProvider delayDuration={0}>
197
+ <div
198
+ className={cn(
199
+ 'sidebar-panel flex h-full flex-col transition-[width] duration-200',
200
+ mobile ? 'w-64' : isRail ? 'w-14' : 'w-64',
201
+ )}
202
+ >
203
+ <BrandTile collapsed={isRail} />
204
+
205
+ {/* Signed-in user profile card (clickable → Account → Profile) — collapses to
206
+ just the avatar in the icon-rail. */}
207
+ <UserCard collapsed={isRail} onNavigate={onNavigate} />
208
+
209
+ <nav className="flex-1 space-y-4 overflow-y-auto px-2 py-3">
210
+ {visibleSections.map((section) => (
211
+ <div key={section.label}>
212
+ {!isRail && (
213
+ <p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-widest text-sidebar-foreground/40">
214
+ {section.label}
215
+ </p>
216
+ )}
217
+ <div className="space-y-0.5">
218
+ {section.items.map((item) => (
219
+ <Item key={item.label} item={item} collapsed={isRail} onNavigate={onNavigate} />
220
+ ))}
221
+ </div>
222
+ </div>
223
+ ))}
224
+ </nav>
225
+
226
+ {/* Bottom: sign out (replaces the old collapse button — collapse now lives
227
+ in the header beside the breadcrumb). */}
228
+ <div className="border-t border-sidebar-border p-2">
229
+ {(() => {
230
+ const btn = (
231
+ <button
232
+ onClick={onLogout}
233
+ className={cn(
234
+ 'flex w-full items-center rounded-lg text-sm font-medium text-sidebar-foreground/60 transition-colors hover:bg-destructive/10 hover:text-destructive',
235
+ isRail ? 'mx-auto size-9 justify-center' : 'gap-3 px-2.5 py-2',
236
+ )}
237
+ aria-label="Sign out"
238
+ >
239
+ <LogOut className="size-4 shrink-0" />
240
+ {!isRail && <span>Sign out</span>}
241
+ </button>
242
+ );
243
+ if (!isRail) return btn;
244
+ return (
245
+ <Tooltip>
246
+ <TooltipTrigger asChild>{btn}</TooltipTrigger>
247
+ <TooltipContent side="right" className="text-xs">
248
+ Sign out
249
+ </TooltipContent>
250
+ </Tooltip>
251
+ );
252
+ })()}
253
+ </div>
254
+ </div>
255
+ </TooltipProvider>
256
+ );
257
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The app navigation — a plain static menu (no role/redux coupling). Each item has
3
+ * an icon, label, path, and optional badge. Grouped into labelled sections.
4
+ *
5
+ * `gate` (Phase E slice 1): an optional permission KEY the item is hidden without. The
6
+ * Sidebar filters items via `useCan(item.gate)` — reading the `@astram/sdk` auth-facts
7
+ * projection (a UX convenience: hiding an unusable nav entry; NEVER the security
8
+ * boundary — the server's decide() on the underlying @Classified route is the real
9
+ * enforcement regardless of what the nav shows). Every `gate` value below was VERIFIED
10
+ * against the actual `@Classified({ typeKey, action })` on that page's list route (not
11
+ * guessed):
12
+ * - Companies → `identity:company:read` (identity.controller.ts `GET companies`)
13
+ * - Users → `identity:user:read` (identity.controller.ts `GET users`)
14
+ * - Roles → `identity:role:read` (identity.controller.ts `GET roles`)
15
+ * - Access → `identity:role:read` (grant-admin.controller.ts backs this page
16
+ * via `GET access/role-grants` + `GET permission-catalog`, BOTH gated
17
+ * `role:read` — "managing/reading grants IS role administration")
18
+ * - Audit → `identity:role:read` (audit.controller.ts `GET audit`)
19
+ * Dashboard / Account are left UNGATED — self/owned surfaces every authenticated
20
+ * caller may reach (no admin-only list route backs them).
21
+ */
22
+ import {
23
+ Building2,
24
+ History,
25
+ KeyRound,
26
+ LayoutDashboard,
27
+ ShieldCheck,
28
+ UserCog,
29
+ Users,
30
+ } from 'lucide-react';
31
+
32
+ export const NAV_SECTIONS = [
33
+ {
34
+ label: 'Overview',
35
+ items: [{ icon: LayoutDashboard, label: 'Dashboard', path: '/dashboard', badge: null }],
36
+ },
37
+ {
38
+ label: 'Identity',
39
+ items: [
40
+ {
41
+ icon: Building2,
42
+ label: 'Companies',
43
+ path: '/companies',
44
+ badge: null,
45
+ matchPrefix: true,
46
+ gate: 'identity:company:read',
47
+ },
48
+ {
49
+ icon: Users,
50
+ label: 'Users',
51
+ path: '/users',
52
+ badge: null,
53
+ matchPrefix: true,
54
+ gate: 'identity:user:read',
55
+ },
56
+ {
57
+ icon: ShieldCheck,
58
+ label: 'Roles',
59
+ path: '/roles',
60
+ badge: null,
61
+ matchPrefix: true,
62
+ gate: 'identity:role:read',
63
+ },
64
+ {
65
+ icon: KeyRound,
66
+ label: 'Access',
67
+ path: '/access',
68
+ badge: null,
69
+ matchPrefix: true,
70
+ gate: 'identity:role:read',
71
+ },
72
+ {
73
+ icon: History,
74
+ label: 'Audit',
75
+ path: '/audit',
76
+ badge: null,
77
+ matchPrefix: true,
78
+ gate: 'identity:role:read',
79
+ },
80
+ ],
81
+ },
82
+ {
83
+ label: 'Settings',
84
+ // `/account` redirects to `/account/profile`; `matchPrefix` keeps the nav item
85
+ // highlighted across ALL /account/* sub-tabs (profile / password / 2fa).
86
+ items: [
87
+ { icon: UserCog, label: 'Account', path: '/account', badge: null, matchPrefix: true },
88
+ ],
89
+ },
90
+ ];
@@ -0,0 +1,14 @@
1
+ /**
2
+ * ThemeProvider — light/dark/system via next-themes (OCA-style dual theme).
3
+ * Stamps `.dark` on <html>; the tokens in index.css do the rest. `attribute="class"`
4
+ * matches the `@custom-variant dark (&:is(.dark *))` in index.css.
5
+ */
6
+ import { ThemeProvider as NextThemes } from 'next-themes';
7
+
8
+ export function ThemeProvider({ children }) {
9
+ return (
10
+ <NextThemes attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
11
+ {children}
12
+ </NextThemes>
13
+ );
14
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * ThemeToggle — an OCA-style Light/Dark/System segmented control (in the user menu
3
+ * or standalone). Uses next-themes; the active segment is highlighted.
4
+ */
5
+ import { useEffect, useState } from 'react';
6
+ import { useTheme } from 'next-themes';
7
+ import { Monitor, Moon, Sun } from 'lucide-react';
8
+ import { cn } from '@/lib/utils';
9
+
10
+ const OPTIONS = [
11
+ { value: 'light', icon: Sun, label: 'Light' },
12
+ { value: 'dark', icon: Moon, label: 'Dark' },
13
+ { value: 'system', icon: Monitor, label: 'System' },
14
+ ];
15
+
16
+ export default function ThemeToggle({ compact = false }) {
17
+ const { theme, setTheme } = useTheme();
18
+ const [mounted, setMounted] = useState(false);
19
+ useEffect(() => setMounted(true), []);
20
+ const active = mounted ? theme : 'system';
21
+
22
+ return (
23
+ <div className="inline-flex items-center gap-0.5 rounded-lg border border-border bg-muted/50 p-0.5">
24
+ {OPTIONS.map(({ value, icon: Icon, label }) => (
25
+ <button
26
+ key={value}
27
+ type="button"
28
+ onClick={() => setTheme(value)}
29
+ aria-label={label}
30
+ title={label}
31
+ className={cn(
32
+ 'flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors',
33
+ active === value
34
+ ? 'bg-background text-foreground shadow-sm'
35
+ : 'text-muted-foreground hover:text-foreground',
36
+ )}
37
+ >
38
+ <Icon className="size-3.5" />
39
+ {!compact && <span>{label}</span>}
40
+ </button>
41
+ ))}
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "allowJs": true,
15
+ "strict": false,
16
+ "baseUrl": ".",
17
+ "paths": {
18
+ "@/*": ["./src/*"]
19
+ }
20
+ },
21
+ "include": ["src"]
22
+ }
@@ -0,0 +1,30 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import tailwindcss from '@tailwindcss/vite';
4
+ import { fileURLToPath, URL } from 'node:url';
5
+
6
+ /**
7
+ * __APP_NAME__ frontend — Vite config (shadcn/ui + Tailwind v4).
8
+ *
9
+ * `/api` is proxied to the framework server (default :3000), stripping the `/api`
10
+ * prefix so the browser calls same-origin `/api/auth/login` while the server sees
11
+ * `/auth/login`. `@` aliases `src/` (the shadcn import convention).
12
+ */
13
+ export default defineConfig({
14
+ plugins: [react(), tailwindcss()],
15
+ resolve: {
16
+ alias: {
17
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
18
+ },
19
+ },
20
+ server: {
21
+ port: 5173,
22
+ proxy: {
23
+ '/api': {
24
+ target: process.env.API_URL ?? 'http://localhost:3000',
25
+ changeOrigin: true,
26
+ rewrite: (path) => path.replace(/^\/api/, ''),
27
+ },
28
+ },
29
+ },
30
+ });
@@ -0,0 +1,45 @@
1
+ # __APP_NAME__ — environment configuration (references only, no secrets).
2
+ #
3
+ # Every value below has a safe default, so the app boots with NO .env file at all.
4
+ # Copy this file to `.env` and override only what you need. `.env` is gitignored.
5
+ # These are read by `loadAppConfig()` from the installed @astram/guard framework.
6
+
7
+ # HTTP port the app listens on (default: 3000).
8
+ PORT=3000
9
+
10
+ # Runtime environment: development | production | test (default: development).
11
+ NODE_ENV=development
12
+
13
+ # --- ADR-0018 connection-pool budget (all optional; framework defaults apply) ---
14
+ # Uncomment + tune only if your deployment needs to.
15
+ # POOL_WORKERS=1
16
+ # APP_DB_MAX_CONNECTIONS=50
17
+ # POOL_HEADROOM=5
18
+ # POOL_MAX_CORE=10
19
+ # POOL_MAX_APP_CRUD=10
20
+ # POOL_MAX_SCHEMA_SVC=5
21
+
22
+ # --- OPTIONAL: real authentication (only if you set ShieldModule.forRoot({ auth: true })) ---
23
+ # Auth needs a live MariaDB + Redis and a refresh-token HMAC key. These are
24
+ # REQUIRED once auth is enabled (the app fails closed without the HMAC key) — but
25
+ # irrelevant (unread) while auth is off.
26
+ #
27
+ # Runtime app DB account (DML — the running server uses this):
28
+ # APP_DB_HOST=127.0.0.1
29
+ # APP_DB_PORT=3306
30
+ # APP_DB_USER=shield_core
31
+ # APP_DB_PASSWORD=change-me
32
+ # APP_DB_NAME=shield_identity
33
+ #
34
+ # Deploy-time DDL account (CREATE/ALTER/DROP — used only by `npm run migration:run`,
35
+ # NEVER by the running server; least-privilege). Host/port/db inherit APP_DB_* above.
36
+ # DDL_DB_USER=shield_migrate
37
+ # DDL_DB_PASSWORD=change-me
38
+ #
39
+ # Redis + refresh-token secret:
40
+ # REDIS_URL=redis://127.0.0.1:6379
41
+ # REFRESH_HMAC_KEY=change-me-to-a-long-random-secret # REQUIRED when auth is on
42
+
43
+ # --- Migrations (optional; @astram/identity's migrations run automatically) ---
44
+ # APP_MIGRATION_DIR=migrations # your app's own migrations, run after identity's
45
+ # APP_MIGRATION_TABLE=schema_migrations
@@ -0,0 +1,95 @@
1
+ // ESLint flat config for an Astram consumer app.
2
+ //
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // THE PHYSICAL WALL — backstop (do not remove).
5
+ //
6
+ // The Astram paved road is the ONE way features touch data: inject the framework's
7
+ // RecordEngine (from @astram/engine) and go through the pipe (permission gate +
8
+ // audit are mandatory, cross-tenant isolation is enforced). A feature must NEVER
9
+ // reach the database directly — a raw mariadb/typeorm pool, a DataSource, a
10
+ // QueryRunner, or a repository `.save()`/`.query()` skips the security layer
11
+ // entirely (this is exactly how astram-org's ORS got bypassed 165 times).
12
+ //
13
+ // The PRIMARY wall is structural: the framework never hands feature scope a pool
14
+ // (EngineModule seals it). THIS lint rule is the BACKSTOP: it fails the build if a
15
+ // file under `src/features/**` imports a raw DB symbol or calls a raw DB method, so
16
+ // an off-road attempt is a red build, not a silent success.
17
+ //
18
+ // If you hit this rule because the pipe can't do what you need: STOP. Do NOT work
19
+ // around it. Record the gap in `.astram/guide/gaps.md` and raise it with the
20
+ // framework — the capability must be added to @astram/engine (the gap-flag loop).
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+
23
+ import js from '@eslint/js';
24
+ import tseslint from 'typescript-eslint';
25
+
26
+ /** Raw DB packages a feature must never import directly. */
27
+ const FORBIDDEN_DB_IMPORTS = [
28
+ { name: 'mariadb', message: 'Wall: features never open a DB pool — use the RecordEngine (@astram/engine).' },
29
+ { name: 'mysql', message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).' },
30
+ { name: 'mysql2', message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).' },
31
+ { name: 'typeorm', message: 'Wall: no ORM in features — use the RecordEngine (@astram/engine).' },
32
+ { name: '@nestjs/typeorm', message: 'Wall: no ORM in features — use the RecordEngine (@astram/engine).' },
33
+ { name: 'pg', message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).' },
34
+ { name: 'knex', message: 'Wall: no query builder in features — use the RecordEngine (@astram/engine).' },
35
+ ];
36
+
37
+ /**
38
+ * Named imports that hand a feature raw DB access even from an ALLOWED package.
39
+ * `SHIELD_DB_POOL` is exported from `@astram/guard` (a package a feature legitimately
40
+ * imports for other reasons), so it must be blocked by NAME on that path — the engine
41
+ * owns the pool; feature scope must never import the pool token.
42
+ */
43
+ const FORBIDDEN_DB_NAMED_FROM = [
44
+ {
45
+ name: '@astram/guard',
46
+ importNames: ['SHIELD_DB_POOL'],
47
+ message: 'Wall: the DB pool is sealed inside the engine — features use the RecordEngine (@astram/engine).',
48
+ },
49
+ ];
50
+
51
+ export default tseslint.config(
52
+ js.configs.recommended,
53
+ ...tseslint.configs.recommended,
54
+ {
55
+ ignores: ['dist/**', 'node_modules/**'],
56
+ },
57
+ // ── The wall: scoped to feature code ──────────────────────────────────────
58
+ {
59
+ files: ['src/features/**/*.ts'],
60
+ rules: {
61
+ 'no-restricted-imports': [
62
+ 'error',
63
+ {
64
+ paths: [...FORBIDDEN_DB_IMPORTS, ...FORBIDDEN_DB_NAMED_FROM],
65
+ patterns: [
66
+ {
67
+ group: ['**/adapters/mariadb-*', '@astram/engine/dist/adapters/*'],
68
+ message:
69
+ 'Wall: the engine adapters (pool/uow/audit) are framework-internal — features use the RecordEngine facade only.',
70
+ },
71
+ ],
72
+ },
73
+ ],
74
+ // Block raw query-runner / repository calls even if a pool were obtained.
75
+ 'no-restricted-syntax': [
76
+ 'error',
77
+ {
78
+ selector:
79
+ "CallExpression[callee.property.name='createQueryRunner']",
80
+ message: 'Wall: no QueryRunner in features — use the RecordEngine (@astram/engine).',
81
+ },
82
+ {
83
+ selector:
84
+ "CallExpression[callee.property.name='getRepository']",
85
+ message: 'Wall: no repository access in features — use the RecordEngine (@astram/engine).',
86
+ },
87
+ {
88
+ selector:
89
+ "CallExpression[callee.property.name='createPool']",
90
+ message: 'Wall: features never create a DB pool — use the RecordEngine (@astram/engine).',
91
+ },
92
+ ],
93
+ },
94
+ },
95
+ );
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "description": "The server (API) half of an Astram consumer app. Depends on the immutable framework (@astram/core, @astram/guard, @astram/engine, @astram/sdk) and declares enforcement via ShieldModule.forRoot(). Features go in src/features/; the UI lives in ../frontend.",
6
+ "type": "commonjs",
7
+ "main": "dist/main.js",
8
+ "scripts": {
9
+ "prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
10
+ "build": "tsc -p tsconfig.build.json",
11
+ "start": "node --env-file-if-exists=.env dist/main.js",
12
+ "dev": "node scripts/dev.mjs",
13
+ "test": "vitest run",
14
+ "migration:run": "node --env-file-if-exists=.env node_modules/@astram/migrate/dist/cli.js migrate",
15
+ "migration:status": "node --env-file-if-exists=.env node_modules/@astram/migrate/dist/cli.js migrate:status",
16
+ "migration:revert": "node --env-file-if-exists=.env node_modules/@astram/migrate/dist/cli.js migrate:revert",
17
+ "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
18
+ "guide:sync": "node scripts/sync-astram-guide.mjs",
19
+ "postinstall": "node scripts/sync-astram-guide.mjs"
20
+ },
21
+ "dependencies": {
22
+ "@astram/core": "^1.0.0",
23
+ "@astram/engine": "^0.1.2",
24
+ "@astram/guard": "^0.4.1",
25
+ "@astram/guide": "^0.1.0",
26
+ "@astram/identity": "^0.1.2",
27
+ "@astram/migrate": "^0.1.1",
28
+ "@astram/sdk": "^0.2.0",
29
+ "@nestjs/common": "^11.0.0",
30
+ "@nestjs/core": "^11.0.0",
31
+ "@nestjs/platform-express": "^11.0.0",
32
+ "reflect-metadata": "^0.2.2",
33
+ "rxjs": "^7.8.1"
34
+ },
35
+ "devDependencies": {
36
+ "@eslint/js": "^9.0.0",
37
+ "@nestjs/testing": "^11.0.0",
38
+ "@types/express": "^5.0.0",
39
+ "@types/node": "^22.0.0",
40
+ "@types/supertest": "^6.0.2",
41
+ "eslint": "^9.0.0",
42
+ "supertest": "^7.0.0",
43
+ "tsx": "^4.19.2",
44
+ "typescript": "^5.7.0",
45
+ "typescript-eslint": "^8.0.0",
46
+ "unplugin-swc": "^1.5.1",
47
+ "vitest": "^2.1.8"
48
+ }
49
+ }