@naisys/erp 3.0.0-beta.37 → 3.0.0-beta.39

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.
@@ -47,7 +47,7 @@ object({
47
47
  chatEnabled: boolean().optional().describe("Show chat commands to the agent. Chat encourages more concise communication"),
48
48
  webEnabled: boolean().optional().describe("Allow agent to browse the web with a context-optimized text browser built on Lynx. Javascript not supported. Requires `lynx` on the host (e.g. `apt install lynx`)"),
49
49
  browserEnabled: boolean().optional().describe("Allow agent to browse the web with a real headless Chromium browser via Playwright. Vision-capable models see screenshots; others fall back to a text/selector mode. Requires `npx playwright install chromium` on the host"),
50
- completeSessionEnabled: boolean().optional().describe("Allow the agent to end its session. Once ended, it can only be restarted explicitly or via mail if wakeOnMessage is enabled. Disable on root agents to prevent the system from going unresponsive"),
50
+ completeSessionEnabled: boolean().optional().describe("Allow the agent to end its session. Once ended, it can only be restarted explicitly or via chat/mail if wakeOnMessage is enabled. Disable on root agents to prevent the system from going unresponsive"),
51
51
  debugPauseSeconds: number().int("Must be a whole number").min(0, "Must be non-negative").optional().describe("Seconds to wait at the debug prompt before auto-continuing, only applies when the agent's console is in focus. Set to 0 to continue immediately. Unset waits indefinitely for manual input"),
52
52
  wakeOnMessage: boolean().optional().describe("When mail or chat is received, start the agent automatically, or wake it from its wait state"),
53
53
  commandProtection: _enum([
@@ -724,7 +724,7 @@ var CompactMarkdown = ({ children }) => (0, import_jsx_runtime.jsx)(Markdown, {
724
724
  });
725
725
  //#endregion
726
726
  //#region ../../../packages/common-browser/dist/SecretField.js
727
- var SecretField = ({ value, onRotate, rotating }) => {
727
+ var SecretField = ({ value, onRotate, rotating, emptyLabel = "Not set" }) => {
728
728
  const [visible, setVisible] = (0, import_react.useState)(false);
729
729
  return (0, import_jsx_runtime.jsxs)(Group, {
730
730
  gap: "xs",
@@ -759,7 +759,7 @@ var SecretField = ({ value, onRotate, rotating }) => {
759
759
  ] }) : (0, import_jsx_runtime.jsx)(Text, {
760
760
  c: "dimmed",
761
761
  size: "sm",
762
- children: "Not set"
762
+ children: emptyLabel
763
763
  }), onRotate && (0, import_jsx_runtime.jsx)(Tooltip, {
764
764
  label: value ? "Rotate key" : "Generate API key",
765
765
  children: (0, import_jsx_runtime.jsx)(ActionIcon, {
@@ -1691,7 +1691,6 @@ CreateResponseSchema.extend({ runNo: number$2() });
1691
1691
  object$1({
1692
1692
  id: number$2(),
1693
1693
  username: string$1(),
1694
- apiKey: string$1().nullable().optional(),
1695
1694
  _links: array$1(HateoasLinkSchema).optional(),
1696
1695
  _actions: array$1(HateoasActionSchema).optional()
1697
1696
  });
@@ -1953,7 +1952,7 @@ object$1({
1953
1952
  isAgent: boolean$1(),
1954
1953
  createdAt: string$1(),
1955
1954
  updatedAt: string$1(),
1956
- apiKey: string$1().nullable().optional(),
1955
+ hasApiKey: boolean$1(),
1957
1956
  permissions: array$1(UserPermissionSchema),
1958
1957
  _links: array$1(any()).optional(),
1959
1958
  _actions: array$1(any()).optional()
@@ -9731,6 +9730,7 @@ var UserDetail = () => {
9731
9730
  const [editError, setEditError] = (0, import_react.useState)("");
9732
9731
  const [grantPerm, setGrantPerm] = (0, import_react.useState)(null);
9733
9732
  const [rotating, setRotating] = (0, import_react.useState)(false);
9733
+ const [apiKey, setApiKey] = (0, import_react.useState)(null);
9734
9734
  const [pwOpened, { open: openPw, close: closePw }] = useDisclosure();
9735
9735
  const [newPassword, setNewPassword] = (0, import_react.useState)("");
9736
9736
  const [pwSaving, setPwSaving] = (0, import_react.useState)(false);
@@ -9740,6 +9740,7 @@ var UserDetail = () => {
9740
9740
  setLoading(true);
9741
9741
  try {
9742
9742
  setUser(await api.get(apiEndpoints.user(routeUsername)));
9743
+ setApiKey(null);
9743
9744
  } catch {} finally {
9744
9745
  setLoading(false);
9745
9746
  }
@@ -9806,11 +9807,15 @@ var UserDetail = () => {
9806
9807
  };
9807
9808
  const handleRotateKey = async () => {
9808
9809
  if (!routeUsername) return;
9809
- if (!confirm("Rotate this user's API key? The old key will stop working immediately.")) return;
9810
+ if (!confirm("Generate a new API key? The old key will stop working immediately.")) return;
9810
9811
  setRotating(true);
9811
9812
  try {
9812
- await api.post(apiEndpoints.userRotateKey(routeUsername), {});
9813
- fetchUser();
9813
+ const result = await api.post(apiEndpoints.userRotateKey(routeUsername), {});
9814
+ setApiKey(result.apiKey ?? null);
9815
+ setUser((current) => current ? {
9816
+ ...current,
9817
+ hasApiKey: !!result.apiKey
9818
+ } : current);
9814
9819
  } catch (err) {
9815
9820
  showErrorNotification(err);
9816
9821
  } finally {
@@ -9912,12 +9917,28 @@ var UserDetail = () => {
9912
9917
  w: 120,
9913
9918
  children: "Type:"
9914
9919
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: user.isAgent ? "Agent" : "User" })] }),
9915
- (user.apiKey || hasAction(user._actions, "rotate-key")) && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Group, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, {
9920
+ supervisorAuth ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Group, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, {
9921
+ fw: 600,
9922
+ w: 120,
9923
+ children: "Credentials:"
9924
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Text, {
9925
+ size: "sm",
9926
+ children: [
9927
+ "Password and API key are managed in the",
9928
+ " ",
9929
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Anchor, {
9930
+ href: `/supervisor/users/${user.username}`,
9931
+ children: "supervisor"
9932
+ }),
9933
+ "."
9934
+ ]
9935
+ })] }) : (user.hasApiKey || hasAction(user._actions, "rotate-key")) && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Group, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, {
9916
9936
  fw: 600,
9917
9937
  w: 120,
9918
9938
  children: "API Key:"
9919
9939
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SecretField, {
9920
- value: user.apiKey ?? null,
9940
+ value: apiKey,
9941
+ emptyLabel: user.hasApiKey ? "Generated (hidden)" : "Not set",
9921
9942
  onRotate: hasAction(user._actions, "rotate-key") ? handleRotateKey : void 0,
9922
9943
  rotating
9923
9944
  })] })
@@ -45,7 +45,7 @@
45
45
  <meta name="format-detection" content="telephone=no" />
46
46
 
47
47
  <title>NAISYS ERP</title>
48
- <script type="module" crossorigin src="/erp/assets/index-jH5OYerq.js"></script>
48
+ <script type="module" crossorigin src="/erp/assets/index-CBngjRDx.js"></script>
49
49
  <link rel="modulepreload" crossorigin href="/erp/assets/rolldown-runtime-CvHMtSRF.js">
50
50
  <link rel="modulepreload" crossorigin href="/erp/assets/vendor-DFaFIeiT.js">
51
51
  <link rel="stylesheet" crossorigin href="/erp/assets/vendor-CLUPjUnv.css">
@@ -1,4 +1,4 @@
1
- import { AuthCache } from "@naisys/common";
1
+ import { AuthCache, urlMatchesPrefix } from "@naisys/common";
2
2
  import { extractBearerToken, hashToken, SESSION_COOKIE_NAME, } from "@naisys/common-node";
3
3
  import { findAgentByApiKey } from "@naisys/hub-database";
4
4
  import { findSession, findUserByApiKey } from "@naisys/supervisor-database";
@@ -13,6 +13,63 @@ async function loadPermissions(userId) {
13
13
  });
14
14
  return perms.map((p) => p.permission);
15
15
  }
16
+ async function materializeErpUser(localUser) {
17
+ return {
18
+ id: localUser.id,
19
+ username: localUser.username,
20
+ permissions: await loadPermissions(localUser.id),
21
+ };
22
+ }
23
+ async function resolveCookie(token) {
24
+ const tokenHash = hashToken(token);
25
+ return authCache.getOrLoad(`cookie:${tokenHash}`, async () => {
26
+ const localUser = isSupervisorAuth()
27
+ ? await loadCookieUserSso(tokenHash)
28
+ : await loadCookieUserStandalone(tokenHash);
29
+ return localUser ? materializeErpUser(localUser) : null;
30
+ });
31
+ }
32
+ async function loadCookieUserSso(tokenHash) {
33
+ const session = await findSession(tokenHash);
34
+ if (!session)
35
+ return null;
36
+ return erpDb.user.upsert({
37
+ where: { uuid: session.uuid },
38
+ create: { uuid: session.uuid, username: session.username },
39
+ update: {},
40
+ });
41
+ }
42
+ async function loadCookieUserStandalone(tokenHash) {
43
+ const session = await erpDb.session.findUnique({
44
+ where: { tokenHash, expiresAt: { gt: new Date() } },
45
+ include: { user: true },
46
+ });
47
+ return session?.user ?? null;
48
+ }
49
+ async function resolveApiKey(apiKey) {
50
+ const apiKeyHash = hashToken(apiKey);
51
+ return authCache.getOrLoad(`apikey:${apiKeyHash}`, async () => {
52
+ const localUser = isSupervisorAuth()
53
+ ? await loadApiKeyUserSso(apiKey)
54
+ : await erpDb.user.findUnique({ where: { apiKeyHash } });
55
+ return localUser ? materializeErpUser(localUser) : null;
56
+ });
57
+ }
58
+ async function loadApiKeyUserSso(apiKey) {
59
+ // Try supervisor DB (humans + agents with external keys),
60
+ // then hub DB (agents matching their hub-issued runtime key).
61
+ const supervisorUser = await findUserByApiKey(apiKey);
62
+ const hubAgent = supervisorUser ? null : await findAgentByApiKey(apiKey);
63
+ const match = supervisorUser ?? hubAgent;
64
+ if (!match)
65
+ return null;
66
+ const isAgent = supervisorUser?.isAgent ?? !!hubAgent;
67
+ return erpDb.user.upsert({
68
+ where: { uuid: match.uuid },
69
+ create: { uuid: match.uuid, username: match.username, isAgent },
70
+ update: {},
71
+ });
72
+ }
16
73
  export function hasPermission(user, permission) {
17
74
  if (!user)
18
75
  return false;
@@ -44,13 +101,11 @@ function isPublicRoute(url) {
44
101
  // Exact match: API root
45
102
  if (url === "/erp/api/" || url === "/erp/api")
46
103
  return true;
47
- // Prefix matches
48
104
  for (const prefix of PUBLIC_PREFIXES) {
49
- if (url.startsWith(prefix))
105
+ if (urlMatchesPrefix(url, prefix))
50
106
  return true;
51
107
  }
52
- // Schema routes
53
- if (url.startsWith("/erp/api/schemas"))
108
+ if (urlMatchesPrefix(url, "/erp/api/schemas"))
54
109
  return true;
55
110
  // Non-ERP-API paths (static files, supervisor routes, etc.)
56
111
  if (!url.startsWith("/erp/api"))
@@ -63,131 +118,18 @@ export function registerAuthMiddleware(fastify) {
63
118
  fastify.addHook("onRequest", async (request, reply) => {
64
119
  const token = request.cookies?.[SESSION_COOKIE_NAME];
65
120
  if (token) {
66
- const tokenHash = hashToken(token);
67
- const cacheKey = `cookie:${tokenHash}`;
68
- const cached = authCache.get(cacheKey);
69
- if (cached !== undefined) {
70
- // Cache hit (valid or negative)
71
- if (cached)
72
- request.erpUser = cached;
73
- }
74
- else if (isSupervisorAuth()) {
75
- // SSO mode: supervisor DB is source of truth for sessions
76
- const session = await findSession(tokenHash);
77
- if (session) {
78
- let localUser = await erpDb.user.findUnique({
79
- where: { uuid: session.uuid },
80
- });
81
- if (!localUser) {
82
- localUser = await erpDb.user.create({
83
- data: {
84
- uuid: session.uuid,
85
- username: session.username,
86
- passwordHash: "!sso-passkey-only",
87
- },
88
- });
89
- }
90
- const permissions = await loadPermissions(localUser.id);
91
- const erpUser = {
92
- id: localUser.id,
93
- username: localUser.username,
94
- permissions,
95
- };
96
- authCache.set(cacheKey, erpUser);
97
- request.erpUser = erpUser;
98
- }
99
- else {
100
- authCache.set(cacheKey, null);
101
- }
102
- }
103
- else {
104
- // Standalone mode: local session only
105
- const session = await erpDb.session.findUnique({
106
- where: {
107
- tokenHash,
108
- expiresAt: { gt: new Date() },
109
- },
110
- include: { user: true },
111
- });
112
- if (session) {
113
- const permissions = await loadPermissions(session.user.id);
114
- const erpUser = {
115
- id: session.user.id,
116
- username: session.user.username,
117
- permissions,
118
- };
119
- authCache.set(cacheKey, erpUser);
120
- request.erpUser = erpUser;
121
- }
122
- else {
123
- authCache.set(cacheKey, null);
124
- }
125
- }
121
+ const user = await resolveCookie(token);
122
+ if (user)
123
+ request.erpUser = user;
126
124
  }
127
- // API key auth (for agents / machine-to-machine)
128
125
  if (!request.erpUser) {
129
126
  const apiKey = extractBearerToken(request.headers.authorization);
130
127
  if (apiKey) {
131
- const apiKeyHash = hashToken(apiKey);
132
- const cacheKey = `apikey:${apiKeyHash}`;
133
- const cached = authCache.get(cacheKey);
134
- if (cached !== undefined) {
135
- if (cached)
136
- request.erpUser = cached;
137
- }
138
- else if (isSupervisorAuth()) {
139
- // SSO mode: try supervisor DB (human users), then hub DB (agents)
140
- const match = (await findUserByApiKey(apiKey)) ??
141
- (await findAgentByApiKey(apiKey));
142
- if (match) {
143
- let localUser = await erpDb.user.findUnique({
144
- where: { uuid: match.uuid },
145
- });
146
- if (!localUser) {
147
- localUser = await erpDb.user.create({
148
- data: {
149
- uuid: match.uuid,
150
- username: match.username,
151
- passwordHash: "!api-key-only",
152
- isAgent: true,
153
- },
154
- });
155
- }
156
- const permissions = await loadPermissions(localUser.id);
157
- const erpUser = {
158
- id: localUser.id,
159
- username: localUser.username,
160
- permissions,
161
- };
162
- authCache.set(cacheKey, erpUser);
163
- request.erpUser = erpUser;
164
- }
165
- else {
166
- authCache.set(cacheKey, null);
167
- }
168
- }
169
- else {
170
- // Standalone mode: check local ERP user table
171
- const localUser = await erpDb.user.findUnique({
172
- where: { apiKey },
173
- });
174
- if (localUser) {
175
- const permissions = await loadPermissions(localUser.id);
176
- const erpUser = {
177
- id: localUser.id,
178
- username: localUser.username,
179
- permissions,
180
- };
181
- authCache.set(cacheKey, erpUser);
182
- request.erpUser = erpUser;
183
- }
184
- else {
185
- authCache.set(cacheKey, null);
186
- }
187
- }
128
+ const user = await resolveApiKey(apiKey);
129
+ if (user)
130
+ request.erpUser = user;
188
131
  }
189
132
  }
190
- // Check if auth is required
191
133
  if (request.erpUser)
192
134
  return; // Authenticated, always allowed
193
135
  if (isPublicRoute(request.url))
package/dist/dbConfig.js CHANGED
@@ -6,5 +6,5 @@ export function erpDbUrl() {
6
6
  return "file:" + erpDbPath();
7
7
  }
8
8
  /** We run migration scripts if this is greater than what's in the schema_version table */
9
- export const ERP_DB_VERSION = 43;
9
+ export const ERP_DB_VERSION = 44;
10
10
  //# sourceMappingURL=dbConfig.js.map
package/dist/erpServer.js CHANGED
@@ -17,6 +17,7 @@ import Fastify from "fastify";
17
17
  import { jsonSchemaTransform, jsonSchemaTransformObject, serializerCompiler, validatorCompiler, } from "fastify-type-provider-zod";
18
18
  import path from "path";
19
19
  import { fileURLToPath } from "url";
20
+ import { takeCoverage } from "v8";
20
21
  import { registerApiReference } from "./api-reference.js";
21
22
  import { registerAuthMiddleware } from "./auth-middleware.js";
22
23
  import { ERP_DB_VERSION, erpDbPath } from "./dbConfig.js";
@@ -129,6 +130,9 @@ async function startServer(wizardRan) {
129
130
  const isProd = process.env.NODE_ENV === "production";
130
131
  const fastify = Fastify({
131
132
  pluginTimeout: 60_000,
133
+ // trustProxy: TLS terminates at the reverse proxy, so honor X-Forwarded-*
134
+ // headers — otherwise request.protocol reads the internal http hop.
135
+ trustProxy: true,
132
136
  logger: {
133
137
  transport: {
134
138
  target: "pino-pretty",
@@ -159,6 +163,12 @@ async function startServer(wizardRan) {
159
163
  fastify.get("/", { schema: { hide: true } }, async (_request, reply) => {
160
164
  return reply.redirect("/erp/");
161
165
  });
166
+ if (process.env.NODE_V8_COVERAGE) {
167
+ fastify.post("/erp/api/__coverage/flush", { schema: { hide: true } }, () => {
168
+ takeCoverage();
169
+ return { ok: true };
170
+ });
171
+ }
162
172
  const superAdminPassword = wizardRan && !isSupervisorAuth()
163
173
  ? await promptSuperAdminPassword("ERP Setup")
164
174
  : undefined;
@@ -196,7 +206,6 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
196
206
  },
197
207
  { key: "SERVER_PORT", label: "Server Port" },
198
208
  { key: "SUPERVISOR_AUTH", label: "Use Supervisor for Auth" },
199
- { key: "PUBLIC_READ", label: "Public Read Access" },
200
209
  ],
201
210
  },
202
211
  ],
@@ -207,7 +216,10 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
207
216
  wizardRan = await runSetupWizard(path.resolve(".env"), erpExampleUrl, erpWizardConfig);
208
217
  expandNaisysFolder();
209
218
  }
210
- wizardRan = (await ensureDotEnv(erpExampleUrl, erpWizardConfig)) || wizardRan;
219
+ if (process.env.NAISYS_SKIP_DOTENV_CHECK !== "1") {
220
+ wizardRan =
221
+ (await ensureDotEnv(erpExampleUrl, erpWizardConfig)) || wizardRan;
222
+ }
211
223
  const fastify = await startServer(wizardRan);
212
224
  let shuttingDown = false;
213
225
  const handleShutdown = async (signal) => {