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.
- package/README.md +21 -3
- package/bin/create.js +30 -10
- package/package.json +3 -2
- package/scaffold.d.ts +7 -2
- package/scaffold.js +45 -9
- package/template/README.md +318 -0
- package/template/feltkit/_generated/api.ts +2 -1
- package/template/feltkit/app.ts +27 -10
- package/template/feltkit/inbox.ts +21 -0
- package/template/feltkit/projects.ts +19 -0
- package/template/feltkit/tasks.ts +11 -1
- package/template/index.html +4 -1
- package/template/package.json +6 -1
- package/template/public/apple-touch-icon.png +0 -0
- package/template/public/favicon.png +0 -0
- package/template/public/feltkit-mark.png +0 -0
- package/template/public/theme.js +7 -0
- package/template/src/App.tsx +31 -7
- package/template/src/styles.css +31 -31
- package/template/src/theme.tsx +58 -0
- package/template/tests/tasks.test.ts +42 -0
- package/template-managed/.dockerignore +7 -0
- package/template-managed/Dockerfile +24 -0
- package/template-managed/README.md +222 -0
- package/template-managed/_gitignore +7 -0
- package/template-managed/docker-compose.yml +17 -0
- package/template-managed/feltdb.contract.json +1102 -0
- package/template-managed/feltkit/_generated/api.ts +9 -0
- package/template-managed/feltkit/app.ts +40 -0
- package/template-managed/feltkit/items.ts +55 -0
- package/template-managed/feltkit/system.ts +14 -0
- package/template-managed/fly.toml +28 -0
- package/template-managed/package.json +35 -0
- package/template-managed/scripts/deploy-fly.mjs +54 -0
- package/template-managed/scripts/managed-env.mjs +103 -0
- package/template-managed/tests/config.test.ts +91 -0
- package/template-managed/tests/inventory.test.ts +69 -0
- package/template-managed/tests/managed.live.test.ts +75 -0
- package/template-managed/tsconfig.json +15 -0
- 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
|
-
|
|
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
|
});
|
package/template/index.html
CHANGED
|
@@ -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.
|
|
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>
|
package/template/package.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"typecheck": "tsc --noEmit"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@feltdb/feltkit": "0.3.
|
|
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
|
|
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) {}
|
package/template/src/App.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
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);
|
package/template/src/styles.css
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
:root {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
--
|
|
6
|
-
--
|
|
7
|
-
--
|
|
8
|
-
--
|
|
9
|
-
--
|
|
10
|
-
--
|
|
11
|
-
--
|
|
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
|
-
|
|
22
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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,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.
|