create-feltkit 0.3.2 → 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 CHANGED
@@ -1,3 +1,10 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rkendel1/feltkit/main/docs/assets/feltkit-logo-dark.png">
4
+ <img src="https://raw.githubusercontent.com/rkendel1/feltkit/main/docs/assets/feltkit-logo.png" alt="FeltKit" width="160">
5
+ </picture>
6
+ </p>
7
+
1
8
  # create-feltkit
2
9
 
3
10
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-feltkit",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Create a FeltKit application: npm create feltkit@latest my-app (add -- --template managed for Managed FeltDB)",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/scaffold.js CHANGED
@@ -86,9 +86,9 @@ export function scaffold({ target, name, sdkSpec, template = "tasks" }) {
86
86
  readmePath,
87
87
  readme
88
88
  .replace(`# ${starterName}\n`, `# ${appName}\n`)
89
- .replaceAll(`docker build -t ${starterName} .`, `docker build -t ${appName} .`)
90
- .replaceAll(`-v ${starterName}-data:/data`, `-v ${appName}-data:/data`)
91
- .replaceAll(`--env-file prod.env ${starterName}`, `--env-file prod.env ${appName}`),
89
+ .replaceAll(`docker build -t ${starterName} `, `docker build -t ${appName} `)
90
+ .replaceAll(`-v ${starterName}-data:/data ${starterName}`, `-v ${appName}-data:/data ${appName}`)
91
+ .replaceAll(`--name ${starterName} --namespace ${starterName} `, `--name ${appName} --namespace ${appName} `),
92
92
  );
93
93
  }
94
94
  }
@@ -39,7 +39,7 @@ start over.
39
39
  | `feltkit/app.ts` | The application: its name, roles and permissions, dev users, enabled services, collections |
40
40
  | `feltkit/projects.ts`, `feltkit/tasks.ts` | Functions: queries (read) and mutations (write). A function is `<module>:<export>`, e.g. `tasks:create` |
41
41
  | `feltkit/_generated/api.ts` | Typed references to every function. Regenerated by `feltkit dev` and `feltkit codegen`; don't edit it |
42
- | `src/` | The React frontend |
42
+ | `src/` | The React frontend. Colors are tokens in `src/styles.css`, each `light-dark(light, dark)`; the top bar's switch (`src/theme.tsx`) picks light, dark, or the system setting, remembered per browser and applied before first paint by `public/theme.js` |
43
43
  | `tests/` | Tests, each against a fresh FeltDB instance |
44
44
  | `.feltkit/` | Local state, keys and builds, per environment. Never commit it |
45
45
 
@@ -92,19 +92,44 @@ operation there is authorized as the dev user you choose with **Act as**. From a
92
92
 
93
93
  ```ts
94
94
  await ctx.services.files.upload({ name: "notes.txt", content: text });
95
- await ctx.services.notifications.send({ recipient: "grace", type: "task.assigned", title: "Review the plan" });
95
+ await ctx.services.notifications.send({ recipient: "grace", type: "task.completed", title: "Done" });
96
+ await ctx.services.webhooks?.emit("task.created", { id }); // mutations only
97
+ await ctx.services.jobs?.enqueue("projects.clear-completed", {}); // mutations only
96
98
  const greeting = await ctx.services.configuration.get("GREETING");
97
- await ctx.services.jobs?.enqueue("report.build", { week }); // mutations only
98
99
  ```
99
100
 
