create-feltkit 0.3.1 → 0.3.3

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 (40) hide show
  1. package/README.md +21 -3
  2. package/bin/create.js +30 -10
  3. package/package.json +3 -2
  4. package/scaffold.d.ts +7 -2
  5. package/scaffold.js +45 -9
  6. package/template/README.md +318 -0
  7. package/template/feltkit/_generated/api.ts +2 -1
  8. package/template/feltkit/app.ts +27 -10
  9. package/template/feltkit/inbox.ts +21 -0
  10. package/template/feltkit/projects.ts +19 -0
  11. package/template/feltkit/tasks.ts +11 -1
  12. package/template/index.html +4 -1
  13. package/template/package.json +6 -1
  14. package/template/public/apple-touch-icon.png +0 -0
  15. package/template/public/favicon.png +0 -0
  16. package/template/public/feltkit-mark.png +0 -0
  17. package/template/public/theme.js +7 -0
  18. package/template/src/App.tsx +31 -7
  19. package/template/src/styles.css +31 -31
  20. package/template/src/theme.tsx +58 -0
  21. package/template/tests/tasks.test.ts +42 -0
  22. package/template-managed/.dockerignore +7 -0
  23. package/template-managed/Dockerfile +24 -0
  24. package/template-managed/README.md +222 -0
  25. package/template-managed/_gitignore +7 -0
  26. package/template-managed/docker-compose.yml +17 -0
  27. package/template-managed/feltdb.contract.json +1102 -0
  28. package/template-managed/feltkit/_generated/api.ts +9 -0
  29. package/template-managed/feltkit/app.ts +40 -0
  30. package/template-managed/feltkit/items.ts +55 -0
  31. package/template-managed/feltkit/system.ts +14 -0
  32. package/template-managed/fly.toml +28 -0
  33. package/template-managed/package.json +35 -0
  34. package/template-managed/scripts/deploy-fly.mjs +54 -0
  35. package/template-managed/scripts/managed-env.mjs +103 -0
  36. package/template-managed/tests/config.test.ts +91 -0
  37. package/template-managed/tests/inventory.test.ts +69 -0
  38. package/template-managed/tests/managed.live.test.ts +75 -0
  39. package/template-managed/tsconfig.json +15 -0
  40. package/template/public/favicon.svg +0 -1
@@ -45,6 +45,25 @@ export const rename = app.mutation({
45
45
  },
46
46
  });
47
47
 
48
+ /**
49
+ * Removes completed tasks (and their notes) from one project, or from every project. Runs as
50
+ * the background job "projects.clear-completed" (services.jobs.handlers in app.ts), queued
51
+ * from Studio → Services → Jobs or on a schedule, as the person who queued it.
52
+ */
53
+ export const clearCompleted = app.mutation({
54
+ access: "projects.write",
55
+ args: { projectId: s.optional(s.string()) },
56
+ returns: s.object({ removed: s.integer() }),
57
+ handler: async (ctx, { projectId }) => {
58
+ const done = await ctx.db.find(tasks, projectId ? { projectId, done: true } : { done: true });
59
+ for (const task of done) {
60
+ for (const file of await ctx.services.files.list({ metadata: { taskId: task.id } })) await ctx.services.files.remove(file.id);
61
+ await ctx.db.delete(tasks, task.id);
62
+ }
63
+ return { removed: done.length };
64
+ },
65
+ });
66
+
48
67
  /** Deletes a project and all of its tasks in one atomic commit. */
49
68
  export const remove = app.mutation({
50
69
  access: "projects.write",
@@ -33,7 +33,7 @@ export const create = app.mutation({
33
33
  capability: { name: "tasks.create", description: "Add a task to a project." },
34
34
  handler: async (ctx, { projectId, title, details }) => {
35
35
  if (!(await ctx.db.get(projects, projectId))) fail("NOT_FOUND", "That project does not exist.");
36
- return ctx.db.insert(tasks, {
36
+ const id = await ctx.db.insert(tasks, {
37
37
  projectId,
38
38
  title: title.trim(),
39
39
  ...(details?.trim() ? { details: details.trim() } : {}),
@@ -41,6 +41,9 @@ export const create = app.mutation({
41
41
  createdAt: Date.now(),
42
42
  createdBy: ctx.auth.requirePrincipal().id,
43
43
  });
44
+ // Delivered, signed, to every webhook endpoint registered for "task.created" (Studio → Services → Webhooks).
45
+ await ctx.services.webhooks?.emit("task.created", { id, projectId, title: title.trim() });
46
+ return id;
44
47
  },
45
48
  });
46
49
 
@@ -63,6 +66,13 @@ export const setDone = app.mutation({
63
66
  handler: async (ctx, { id, done }) => {
64
67
  const who = ctx.auth.requirePrincipal().id;
65
68
  const task = await ctx.db.patch(tasks, id, done ? { done: true, completedAt: Date.now(), completedBy: who } : { done: false, completedAt: undefined, completedBy: undefined });
69
+ if (done) {
70
+ await ctx.services.webhooks?.emit("task.completed", { id: task.id, projectId: task.projectId, completedBy: who });
71
+ // Tell whoever created the task, when someone else completes it.
72
+ if (task.createdBy !== who) {
73
+ await ctx.services.notifications.send({ recipient: task.createdBy, type: "task.completed", title: `${who} completed "${task.title}"`, data: { taskId: task.id }, idempotencyKey: `task.completed:${task.id}:${task.completedAt}` });
74
+ }
75
+ }
66
76
  return { id: task.id, done: task.done };
67
77
  },
68
78
  });
@@ -4,8 +4,11 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <meta name="color-scheme" content="light dark" />
7
- <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
7
+ <link rel="icon" href="/favicon.png" type="image/png" />
8
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
8
9
  <title>Tasks · FeltKit</title>
10
+ <!-- The saved light/dark choice, applied before first paint (src/theme.tsx). -->
11
+ <script vite-ignore src="/theme.js"></script>
9
12
  </head>
10
13
  <body>
11
14
  <div id="root"></div>
@@ -13,7 +13,7 @@
13
13
  "typecheck": "tsc --noEmit"
14
14
  },
15
15
  "dependencies": {
16
- "@feltdb/feltkit": "0.3.1",
16
+ "@feltdb/feltkit": "0.3.3",
17
17
  "react": "19.2.0",
18
18
  "react-dom": "19.2.0"
19
19
  },
@@ -24,5 +24,10 @@
24
24
  "@vitejs/plugin-react": "5.1.0",
25
25
  "typescript": "5.9.3",
26
26
  "vite": "7.3.7"
27
+ },
28
+ "overrides": {
29
+ "@appport/services": {
30
+ "@feltdb/core": "0.16.1"
31
+ }
27
32
  }
28
33
  }
Binary file
Binary file
@@ -0,0 +1,7 @@
1
+ // Applies the saved theme before the app renders, so there is no light flash in dark mode.
2
+ // Same key and values as src/theme.tsx. A file, not an inline script, so it also works under
3
+ // a Content-Security-Policy that allows only same-origin scripts.
4
+ try {
5
+ var theme = localStorage.getItem("app:theme");
6
+ if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
7
+ } catch (e) {}
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
2
- import { useConnectionState, useMutation, useQueryState, useSession, type FeltKitClientError } from "@feltdb/feltkit/react";
2
+ import { useConnectionState, useFeltKit, useMutation, useQueryState, useSession, type FeltKitClientError } from "@feltdb/feltkit/react";
3
3
  import { api } from "../feltkit/_generated/api";
4
+ import { ThemeToggle } from "./theme";
4
5
 
5
6
  type Notice = { tone: "error" | "info"; text: string } | null;
6
7
 
@@ -47,11 +48,7 @@ export function App() {
47
48
  <div className="shell">
48
49
  <header className="topbar">
49
50
  <div className="brand">
50
- <span className="mark" aria-hidden>
51
- <svg viewBox="0 0 32 32" width="22" height="22">
52
- <path d="M11 7c-4 5-4 13 0 18M21 7c4 5 4 13 0 18" stroke="currentColor" strokeWidth="3" fill="none" strokeLinecap="round" />
53
- </svg>
54
- </span>
51
+ <img className="mark" src="/feltkit-mark.png" width="30" height="30" alt="" />
55
52
  <span className="brand-name">Tasks</span>
56
53
  {session && <span className={`env ${session.environment.production ? "env-prod" : ""}`}>{session.environment.name}</span>}
57
54
  </div>
@@ -76,6 +73,7 @@ export function App() {
76
73
  </select>
77
74
  </label>
78
75
  )}
76
+ <ThemeToggle />
79
77
  {session?.dashboard && (
80
78
  <a className="dashboard-link" href={session.dashboard} target="_blank" rel="noreferrer">
81
79
  Dashboard ↗
@@ -125,13 +123,39 @@ function SignedOut({ devUsers, onSignIn }: { devUsers: Array<{ id: string; name:
125
123
  </div>
126
124
  </>
127
125
  ) : (
128
- <p className="muted">This environment has no development sign-in. Configure auth.authenticate in feltkit/app.ts.</p>
126
+ <TokenSignIn />
129
127
  )}
130
128
  </div>
131
129
  </div>
132
130
  );
133
131
  }
134
132
 
133
+ /**
134
+ * Production has no development sign-in. Until auth.authenticate in feltkit/app.ts verifies your
135
+ * users with your identity provider, sign in with a session token created on the server.
136
+ */
137
+ function TokenSignIn() {
138
+ const client = useFeltKit();
139
+ const [token, setToken] = useState("");
140
+ const submit = (e: FormEvent) => {
141
+ e.preventDefault();
142
+ if (token.trim()) client.setToken(token.trim());
143
+ };
144
+ return (
145
+ <form className="stack" onSubmit={submit}>
146
+ <p className="muted">
147
+ Sign in with a session token. Create one on the server (with the app stopped):
148
+ </p>
149
+ <code className="block">npx feltkit session create --env production --type user --id you --role member</code>
150
+ <input type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="Session token (fks_…)" aria-label="Session token" autoComplete="off" />
151
+ <button type="submit" disabled={!token.trim()}>
152
+ Sign in
153
+ </button>
154
+ <p className="muted small-text">To sign people in with your own login, add auth.authenticate in feltkit/app.ts.</p>
155
+ </form>
156
+ );
157
+ }
158
+
135
159
  function Workspace({ canWrite, canComplete, role, onError }: { canWrite: boolean; canComplete: boolean; role: string; onError: (e: unknown) => void }) {
136
160
  const projects = useQueryState(api.projects.list);
137
161
  const [selected, setSelected] = useState<string | null>(null);
@@ -1,16 +1,22 @@
1
1
  :root {
2
- --bg: #f7f7f5;
3
- --surface: #ffffff;
4
- --ink: #1b1d1c;
5
- --muted: #6b706d;
6
- --line: #e4e5e1;
7
- --accent: #166534;
8
- --accent-ink: #ffffff;
9
- --accent-soft: #e8f3ec;
10
- --danger: #b42318;
11
- --warn: #92400e;
2
+ /* Light or dark: the system's setting unless the top bar's theme switch chose one
3
+ (data-theme on <html>). Each color is light-dark(light, dark). */
4
+ color-scheme: light dark;
5
+ --bg: light-dark(#f7f7f5, #111312);
6
+ --surface: light-dark(#ffffff, #181b1a);
7
+ --ink: light-dark(#1b1d1c, #eceeed);
8
+ --muted: light-dark(#6b706d, #9aa19d);
9
+ --line: light-dark(#e4e5e1, #2a2f2c);
10
+ --accent: light-dark(#166534, #4ade80);
11
+ --accent-ink: light-dark(#ffffff, #052e16);
12
+ --accent-soft: light-dark(#e8f3ec, #15301f);
13
+ --warn-soft: light-dark(#fef3c7, #2b2210);
14
+ --danger-soft: light-dark(#fef3f2, #2a1513);
15
+ --danger-line: light-dark(#fecdca, #55201b);
16
+ --danger: light-dark(#b42318, #f97066);
17
+ --warn: light-dark(#92400e, #fbbf24);
12
18
  --radius: 10px;
13
- --shadow: 0 1px 2px rgba(16, 24, 20, 0.06), 0 2px 8px rgba(16, 24, 20, 0.04);
19
+ --shadow: 0 1px 2px light-dark(rgba(16, 24, 20, 0.06), transparent), 0 2px 8px light-dark(rgba(16, 24, 20, 0.04), transparent);
14
20
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
15
21
  color: var(--ink);
16
22
  background: var(--bg);
@@ -18,21 +24,8 @@
18
24
  line-height: 1.5;
19
25
  -webkit-font-smoothing: antialiased;
20
26
  }
21
- @media (prefers-color-scheme: dark) {
22
- :root {
23
- --bg: #111312;
24
- --surface: #181b1a;
25
- --ink: #eceeed;
26
- --muted: #9aa19d;
27
- --line: #2a2f2c;
28
- --accent: #4ade80;
29
- --accent-ink: #052e16;
30
- --accent-soft: #15301f;
31
- --danger: #f97066;
32
- --warn: #fbbf24;
33
- --shadow: none;
34
- }
35
- }
27
+ :root[data-theme="light"] { color-scheme: light; }
28
+ :root[data-theme="dark"] { color-scheme: dark; }
36
29
  * { box-sizing: border-box; }
37
30
  body { margin: 0; }
38
31
  button, input, select, textarea { font: inherit; color: inherit; }
@@ -42,9 +35,9 @@ button, input, select, textarea { font: inherit; color: inherit; }
42
35
 
43
36
  .topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 20px; border-bottom: 1px solid var(--line); background: var(--surface); position: sticky; top: 0; z-index: 2; }
44
37
  .brand { display: flex; align-items: center; gap: 10px; font-weight: 600; }
45
- .mark { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; background: var(--accent); color: var(--accent-ink); }
38
+ .mark { display: block; width: 30px; height: 30px; }
46
39
  .env { font-size: 12px; font-weight: 500; padding: 2px 8px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); }
47
- .env-prod { background: #fef3c7; color: var(--warn); }
40
+ .env-prod { background: var(--warn-soft); color: var(--warn); }
48
41
  .topbar-right { display: flex; align-items: center; gap: 14px; }
49
42
  .live { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
50
43
  .live .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--line); }
@@ -52,11 +45,15 @@ button, input, select, textarea { font: inherit; color: inherit; }
52
45
  .identity select { padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
53
46
  .dashboard-link { font-size: 13px; color: var(--muted); text-decoration: none; }
54
47
  .dashboard-link:hover { color: var(--ink); }
48
+ .theme-toggle { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--line); border-radius: 8px; }
49
+ .theme-toggle button { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 24px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--muted); cursor: pointer; }
50
+ .theme-toggle button:hover { color: var(--ink); }
51
+ .theme-toggle button.on { background: var(--accent-soft); color: var(--accent); }
52
+ .theme-toggle button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
55
53
 
56
54
  .notice { margin: 12px 20px 0; padding: 10px 14px; border-radius: var(--radius); display: flex; justify-content: space-between; align-items: center; gap: 12px; }
57
- .notice.error { background: #fef3f2; color: var(--danger); border: 1px solid #fecdca; }
55
+ .notice.error { background: var(--danger-soft); color: var(--danger); border: 1px solid var(--danger-line); }
58
56
  .notice.info { background: var(--accent-soft); color: var(--accent); }
59
- @media (prefers-color-scheme: dark) { .notice.error { background: #2a1513; border-color: #55201b; } }
60
57
 
61
58
  .workspace { flex: 1; display: grid; grid-template-columns: 260px 1fr; min-height: 0; }
62
59
  .sidebar { border-right: 1px solid var(--line); padding: 20px 12px; display: flex; flex-direction: column; gap: 6px; }
@@ -108,6 +105,7 @@ button.icon { color: var(--muted); font-size: 14px; opacity: 0; }
108
105
  .danger-link { background: none; border: 0; color: var(--danger); cursor: pointer; font-size: 13px; }
109
106
  .wide { width: 100%; text-align: left; }
110
107
  .stack { display: flex; flex-direction: column; gap: 8px; }
108
+ code.block { display: block; padding: 8px 10px; border-radius: 6px; background: rgba(127, 127, 127, 0.12); font-size: 12px; word-break: break-all; }
111
109
 
112
110
  .empty { margin-top: 12vh; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 4px; }
113
111
  .empty.small { margin-top: 36px; }
@@ -124,6 +122,8 @@ button.icon { color: var(--muted); font-size: 14px; opacity: 0; }
124
122
  .workspace { grid-template-columns: 1fr; }
125
123
  .sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
126
124
  .main { padding: 20px 16px 48px; }
127
- .topbar { flex-wrap: wrap; }
125
+ .topbar { flex-wrap: wrap; padding: 10px 16px; }
126
+ .topbar-right { flex-wrap: wrap; gap: 10px; min-width: 0; }
127
+ .identity, .identity select { max-width: 100%; min-width: 0; }
128
128
  .task-detail { padding-left: 16px; }
129
129
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Light, dark, or the system's setting. The choice is kept in this browser (localStorage) and
3
+ * applied as data-theme on <html>; every color in styles.css follows it. public/theme.js
4
+ * applies it before first paint.
5
+ */
6
+ import { useEffect, useState, type ReactNode } from "react";
7
+
8
+ type Theme = "system" | "light" | "dark";
9
+ const KEY = "app:theme";
10
+
11
+ function stored(): Theme {
12
+ try {
13
+ const value = localStorage.getItem(KEY);
14
+ return value === "light" || value === "dark" ? value : "system";
15
+ } catch {
16
+ return "system";
17
+ }
18
+ }
19
+
20
+ function apply(theme: Theme) {
21
+ if (theme === "system") delete document.documentElement.dataset.theme;
22
+ else document.documentElement.dataset.theme = theme;
23
+ try {
24
+ if (theme === "system") localStorage.removeItem(KEY);
25
+ else localStorage.setItem(KEY, theme);
26
+ } catch {
27
+ // Storage is blocked (a private window): the choice lasts until the page reloads.
28
+ }
29
+ }
30
+
31
+ const OPTIONS: Array<{ theme: Theme; label: string; icon: ReactNode }> = [
32
+ { theme: "system", label: "System", icon: <><rect x="3" y="4" width="14" height="10" rx="1.5" /><path d="M7.5 17h5M10 14v3" /></> },
33
+ { theme: "light", label: "Light", icon: <><circle cx="10" cy="10" r="3.2" /><path d="M10 2.5v2M10 15.5v2M2.5 10h2M15.5 10h2M4.7 4.7l1.4 1.4M13.9 13.9l1.4 1.4M4.7 15.3l1.4-1.4M13.9 6.1l1.4-1.4" /></> },
34
+ { theme: "dark", label: "Dark", icon: <path d="M15.5 12.2A6 6 0 0 1 7.8 4.5a6 6 0 1 0 7.7 7.7Z" /> },
35
+ ];
36
+
37
+ /** The light / dark / system switch in the top bar. */
38
+ export function ThemeToggle() {
39
+ const [theme, setTheme] = useState<Theme>(stored);
40
+ useEffect(() => apply(theme), [theme]);
41
+ useEffect(() => {
42
+ const onStorage = (e: StorageEvent) => e.key === KEY && setTheme(stored());
43
+ window.addEventListener("storage", onStorage);
44
+ return () => window.removeEventListener("storage", onStorage);
45
+ }, []);
46
+ return (
47
+ <div className="theme-toggle" role="radiogroup" aria-label="Theme">
48
+ {OPTIONS.map((o) => (
49
+ <button key={o.theme} type="button" role="radio" aria-checked={theme === o.theme} className={theme === o.theme ? "on" : ""} onClick={() => setTheme(o.theme)} title={`${o.label} theme`}>
50
+ <svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
51
+ {o.icon}
52
+ </svg>
53
+ <span className="sr-only">{o.label}</span>
54
+ </button>
55
+ ))}
56
+ </div>
57
+ );
58
+ }
@@ -57,6 +57,26 @@ test("notes are stored by the Files service and readable by viewers", async () =
57
57
  }
58
58
  });
59
59
 
60
+ test("completing someone else's task notifies them, in their own inbox only", async () => {
61
+ const t = await createTestApp();
62
+ const bob = { id: "bob", type: "user" as const, roles: ["member"] };
63
+ try {
64
+ const ada = t.as("ada");
65
+ const projectId = await ada.mutation(api.projects.create, { name: "Inbox" });
66
+ const taskId = await ada.mutation(api.tasks.create, { projectId, title: "Review the draft" });
67
+ await t.as(bob).mutation(api.tasks.setDone, { id: taskId, done: true });
68
+ const [notification, ...rest] = await ada.query(api.inbox.list, { unread: true });
69
+ assert.equal(rest.length, 0);
70
+ assert.equal(notification!.type, "task.completed");
71
+ assert.deepEqual(notification!.data, { taskId });
72
+ assert.deepEqual(await t.as(bob).query(api.inbox.list, {}), [], "bob sees only his own");
73
+ await ada.mutation(api.inbox.markRead, { id: notification!.id });
74
+ assert.deepEqual(await ada.query(api.inbox.list, { unread: true }), []);
75
+ } finally {
76
+ await t.close();
77
+ }
78
+ });
79
+
60
80
  test("deleting a project removes its tasks in one atomic commit", async () => {
61
81
  const t = await createTestApp();
62
82
  try {
@@ -99,3 +119,25 @@ test("state survives a restart", async () => {
99
119
  await t.close();
100
120
  }
101
121
  });
122
+
123
+ test("completing someone else's task notifies its creator; the clear-completed job runs as whoever queued it", async () => {
124
+ const t = await createTestApp();
125
+ try {
126
+ const ada = t.as("ada");
127
+ const projectId = await ada.mutation(api.projects.create, { name: "Release" });
128
+ const taskId = await ada.mutation(api.tasks.create, { projectId, title: "Tag the build" });
129
+ await t.as({ id: "triage-bot", type: "agent", roles: ["agent"] }).mutation(api.tasks.setDone, { id: taskId, done: true });
130
+ const sent = await t.runtime.instance.handle("test").collection<{ recipient: string; type: string }>("notifications").find({});
131
+ assert.deepEqual(sent.map((n) => [n.recipient, n.type]), [["ada", "task.completed"]]);
132
+
133
+ // Studio → Services → Jobs queues this as the chosen identity; here, as Ada.
134
+ const principal = { id: "ada", type: "user" as const, roles: ["member", "admin"] };
135
+ await t.runtime.issueSession(principal, { kind: "test", via: "test" });
136
+ const services = t.runtime.services;
137
+ await services.asPrincipal(principal, (verified) => services.jobs!.enqueue({ type: "projects.clear-completed", payload: { projectId } }, verified));
138
+ assert.equal((await t.runtime.runServices()).jobs, 1);
139
+ assert.deepEqual(await ada.query(api.tasks.list, { projectId }), [], "the completed task was cleared");
140
+ } finally {
141
+ await t.close();
142
+ }
143
+ });
@@ -0,0 +1,7 @@
1
+ node_modules
2
+ .feltkit
3
+ dist
4
+ tests
5
+ .env
6
+ .env.*
7
+ .feltdb
@@ -0,0 +1,24 @@
1
+ # The inventory service on Managed FeltDB. The image holds no state and no credentials:
2
+ # FELTDB_URL, FELTDB_APPLICATION_ID and FELTDB_ENVIRONMENT come from the platform's configuration
3
+ # and FELTDB_TOKEN from its secret store, at run time (README.md, "Deploy").
4
+ FROM node:22-slim AS build
5
+ WORKDIR /app
6
+ COPY package.json package-lock.json* ./
7
+ RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi
8
+ COPY . .
9
+ # The contract checked in must be the one this source declares: the managed application
10
+ # only stores what it declares.
11
+ RUN npx feltkit contract --check && npx feltkit build && npm prune --omit=dev --no-audit --no-fund
12
+
13
+ FROM node:22-slim
14
+ ENV NODE_ENV=production PORT=8080
15
+ WORKDIR /app
16
+ COPY --from=build --chown=node:node /app ./
17
+ USER node
18
+ EXPOSE 8080
19
+ # Liveness: the runtime is ready only after it reached FeltDB with its key at startup.
20
+ HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
21
+ CMD ["node", "-e", "fetch('http://127.0.0.1:'+process.env.PORT+'/_feltkit/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
22
+ # The launcher validates the configuration (refusing to start without it) and forwards
23
+ # SIGTERM so FeltKit shuts down cleanly.
24
+ CMD ["node", "scripts/managed-env.mjs", "start", "--host", "0.0.0.0"]
@@ -0,0 +1,222 @@
1
+ # Inventory on Managed FeltDB: a FeltKit example
2
+
3
+ A small inventory service: items with stock levels, and a movement recorded with every stock
4
+ change in the same FeltDB transaction. Its state lives in **Managed FeltDB**, the hosted FeltDB
5
+ service. Nothing on the app's host holds state, so you can restart or redeploy the app and
6
+ nothing is lost.
7
+
8
+ ```bash
9
+ npm create feltkit@latest inventory -- --template managed
10
+ ```
11
+
12
+ ## How this differs from the default starter
13
+
14
+ | | Default starter (`--template tasks`) | This starter (`--template managed`) |
15
+ |---|---|---|
16
+ | Where state lives | local FeltDB: `.feltkit/<env>/state`, or a FeltDB server you run (`FELTDB_URL` + `FELTDB_TOKEN`) | Managed FeltDB: one managed *application*, one *environment* (`production` by default) |
17
+ | Development and tests | local FeltDB | local FeltDB, the same (`npm test`, `npm run dev`) |
18
+ | What FeltDB stores | any collection | only what the promoted **contract** declares: `feltdb.contract.json`, generated by `feltkit contract` and checked in |
19
+ | AppPort Services (files, intelligence, …) | yes | no: their collections can't be declared in a contract yet, so `feltkit contract` refuses an app that enables them |
20
+ | Credentials | none for local state | a **runtime key** with four scopes (server-side only) and a **control key** for publishing contracts (never in the running app), both minted by the FeltDB CLI |
21
+ | Deployment | `feltkit deploy` (a Docker artifact with a state volume) | `Dockerfile`, `docker-compose.yml`, `fly.toml`: stateless, configured by the environment |
22
+
23
+ Managed FeltDB is an external service. Nothing here provisions or operates it: `feltdb
24
+ init-managed` creates *your application* in it, using your FeltDB account.
25
+
26
+ ## What's here
27
+
28
+ | Path | What it is |
29
+ |---|---|
30
+ | `feltkit/app.ts` | roles (`clerk`, `auditor`), collections `items` and `movements` |
31
+ | `feltkit/items.ts` | `list`, `get`, `create` and `adjust` (AppPort capabilities `items.create` and `stock.adjust`), `history` |
32
+ | `feltkit/system.ts` | `system:status`: an authenticated read through the configured backend; names the backend, never a key |
33
+ | `feltdb.contract.json` | the FeltDB application contract: your collections, FeltKit's records, FeltDB's agent state |
34
+ | `scripts/managed-env.mjs` | runs FeltKit with the runtime settings only; refuses missing or contradictory ones |
35
+ | `Dockerfile`, `.dockerignore`, `docker-compose.yml` | the image, and a way to run it locally against Managed FeltDB |
36
+ | `fly.toml`, `scripts/deploy-fly.mjs` | deployment to Fly.io |
37
+ | `tests/` | `inventory` (persistence and refusals, local), `config` (configuration contract, deploy script), `managed.live` (against Managed FeltDB) |
38
+
39
+ ## Prerequisites
40
+
41
+ - Node.js 20.11 or later, and npm.
42
+ - A FeltDB account (`npm run managed:setup` creates one if the e-mail is new). The account
43
+ creates the application and its keys, so you need permission to manage keys for it. The
44
+ account service never issues a scope you don't hold.
45
+ - To deploy: Docker, or [flyctl](https://fly.io/docs/flyctl/install/) signed in to Fly.io.
46
+
47
+ ## 1. Run it locally
48
+
49
+ ```bash
50
+ npm install
51
+ npm test # local FeltDB per test: persistence, refusals, roles, configuration
52
+ npm run dev # http://localhost:3000, state in .feltkit/development/state
53
+ ```
54
+
55
+ Development and tests always use local FeltDB, so you don't need an account for either.
56
+
57
+ ## 2. Set up Managed FeltDB: account, application, keys, contract
58
+
59
+ ```bash
60
+ npm run managed:setup
61
+ ```
62
+
63
+ This runs `feltkit contract` (writes `feltdb.contract.json`), then FeltDB's own CLI (shipped with
64
+ `@feltdb/core`):
65
+
66
+ ```bash
67
+ feltdb init-managed --contract feltdb.contract.json --runtime-scopes minimal
68
+ ```
69
+
70
+ It signs you in (or signs you up), prompting for your e-mail and password. For CI, set
71
+ `FELTDB_MANAGED_EMAIL` and `FELTDB_MANAGED_PASSWORD` instead. It then:
72
+
73
+ 1. creates or reuses the managed application named after `package.json`'s `name` (`--name` to choose);
74
+ 2. mints a **control key** (publish and promote contracts) and a **runtime key** with
75
+ `application:read`, `application:revision:read`, `state:read` and `state:write`, nothing more;
76
+ 3. writes `.env.local` (mode 600) and `.feltdb/managed.json`; both are in `.gitignore`;
77
+ 4. publishes and promotes the contract to `production`, and checks that the runtime key resolves it.
78
+
79
+ | Variable (`.env.local`) | Secret? | Used by |
80
+ |---|---|---|
81
+ | `FELTDB_URL` | no | the app |
82
+ | `FELTDB_APPLICATION_ID` | no | the app |
83
+ | `FELTDB_ENVIRONMENT` | no | the app (it is also FeltKit's environment) |
84
+ | `FELTDB_TOKEN` | **yes**: the runtime key | the app |
85
+ | `FELTDB_MANAGED_CONTROL_API_KEY` | **yes**: the control key | `feltdb publish` only; never the app |
86
+
87
+ `init-managed` also writes `FELTDB_NAMESPACE`, `FELTDB_MODE` and `VITE_FELTDB_*`, which hold no
88
+ secrets. The app doesn't use them, and `scripts/managed-env.mjs` doesn't pass them on.
89
+
90
+ ## 3. Run it against Managed FeltDB from your machine
91
+
92
+ ```bash
93
+ npm run managed:session -- --id ops --role clerk # prints a session token once
94
+ npm run managed:start # http://localhost:3000, state in Managed FeltDB
95
+ ```
96
+
97
+ Both go through `scripts/managed-env.mjs`, which reads `.env.local`. It passes FeltKit only the
98
+ four runtime settings and refuses to start if one is missing. `managed:start` prints
99
+ `State remote https://…`.
100
+
101
+ Production has no development sign-in, so callers use a session (`Authorization: Bearer <token>`)
102
+ or your own `auth.authenticate` hook in `feltkit/app.ts`. Create sessions while the server is
103
+ stopped (or from another machine). A running production server's admin API answers only verified
104
+ operators.
105
+
106
+ ## 4. Verify reads and writes
107
+
108
+ ```bash
109
+ TOKEN=fks_… # from managed:session
110
+ curl -s -X POST localhost:3000/api/mutation/items:create -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"args":{"sku":"BOLT-10","name":"M10 bolt","quantity":100}}'
111
+ curl -s -X POST localhost:3000/api/mutation/items:adjust -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"args":{"sku":"BOLT-10","delta":-30,"reason":"order 1"}}'
112
+ curl -s -X POST localhost:3000/api/query/items:list -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{}'
113
+ curl -s -X POST localhost:3000/api/query/system:status -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{}'
114
+ # → {"ok":true,"value":{"environment":"production","storage":{"kind":"remote","location":"https://api.feltdb.com","durable":true},"readable":true,…}}
115
+ curl -s localhost:3000/_feltkit/health # liveness: 200 once FeltKit reached FeltDB with its key at startup
116
+ ```
117
+
118
+ ## 5. Restart and check the state is still there
119
+
120
+ Stop the app (Ctrl+C), run `npm run managed:start` again, and repeat `items:list`: the same items
121
+ and quantities come back. Or run the automated check, which writes a `live-test-…` item, kills
122
+ the app, starts a new process and reads it back:
123
+
124
+ ```bash
125
+ FELTKIT_MANAGED_LIVE=1 npm run managed:test
126
+ ```
127
+
128
+ ## 6. Deploy
129
+
130
+ The image holds no state and no keys. It is configured entirely by the environment at run
131
+ time, so the same image runs anywhere.
132
+
133
+ **Docker (any host).** Locally, with the settings from step 2:
134
+
135
+ ```bash
136
+ docker compose --env-file .env.local up --build
137
+ ```
138
+
139
+ The compose file passes the container only `FELTDB_URL`, `FELTDB_APPLICATION_ID`,
140
+ `FELTDB_ENVIRONMENT` and `FELTDB_TOKEN`. On any other platform, set the first three as plain
141
+ configuration and `FELTDB_TOKEN` from its secret store. Point health checks at
142
+ `GET /_feltkit/health` on port 8080. The image checks that `feltdb.contract.json` matches the
143
+ source at build time.
144
+
145
+ **Fly.io.**
146
+
147
+ ```bash
148
+ fly apps create <your-app-name> # once
149
+ npm run deploy:fly -- --app <your-app-name>
150
+ ```
151
+
152
+ `scripts/deploy-fly.mjs` stages `FELTDB_TOKEN` as a Fly secret by piping it to
153
+ `fly secrets import --stage` on stdin, so it never appears in a command line or output. It then
154
+ runs `fly deploy` with the three non-secret settings. `fly.toml` holds the port, the health check
155
+ and `FELTDB_ENVIRONMENT`.
156
+
157
+ Then repeat steps 4 and 5 against the deployed URL. Create the session from your machine with
158
+ `npm run managed:session`: sessions live in Managed FeltDB, so the deployed app accepts them.
159
+
160
+ ## Changing the data model
161
+
162
+ 1. Edit `feltkit/app.ts`.
163
+ 2. Run `npm run managed:publish`. This regenerates the contract and publishes it with
164
+ `feltdb publish --contract`. FeltDB classifies the change. A change it classifies as breaking,
165
+ migration-requiring or authorization-sensitive (adding a field is `MIGRATION_REQUIRED`) is
166
+ staged, but not activated until you rerun with `-- --approve-destructive-changes`. A refused or
167
+ unapproved publish leaves the active contract in place.
168
+ 3. Commit `feltdb.contract.json` with the code, then redeploy. The app that is already running needs
169
+ no restart. Its next request after the promotion is refused once (`CONFLICT`, `feltdbCode:
170
+ REVISION_NOT_ACTIVE`, nothing written, not resent), and every request after that runs under the
171
+ new contract. A compatible change, such as a new optional field, keeps the old code working. An
172
+ incompatible one refuses the old code's writes until the new code is deployed: FeltDB never
173
+ stores them under the old contract.
174
+
175
+ ## Keys: rotate, narrow, revoke
176
+
177
+ ```bash
178
+ npx feltdb managed keys list # ids, names, scopes; never secrets
179
+ npx feltdb managed keys create --name inventory-runtime-2 --scopes minimal # new FELTDB_TOKEN in .env.local
180
+ npm run deploy:fly -- --app <your-app-name> # or update your platform's secret
181
+ npx feltdb managed keys revoke inventory-runtime # the old key now gets AUTHENTICATION_REQUIRED
182
+ ```
183
+
184
+ `--stdout` writes `FELTDB_TOKEN=…` to a pipe (for example into a secret manager) and is refused on a
185
+ terminal. A key is bound to this application. Wildcard (`*`) keys are operator keys and are
186
+ refused.
187
+
188
+ ## Troubleshooting
189
+
190
+ | You see | Means | Do |
191
+ |---|---|---|
192
+ | `Managed FeltDB configuration is incomplete: FELTDB_… is not set` | the launcher found no value in the environment or `.env.local` | run `npm run managed:setup`, or set it on your platform |
193
+ | startup fails with `AUTHENTICATION_REQUIRED` | the runtime key is wrong, revoked, or for another FeltDB | mint a new one (`feltdb managed keys create`) and update `FELTDB_TOKEN` |
194
+ | `FORBIDDEN` | the key is for another application or lacks a scope | check `FELTDB_APPLICATION_ID`; mint with `--scopes minimal` |
195
+ | `NOT_FOUND … application environment has no active contract` | nothing is promoted to `FELTDB_ENVIRONMENT` | `npm run managed:publish`, or fix `FELTDB_ENVIRONMENT` |
196
+ | `UNKNOWN_COLLECTION` or `record contains an unknown field` | the contract is older than the code | `npm run managed:publish` (approve it) |
197
+ | `CONFLICT` with `feltdbCode: REVISION_NOT_ACTIVE` | a contract was promoted since this process's last request; nothing was written, and FeltKit did not resend it | the request was built for the previous contract: if it still makes sense, send it again, and it runs under the new contract (once per promotion). Retrying it is the caller's decision, never automatic |
198
+ | startup fails with `SERVICE_UNAVAILABLE` / names `FELTDB_URL` | FeltDB can't be reached | check `FELTDB_URL` and network egress. Managed FeltDB's `/health` and `/ready` are public |
199
+ | `FELTDB_ENVIRONMENT is "…", but this is FeltKit's … environment` | the two disagree | FeltKit uses the FeltDB environment of its own name: set one of them |
200
+ | `This application enables AppPort Services` from `feltkit contract` | services can't run on Managed FeltDB yet | remove `services`, or use local or self-hosted FeltDB |
201
+ | `The production environment is already served by pid …` | a FeltKit server for this environment is running on this host | stop it first (or run the CLI on another machine) |
202
+
203
+ ## Clean up
204
+
205
+ - Revoke keys you no longer use: `npx feltdb managed keys revoke <name-or-id>`.
206
+ - `live-test-…` items written by `managed:test` stay in your application. They are ordinary
207
+ records, so remove them with your own mutation if you need to.
208
+ - Delete the Fly app with `fly apps destroy <your-app-name>`. Local files: `.env.local`,
209
+ `.feltdb/`, `.feltkit/`.
210
+ - The managed application itself is deleted from your FeltDB account. This repository has no
211
+ command that deletes it.
212
+
213
+ ## What's verified, and where
214
+
215
+ - `npm test` (here): persistence, refusals, roles, the configuration contract, the Fly deploy
216
+ script with a stand-in `fly`.
217
+ - `npm run test:managed` in the FeltKit repository: this example's full workflow, setup through
218
+ refusals, against a separately running, auth-on `feltdb-server` provisioned the way Managed
219
+ FeltDB is. It covers app and authority restarts, an idempotent retry across a restart, a
220
+ refused key, an unreachable authority, and no secret in any output.
221
+ - `FELTKIT_MANAGED_LIVE=1 npm run managed:test`: the live service. It runs only with your
222
+ credentials.