100
- A job type runs the function you name in `services.jobs.handlers`
101
- (`jobs: { handlers: { "report.build": "reports:build" } }`), as the user who queued it.
102
- To turn a service off, remove it from `services`.
101
+ The starter already uses them: creating and completing a task emits `task.created` and
102
+ `task.completed` to any registered webhook endpoint; completing someone else's task notifies
103
+ its creator; and the job `projects.clear-completed` runs `projects:clearCompleted`.
104
+
105
+ Declare what your app sends in `feltkit/app.ts`, so Studio's forms offer it in dropdowns:
106
+
107
+ ```ts
108
+ services: {
109
+ notifications: { types: ["task.completed"] },
110
+ webhooks: { events: ["task.created", "task.completed"] },
111
+ jobs: { handlers: { "projects.clear-completed": "projects:clearCompleted" } },
112
+ // …
113
+ }
114
+ ```
115
+
116
+ A job type runs the mutation you name, as the user who queued it, with retries. To turn a
117
+ service off, remove it from `services`.
118
+
119
+ ## Connecting an API
120
+
121
+ **Studio → Integrations → Connect an API** reads an OpenAPI document (JSON) or lets you
122
+ describe the operations yourself, then writes `feltkit/<name>.ts` and grants `<name>.*` to the
123
+ roles you choose. Each operation becomes a capability your functions, the agent and other apps
124
+ can call (`<name>.<operation>`), with its permission, and every call recorded. The API key is
125
+ stored in FeltKit's custody; in production set `<NAME>_URL` and `FELTKIT_CREDENTIAL_<NAME>`.
126
+ Then narrow each operation's `output` schema in that file to what the API returns.
103
127
 
104
128
  ## Choosing a model for the agent
105
129
 
106
- Open **Studio → Models**, pick a provider and model, type its API key, **Save**, then
107
- **Test**. Or from a terminal:
130
+ Open **Studio → Models**, pick a provider and model, type its API key, then **Save and use**
131
+ and **Test**. **Save for later** keeps more models (other providers too) under **Saved
132
+ models**, each with its own key; **Use** switches the agent to one. Or from a terminal:
108
133
 
109
134
  ```bash
110
135
  npx feltkit model set --provider anthropic --model <model> --api-key-stdin
@@ -131,8 +156,18 @@ await t.close();
131
156
 
132
157
  ## Deploying
133
158
 
134
- FeltKit doesn't host your app. It builds an artifact you run on any Node 20+ host or
135
- container.
159
+ FeltKit doesn't host your app; it builds what you run, for the way you want to run it:
160
+
161
+ | Target | What runs | Data |
162
+ |---|---|---|
163
+ | `container` (default) | Everything in one container | FeltDB state on a volume |
164
+ | `hosted` | A separate app container | Managed FeltDB, hosted for you, from the artifact's contract (it declares AppPort Services' collections too) |
165
+ | `self-hosted` | A separate app container | A FeltDB server you operate (`feltdb-server`), in this app's namespace |
166
+
167
+ Each target's `DEPLOY.md` spells out its contract: what to set, where state lives and how to
168
+ recover it, what stops startup, and how to upgrade and roll back. A target never changes where
169
+ state is stored: the container target refuses `FELTDB_URL`, and the others refuse to start
170
+ without their FeltDB settings.
136
171
 
137
172
  1. **Check what production needs.**
138
173
 
@@ -140,50 +175,82 @@ container.
140
175
  npx feltkit readiness --env production
141
176
  ```
142
177
 
143
- This lists what the environment has and lacks (state, secrets, identity, model,
144
- integrations), with the fix for each. It exits with 1 while anything fails.
145
-
146
- 2. **Build and verify the artifact.**
178
+ 2. **Build and verify.** It asks which target (or pass `--target container`, `hosted` or `self-hosted`):
147
179
 
148
180
  ```bash
149
181
  npm run deploy
150
182
  ```
151
183
 
152
- This builds the backend and the frontend, boots the production build against throwaway
153
- state to prove it serves, and writes `.feltkit/deploy/<app>-<time>/` (or `--out <dir>`)
154
- with `package.json`, a `Dockerfile` and `DEPLOY.md`.
184
+ This builds the backend and frontend, boots the production build against throwaway state
185
+ to prove it serves, and writes `.feltkit/deploy/` with a `Dockerfile`, `docker-compose.yml`,
186
+ `fly.toml`, `.env.example` (exactly the variables it needs) and `DEPLOY.md`. Each deploy
187
+ replaces it, keeping production state a previous run kept inside it. It prints the commands
188
+ to run next.
189
+
190
+ 3. **Run it.** Everything in one container:
191
+
192
+ ```bash
193
+ docker compose -f .feltkit/deploy/docker-compose.yml up --build
194
+ ```
155
195
 
156
- 3. **Run it.** From the artifact directory:
196
+ Or with Node, from the artifact (data in its `.feltkit/production`):
157
197
 
158
198
  ```bash
159
- npm install --omit=dev
160
- PORT=3000 npm start
199
+ cd .feltkit/deploy
161
200
  ```
162
201
 
163
- Or with Docker:
202
+ ```bash
203
+ npm ci --omit=dev
204
+ ```
164
205
 
165
206
  ```bash
166
- docker build -t tasks . && docker run -p 3000:3000 -v tasks-data:/data --env-file prod.env tasks
207
+ npm start
167
208
  ```
168
209
 
169
- **Production settings** (set them from your secret manager, never in the repository):
210
+ With Managed FeltDB (`--target hosted`), create the app and its key from the artifact's
211
+ contract (`npx feltdb init-managed --contract .feltkit/deploy/feltdb.contract.json
212
+ --runtime-scopes minimal`), put `FELTDB_URL`, `FELTDB_APPLICATION_ID` and `FELTDB_TOKEN` in
213
+ `.feltkit/deploy/.env`, then run the same `docker compose` command. With your own FeltDB
214
+ server (`--target self-hosted`), create a key for the app's namespace (`feltdb-server keys
215
+ create --keys <keys.json> --name tasks --namespace tasks --scope state:read,state:write`) and
216
+ put `FELTDB_URL` and `FELTDB_TOKEN` in `.env`. Either container refuses to start without its
217
+ settings, and checks the database answers, so it never keeps data on its own disk. For
218
+ Fly.io, plain Docker, or other hosts, follow `.feltkit/deploy/DEPLOY.md`.
219
+
220
+ Open <http://localhost:3000>.
221
+
222
+ 4. **Sign in.** Production has no development sign-in (Ada and Grace are for development).
223
+ Until you connect your own login, create a session token, *with the app stopped* (only one
224
+ process may open the state), and paste it into the app's sign-in screen:
225
+
226
+ ```bash
227
+ npx feltkit session create --env production --type user --id you --role member
228
+ ```
229
+
230
+ Run it in `.feltkit/deploy` for the Node run (or anywhere with the target's `FELTDB_*`
231
+ variables set: then the app may keep running, since its sessions are in FeltDB). For the one-container Docker run, use a one-off container on the same
232
+ volume while the app's container is stopped:
233
+
234
+ ```bash
235
+ docker run --rm -v tasks-data:/data tasks node node_modules/@feltdb/feltkit/bin/feltkit.js session create --env production --type user --id you --role member
236
+ ```
237
+
238
+ For real users, add `auth.authenticate` to `feltkit({ ... })` to verify requests with your
239
+ identity provider.
240
+
241
+ **Production settings** (from your secret manager, never in the repository). With one
242
+ container, none is required:
170
243
 
171
244
  | Variable | What it's for |
172
245
  |---|---|
173
- | `FELTKIT_STATE_DIR` | Where production state lives on a durable volume (the Docker image uses `/data/state`) |
174
- | `FELTDB_URL`, `FELTDB_TOKEN` | Use a FeltDB server instead of local state |
175
- | `FELTKIT_API_KEY_VERIFIER_KEY` | **Required**, because API keys are enabled: 32+ random bytes. Changing it invalidates every issued API key |
246
+ | `FELTDB_URL`, `FELTDB_APPLICATION_ID`, `FELTDB_TOKEN` | Managed FeltDB (`--target hosted`) |
247
+ | `FELTDB_URL`, `FELTDB_TOKEN` | Your FeltDB server (`--target self-hosted`) |
248
+ | `FELTKIT_WEBHOOK_PRIVATE_NETWORKS` | `1` lets webhooks reach receivers on private networks (refused by default outside development) |
249
+ | `FELTKIT_API_KEY_VERIFIER_KEY` | Hosted and self-hosted: 32+ random bytes (`openssl rand -base64 32`). In one container it is generated once and kept beside the state on the volume |
176
250
  | `FELTKIT_CREDENTIAL_<ID>` | Credential values, e.g. `FELTKIT_CREDENTIAL_ANTHROPIC` for `credential-ref:anthropic`. Keys you entered in Studio are development-only and are not deployed |
251
+ | `FELTKIT_STATE_DIR` | Where local state lives (the container image uses `/data/state`, on the volume) |
177
252
  | `FELTKIT_OPERATOR_KEY` | Optional: signs operator sign-ins for production Studio |
178
- | `PORT` | The port to listen on |
179
-
180
- **Users in production.** Development sign-in (Ada, Grace) is off in production. Verify
181
- requests with your identity provider by adding `auth.authenticate` to `feltkit({ ... })`,
182
- or issue sessions to known principals:
183
-
184
- ```bash
185
- npx feltkit session create --env production --id ops-bot --type agent --role agent
186
- ```
253
+ | `PORT` | The port to listen on (default 3000) |
187
254
 
188
255
  **Production Studio.** Studio isn't served in production unless `FELTKIT_DASHBOARD=1`. Then
189
256
  operators sign in with a one-time grant you create on the host
@@ -2,8 +2,9 @@
2
2
  /* eslint-disable */
3
3
  import { createApi } from "@feltdb/feltkit/client";
4
4
  import type * as app_module from "../app.js";
5
+ import type * as inbox_module from "../inbox.js";
5
6
  import type * as projects_module from "../projects.js";
6
7
  import type * as tasks_module from "../tasks.js";
7
8
 
8
9
  /** Typed references to every query and mutation. Type-only imports: no backend code reaches the browser. */
9
- export const api = createApi<{ "app": typeof app_module; "projects": typeof projects_module; "tasks": typeof tasks_module }>();
10
+ export const api = createApi<{ "app": typeof app_module; "inbox": typeof inbox_module; "projects": typeof projects_module; "tasks": typeof tasks_module }>();
@@ -13,15 +13,18 @@ export const app = feltkit({
13
13
  roles: {
14
14
  // Members use the app and Studio's agent (feltkit.agent.*: create, approve, and follow agent tasks).
15
15
  // notifications.*: a principal reads, marks, and dismisses only its own notifications.
16
- member: ["projects.*", "tasks.*", "files.*", "notifications.*", "feltkit.agent.*"],
16
+ // webhooks.emit + webhooks.deliver: their changes notify registered endpoints (task.created,
17
+ // task.completed); each delivery runs as the person whose change emitted it.
18
+ member: ["projects.*", "tasks.*", "files.*", "notifications.*", "webhooks.emit", "webhooks.deliver", "feltkit.agent.*"],
17
19
  // Viewers read. They can see agent tasks but not start or approve them.
18
20
  viewer: ["projects.read", "tasks.read", "files.read", "notifications.read", "feltkit.agent.read"],
19
21
  // Agents can read, complete tasks, and leave notes. They cannot create or delete.
20
- agent: ["projects.read", "tasks.read", "tasks.complete", "tasks.note", "files.read", "files.write"],
22
+ agent: ["projects.read", "tasks.read", "tasks.complete", "tasks.note", "files.read", "files.write", "notifications.send", "webhooks.emit", "webhooks.deliver"],
21
23
  // Admins configure the app in Studio → Services: the model (intelligence.*), stored
22
24
  // credentials (credential.*), configuration, API keys, webhooks, jobs, and schedules.
23
25
  // Credentials are stored by reference; the value never enters FeltDB.
24
- admin: ["intelligence.*", "credential.*", "configuration.*", "apikeys.*", "webhooks.*", "jobs.*", "schedules.*"],
26
+ // feltkit.operations.*: in production, admins are Studio's operators (npx feltkit operator grant).
27
+ admin: ["intelligence.*", "credential.*", "configuration.*", "apikeys.*", "webhooks.*", "jobs.*", "schedules.*", "feltkit.operations.*"],
25
28
  },
26
29
  // Development-only identities. Sign in as them from the app or the dashboard.
27
30
  dev: {
@@ -34,14 +37,15 @@ export const app = feltkit({
34
37
  // Services. Intelligence is the model the agent decides with (Studio → Models).
35
38
  services: {
36
39
  files: true,
37
- notifications: true,
40
+ // The types this app sends; Studio offers them when you send one by hand.
41
+ notifications: { types: ["task.completed"] },
38
42
  configuration: true,
39
43
  // A request authenticated by an API key holds these roles.
40
44
  apiKeys: { roles: ["member"] },
41
- webhooks: true,
42
- // Job type → the function that runs it, as the principal that queued it:
43
- // jobs: { handlers: { "report.build": "reports:build" } }
44
- jobs: true,
45
+ // The events this app emits (feltkit/tasks.ts); Studio offers them when you register an endpoint.
46
+ webhooks: { events: ["task.created", "task.completed"] },
47
+ // Job type → the mutation that runs it, as the principal that queued it, with retries.
48
+ jobs: { handlers: { "projects.clear-completed": "projects:clearCompleted" } },
45
49
  schedules: true,
46
50
  intelligence: true,
47
51
  },
@@ -0,0 +1,21 @@
1
+ import { s } from "@feltdb/feltkit";
2
+ import { app } from "./app.js";
3
+
4
+ /**
5
+ * The signed-in person's notifications (AppPort Services Notifications, e.g. "task.completed"
6
+ * from tasks:setDone). Each principal reads and marks only its own; the service enforces it.
7
+ */
8
+ export const list = app.query({
9
+ access: "notifications.read",
10
+ args: { unread: s.optional(s.boolean()) },
11
+ handler: async (ctx, { unread }) =>
12
+ (await ctx.services.notifications.list(unread ? { unread: true } : {})).map((n) => ({ id: n.id, type: n.type, title: n.title, data: n.data ?? null, createdAt: n.createdAt, read: Boolean(n.readAt) })),
13
+ });
14
+
15
+ export const markRead = app.mutation({
16
+ access: "notifications.update",
17
+ args: { id: s.string() },
18
+ handler: async (ctx, { id }) => {
19
+ await ctx.services.notifications.markRead(id);
20
+ },
21
+ });
@@ -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.2",
16
+ "@feltdb/feltkit": "0.3.3",
17
17
  "react": "19.2.0",
18
18
  "react-dom": "19.2.0"
19
19
  },
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "overrides": {
29
29
  "@appport/services": {
30
- "@feltdb/core": "0.16.0"
30
+ "@feltdb/core": "0.16.1"
31
31
  }
32
32
  }
33
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
+ });
@@ -20,16 +20,16 @@
20
20
  "deploy:fly": "node scripts/deploy-fly.mjs"
21
21
  },
22
22
  "dependencies": {
23
- "@feltdb/feltkit": "0.3.2"
23
+ "@feltdb/feltkit": "0.3.3"
24
24
  },
25
25
  "devDependencies": {
26
- "@feltdb/core": "0.16.0",
26
+ "@feltdb/core": "0.16.1",
27
27
  "@types/node": "22.19.1",
28
28
  "typescript": "5.9.3"
29
29
  },
30
30
  "overrides": {
31
31
  "@appport/services": {
32
- "@feltdb/core": "0.16.0"
32
+ "@feltdb/core": "0.16.1"
33
33
  }
34
34
  }
35
35
  }
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#14532d"/><path d="M11 7c-4 5-4 13 0 18M21 7c4 5 4 13 0 18" stroke="#ecfdf5" stroke-width="3" fill="none" stroke-linecap="round"/></svg>