@coffer-org/server 1.2.4 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2
+ import { type AuthUser } from './auth-store.ts';
3
+ declare module 'fastify' {
4
+ interface FastifyRequest {
5
+ user?: AuthUser;
6
+ }
7
+ }
8
+ export declare const PUBLIC_API_PATHS: string[];
9
+ export declare function resolveRequestUser(req: FastifyRequest): Promise<AuthUser | null>;
10
+ export declare function requireAdmin(req: FastifyRequest, reply: FastifyReply): boolean;
11
+ export declare function registerAuthApi(app: FastifyInstance): Promise<void>;
@@ -0,0 +1,184 @@
1
+ import { field } from '@coffer-org/sdk/fields';
2
+ import { countUsers, createUser, findUserByLogin, findUserById, getPasswordHash, listUsers, updateUser, deleteUser, countAdmins, createSession, resolveSession, deleteSession, createApiToken, resolveApiToken, listApiTokens, revokeApiToken, } from "./auth-store.js";
3
+ import { verifyPassword } from "./auth-crypto.js";
4
+ import { maskSecrets, preserveSecrets } from "./secrets.js";
5
+ import { rowMatch } from "./records-api.js";
6
+ import { tokenize } from '@coffer-org/core/search';
7
+ import { USERS_MODULE } from '@coffer-org/sdk/users-module';
8
+ const PASSWORD_FIELDS = [field.password({ key: 'password' })];
9
+ const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
10
+ const COOKIE_NAME = 'sid';
11
+ export const PUBLIC_API_PATHS = ['/api/auth/status', '/api/auth/login', '/api/auth/setup'];
12
+ function readCookie(req, name) {
13
+ const header = req.headers.cookie;
14
+ if (!header)
15
+ return undefined;
16
+ for (const part of header.split(';')) {
17
+ const [k, ...rest] = part.trim().split('=');
18
+ if (k === name)
19
+ return rest.join('=');
20
+ }
21
+ return undefined;
22
+ }
23
+ function setCookie(reply, value, maxAgeSec) {
24
+ const secure = process.env['NODE_ENV'] === 'production' ? '; Secure' : '';
25
+ reply.header('set-cookie', `${COOKIE_NAME}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${secure}`);
26
+ }
27
+ export async function resolveRequestUser(req) {
28
+ const auth = req.headers.authorization;
29
+ if (auth?.startsWith('Bearer ')) {
30
+ return resolveApiToken(auth.slice('Bearer '.length));
31
+ }
32
+ const sid = readCookie(req, COOKIE_NAME);
33
+ if (!sid)
34
+ return null;
35
+ return resolveSession(sid);
36
+ }
37
+ export function requireAdmin(req, reply) {
38
+ if (req.user?.role !== 'admin') {
39
+ reply.code(403).send({ error: 'forbidden' });
40
+ return false;
41
+ }
42
+ return true;
43
+ }
44
+ function publicUser(u) {
45
+ return { id: u.id, login: u.login, displayName: u.displayName, role: u.role, disabled: u.disabled };
46
+ }
47
+ function withMaskedPassword(u) {
48
+ return maskSecrets(PASSWORD_FIELDS, { ...publicUser(u), password: 'x' });
49
+ }
50
+ async function resolvePasswordPatch(userId, rawPassword) {
51
+ const existingHash = await getPasswordHash(userId);
52
+ const preserved = preserveSecrets(PASSWORD_FIELDS, { password: rawPassword }, { password: existingHash ?? undefined });
53
+ const val = preserved['password'];
54
+ if (!val || val === existingHash)
55
+ return undefined;
56
+ return val;
57
+ }
58
+ export async function registerAuthApi(app) {
59
+ app.get('/api/auth/status', async () => ({ needsSetup: (await countUsers()) === 0 }));
60
+ app.post('/api/auth/setup', async (req, reply) => {
61
+ if ((await countUsers()) > 0)
62
+ return reply.code(409).send({ error: 'already_setup' });
63
+ const body = (req.body ?? {});
64
+ if (!body.login || !body.password || !body.displayName) {
65
+ return reply.code(422).send({ issues: [{ field: !body.login ? 'login' : !body.password ? 'password' : 'displayName', code: 'required' }] });
66
+ }
67
+ const user = await createUser({ login: body.login, password: body.password, displayName: body.displayName, role: 'admin' });
68
+ const { raw } = await createSession(user.id, SESSION_TTL_MS);
69
+ setCookie(reply, raw, SESSION_TTL_MS / 1000);
70
+ return publicUser(user);
71
+ });
72
+ app.post('/api/auth/login', async (req, reply) => {
73
+ const body = (req.body ?? {});
74
+ if (!body.login || !body.password)
75
+ return reply.code(422).send({ issues: [{ field: 'login', code: 'required' }] });
76
+ const row = await findUserByLogin(body.login);
77
+ if (!row || row.disabled || !(await verifyPassword(body.password, row.passwordHash))) {
78
+ return reply.code(401).send({ error: 'invalid_credentials' });
79
+ }
80
+ const { raw } = await createSession(row.id, SESSION_TTL_MS);
81
+ setCookie(reply, raw, SESSION_TTL_MS / 1000);
82
+ return publicUser(row);
83
+ });
84
+ app.post('/api/auth/logout', async (req, reply) => {
85
+ const sid = readCookie(req, COOKIE_NAME);
86
+ if (sid)
87
+ await deleteSession(sid);
88
+ setCookie(reply, '', 0);
89
+ return { ok: true };
90
+ });
91
+ app.get('/api/auth/me', async (req, reply) => {
92
+ if (!req.user)
93
+ return reply.code(401).send({ error: 'unauthorized' });
94
+ return withMaskedPassword(req.user);
95
+ });
96
+ app.patch('/api/auth/me', async (req, reply) => {
97
+ if (!req.user)
98
+ return reply.code(401).send({ error: 'unauthorized' });
99
+ const body = (req.body ?? {});
100
+ const password = await resolvePasswordPatch(req.user.id, body.password);
101
+ const updated = await updateUser(req.user.id, { displayName: body.displayName, password });
102
+ return withMaskedPassword(updated);
103
+ });
104
+ app.get('/api/auth/users', async (req, reply) => {
105
+ if (!requireAdmin(req, reply))
106
+ return;
107
+ const { q } = req.query;
108
+ const tokens = q ? tokenize(q) : [];
109
+ let users = await listUsers();
110
+ if (tokens.length > 0) {
111
+ users = users.filter((u) => rowMatch(USERS_MODULE, u, tokens) !== null);
112
+ }
113
+ return users.map(withMaskedPassword);
114
+ });
115
+ app.post('/api/auth/users', async (req, reply) => {
116
+ if (!requireAdmin(req, reply))
117
+ return;
118
+ const body = (req.body ?? {});
119
+ if (!body.login || !body.password || !body.displayName || (body.role !== 'admin' && body.role !== 'member')) {
120
+ return reply.code(422).send({ issues: [{ field: 'login', code: 'required' }] });
121
+ }
122
+ const user = await createUser({ login: body.login, password: body.password, displayName: body.displayName, role: body.role });
123
+ return publicUser(user);
124
+ });
125
+ app.patch('/api/auth/users/:id', async (req, reply) => {
126
+ if (!requireAdmin(req, reply))
127
+ return;
128
+ const id = Number(req.params.id);
129
+ const body = (req.body ?? {});
130
+ const wantsDisabled = body.disabled === undefined ? undefined : Boolean(body.disabled);
131
+ const demotingOrDisabling = (body.role === 'member' || wantsDisabled === true) && (await isLastAdmin(id));
132
+ if (demotingOrDisabling)
133
+ return reply.code(409).send({ error: 'last_admin' });
134
+ const password = await resolvePasswordPatch(id, body.password);
135
+ const updated = await updateUser(id, {
136
+ displayName: body.displayName,
137
+ role: body.role === 'admin' || body.role === 'member' ? body.role : undefined,
138
+ disabled: wantsDisabled,
139
+ password,
140
+ });
141
+ if (!updated)
142
+ return reply.code(404).send({ error: 'not_found' });
143
+ return withMaskedPassword(updated);
144
+ });
145
+ app.delete('/api/auth/users/:id', async (req, reply) => {
146
+ if (!requireAdmin(req, reply))
147
+ return;
148
+ const id = Number(req.params.id);
149
+ if (await isLastAdmin(id))
150
+ return reply.code(409).send({ error: 'last_admin' });
151
+ const ok = await deleteUser(id);
152
+ if (!ok)
153
+ return reply.code(404).send({ error: 'not_found' });
154
+ return { ok: true };
155
+ });
156
+ async function isLastAdmin(id) {
157
+ const user = await findUserById(id);
158
+ return user?.role === 'admin' && (await countAdmins(id)) === 0;
159
+ }
160
+ app.get('/api/auth/tokens', async (req, reply) => {
161
+ if (!req.user)
162
+ return reply.code(401).send({ error: 'unauthorized' });
163
+ return listApiTokens(req.user.id);
164
+ });
165
+ app.post('/api/auth/tokens', async (req, reply) => {
166
+ if (!req.user)
167
+ return reply.code(401).send({ error: 'unauthorized' });
168
+ const body = (req.body ?? {});
169
+ if (!body.name || !body.token) {
170
+ return reply.code(422).send({ issues: [{ field: !body.name ? 'name' : 'token', code: 'required' }] });
171
+ }
172
+ const { id, createdAt } = await createApiToken(req.user.id, body.name, body.token);
173
+ return { id, createdAt };
174
+ });
175
+ app.delete('/api/auth/tokens/:id', async (req, reply) => {
176
+ if (!req.user)
177
+ return reply.code(401).send({ error: 'unauthorized' });
178
+ const id = Number(req.params.id);
179
+ const ok = await revokeApiToken(id, req.user.id);
180
+ if (!ok)
181
+ return reply.code(404).send({ error: 'not_found' });
182
+ return { ok: true };
183
+ });
184
+ }
@@ -0,0 +1,4 @@
1
+ export declare function hashPassword(password: string): Promise<string>;
2
+ export declare function verifyPassword(password: string, stored: string): Promise<boolean>;
3
+ export declare function generateToken(): string;
4
+ export declare function hashToken(raw: string): string;
@@ -0,0 +1,24 @@
1
+ import { randomBytes, scrypt as scryptCb, timingSafeEqual, createHash } from 'node:crypto';
2
+ import { promisify } from 'node:util';
3
+ const scrypt = promisify(scryptCb);
4
+ const KEYLEN = 64;
5
+ export async function hashPassword(password) {
6
+ const salt = randomBytes(16);
7
+ const derived = await scrypt(password, salt, KEYLEN);
8
+ return `${salt.toString('hex')}:${derived.toString('hex')}`;
9
+ }
10
+ export async function verifyPassword(password, stored) {
11
+ const [saltHex, hashHex] = stored.split(':');
12
+ if (!saltHex || !hashHex)
13
+ return false;
14
+ const salt = Buffer.from(saltHex, 'hex');
15
+ const expected = Buffer.from(hashHex, 'hex');
16
+ const derived = await scrypt(password, salt, expected.length);
17
+ return derived.length === expected.length && timingSafeEqual(derived, expected);
18
+ }
19
+ export function generateToken() {
20
+ return randomBytes(32).toString('base64url');
21
+ }
22
+ export function hashToken(raw) {
23
+ return createHash('sha256').update(raw).digest('hex');
24
+ }
@@ -0,0 +1,46 @@
1
+ export interface AuthUser {
2
+ id: number;
3
+ login: string;
4
+ displayName: string;
5
+ role: 'admin' | 'member';
6
+ disabled: boolean;
7
+ }
8
+ export declare function countUsers(): Promise<number>;
9
+ export declare function createUser(input: {
10
+ login: string;
11
+ password: string;
12
+ displayName: string;
13
+ role: 'admin' | 'member';
14
+ }): Promise<AuthUser>;
15
+ export declare function findUserByLogin(login: string): Promise<(AuthUser & {
16
+ passwordHash: string;
17
+ }) | null>;
18
+ export declare function findUserById(id: number): Promise<AuthUser | null>;
19
+ export declare function getPasswordHash(id: number): Promise<string | null>;
20
+ export declare function listUsers(): Promise<AuthUser[]>;
21
+ export declare function updateUser(id: number, patch: {
22
+ displayName?: string;
23
+ role?: 'admin' | 'member';
24
+ disabled?: boolean;
25
+ password?: string;
26
+ }): Promise<AuthUser | null>;
27
+ export declare function deleteUser(id: number): Promise<boolean>;
28
+ export declare function countAdmins(excluding?: number): Promise<number>;
29
+ export declare function createSession(userId: number, ttlMs: number): Promise<{
30
+ raw: string;
31
+ expiresAt: string;
32
+ }>;
33
+ export declare function resolveSession(raw: string): Promise<AuthUser | null>;
34
+ export declare function deleteSession(raw: string): Promise<void>;
35
+ export declare function createApiToken(userId: number, name: string, rawToken: string): Promise<{
36
+ id: number;
37
+ createdAt: string;
38
+ }>;
39
+ export declare function resolveApiToken(raw: string): Promise<AuthUser | null>;
40
+ export declare function listApiTokens(userId: number): Promise<{
41
+ id: number;
42
+ name: string;
43
+ createdAt: string;
44
+ lastUsedAt: string | null;
45
+ }[]>;
46
+ export declare function revokeApiToken(id: number, userId: number): Promise<boolean>;
@@ -0,0 +1,145 @@
1
+ import { getEm } from "./db.js";
2
+ import { hashPassword, generateToken, hashToken } from "./auth-crypto.js";
3
+ function toAuthUser(row) {
4
+ return { id: row.id, login: row.login, displayName: row.display_name, role: row.role, disabled: Boolean(row.disabled) };
5
+ }
6
+ export async function countUsers() {
7
+ const em = getEm().fork();
8
+ return em.count('_User', {});
9
+ }
10
+ export async function createUser(input) {
11
+ const em = getEm().fork();
12
+ const row = em.create('_User', {
13
+ login: input.login,
14
+ password_hash: await hashPassword(input.password),
15
+ display_name: input.displayName,
16
+ role: input.role,
17
+ disabled: 0,
18
+ created_at: new Date().toISOString(),
19
+ });
20
+ await em.flush();
21
+ return toAuthUser(row);
22
+ }
23
+ export async function findUserByLogin(login) {
24
+ const em = getEm().fork();
25
+ const row = (await em.findOne('_User', { login }));
26
+ return row ? { ...toAuthUser(row), passwordHash: row.password_hash } : null;
27
+ }
28
+ export async function findUserById(id) {
29
+ const em = getEm().fork();
30
+ const row = (await em.findOne('_User', { id }));
31
+ return row ? toAuthUser(row) : null;
32
+ }
33
+ export async function getPasswordHash(id) {
34
+ const em = getEm().fork();
35
+ const row = (await em.findOne('_User', { id }));
36
+ return row ? row.password_hash : null;
37
+ }
38
+ export async function listUsers() {
39
+ const em = getEm().fork();
40
+ const rows = (await em.find('_User', {}));
41
+ return rows.map(toAuthUser);
42
+ }
43
+ export async function updateUser(id, patch) {
44
+ const em = getEm().fork();
45
+ const row = (await em.findOne('_User', { id }));
46
+ if (!row)
47
+ return null;
48
+ const changes = {};
49
+ if (patch.displayName !== undefined)
50
+ changes['display_name'] = patch.displayName;
51
+ if (patch.role !== undefined)
52
+ changes['role'] = patch.role;
53
+ if (patch.disabled !== undefined)
54
+ changes['disabled'] = patch.disabled ? 1 : 0;
55
+ if (patch.password)
56
+ changes['password_hash'] = await hashPassword(patch.password);
57
+ em.assign(row, changes);
58
+ await em.flush();
59
+ return toAuthUser(row);
60
+ }
61
+ export async function deleteUser(id) {
62
+ const em = getEm().fork();
63
+ const row = await em.findOne('_User', { id });
64
+ if (!row)
65
+ return false;
66
+ em.remove(row);
67
+ await em.flush();
68
+ return true;
69
+ }
70
+ export async function countAdmins(excluding) {
71
+ const em = getEm().fork();
72
+ const rows = (await em.find('_User', { role: 'admin', disabled: 0 }));
73
+ return rows.filter((r) => r.id !== excluding).length;
74
+ }
75
+ export async function createSession(userId, ttlMs) {
76
+ const em = getEm().fork();
77
+ const raw = generateToken();
78
+ const expiresAt = new Date(Date.now() + ttlMs).toISOString();
79
+ em.create('_Session', {
80
+ token_hash: hashToken(raw),
81
+ user_id: userId,
82
+ expires_at: expiresAt,
83
+ created_at: new Date().toISOString(),
84
+ });
85
+ await em.flush();
86
+ return { raw, expiresAt };
87
+ }
88
+ export async function resolveSession(raw) {
89
+ const em = getEm().fork();
90
+ const session = (await em.findOne('_Session', { token_hash: hashToken(raw) }));
91
+ if (!session)
92
+ return null;
93
+ if (new Date(session.expires_at).getTime() < Date.now())
94
+ return null;
95
+ const user = await findUserById(session.user_id);
96
+ return user && !user.disabled ? user : null;
97
+ }
98
+ export async function deleteSession(raw) {
99
+ const em = getEm().fork();
100
+ const session = await em.findOne('_Session', { token_hash: hashToken(raw) });
101
+ if (session) {
102
+ em.remove(session);
103
+ await em.flush();
104
+ }
105
+ }
106
+ export async function createApiToken(userId, name, rawToken) {
107
+ const em = getEm().fork();
108
+ const createdAt = new Date().toISOString();
109
+ const row = em.create('_ApiToken', {
110
+ user_id: userId,
111
+ name,
112
+ token_hash: hashToken(rawToken),
113
+ created_at: createdAt,
114
+ last_used_at: null,
115
+ revoked: 0,
116
+ });
117
+ await em.flush();
118
+ return { id: row.id, createdAt };
119
+ }
120
+ export async function resolveApiToken(raw) {
121
+ const em = getEm().fork();
122
+ const token = (await em.findOne('_ApiToken', { token_hash: hashToken(raw), revoked: 0 }));
123
+ if (!token)
124
+ return null;
125
+ const user = await findUserById(token.user_id);
126
+ if (!user || user.disabled)
127
+ return null;
128
+ em.assign(token, { last_used_at: new Date().toISOString() });
129
+ await em.flush();
130
+ return user;
131
+ }
132
+ export async function listApiTokens(userId) {
133
+ const em = getEm().fork();
134
+ const rows = (await em.find('_ApiToken', { user_id: userId, revoked: 0 }));
135
+ return rows.map((r) => ({ id: r.id, name: r.name, createdAt: r.created_at, lastUsedAt: r.last_used_at }));
136
+ }
137
+ export async function revokeApiToken(id, userId) {
138
+ const em = getEm().fork();
139
+ const row = await em.findOne('_ApiToken', { id, user_id: userId });
140
+ if (!row)
141
+ return false;
142
+ em.assign(row, { revoked: 1 });
143
+ await em.flush();
144
+ return true;
145
+ }
@@ -114,10 +114,9 @@ export function flattenEmbeddedAt(fields, input) {
114
114
  const nested = out[key];
115
115
  delete out[key];
116
116
  if (nested && typeof nested === 'object') {
117
- for (const f of g.fields) {
118
- if ('key' in f)
119
- out[`${key}__${f.key}`] = nested[f.key];
120
- }
117
+ const flatChild = flattenEmbeddedAt(g.fields, nested);
118
+ for (const [sub, val] of Object.entries(flatChild))
119
+ out[`${key}__${sub}`] = val;
121
120
  }
122
121
  }
123
122
  for (const [key, subs] of columnsTargets(fields)) {
@@ -146,20 +145,18 @@ export function nestEmbeddedAt(fields, row) {
146
145
  out[key] = nested;
147
146
  }
148
147
  for (const [key, g] of embeddedGroups(fields)) {
149
- const nested = {};
148
+ const inner = {};
150
149
  let any = false;
151
- for (const f of g.fields) {
152
- if (!('key' in f))
150
+ const prefix = `${key}__`;
151
+ for (const col of Object.keys(out)) {
152
+ if (!col.startsWith(prefix))
153
153
  continue;
154
- const col = `${key}__${f.key}`;
155
- if (col in out) {
156
- nested[f.key] = out[col];
157
- delete out[col];
158
- any = true;
159
- }
154
+ inner[col.slice(prefix.length)] = out[col];
155
+ delete out[col];
156
+ any = true;
160
157
  }
161
158
  if (any)
162
- out[key] = nested;
159
+ out[key] = nestEmbeddedAt(g.fields, inner);
163
160
  }
164
161
  return out;
165
162
  }
@@ -18,4 +18,7 @@ export declare const EmbeddingSchema: EntitySchema<any, never, import("@mikro-or
18
18
  export declare const PluginStateSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
19
19
  export declare function buildPluginEntities(plugins: PluginManifest[]): EntitySchema[];
20
20
  export declare const MsgLogSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
21
+ export declare const UserSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
22
+ export declare const SessionSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
23
+ export declare const ApiTokenSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
21
24
  export declare const systemEntities: EntitySchema[];
@@ -176,7 +176,44 @@ export const MsgLogSchema = new EntitySchema({
176
176
  ms: { type: 'integer', nullable: true },
177
177
  },
178
178
  });
179
+ export const UserSchema = new EntitySchema({
180
+ name: '_User',
181
+ tableName: '_users',
182
+ properties: {
183
+ id: { type: 'integer', primary: true, autoincrement: true },
184
+ login: { type: 'text', unique: true },
185
+ password_hash: { type: 'text' },
186
+ display_name: { type: 'text' },
187
+ role: { type: 'text' },
188
+ disabled: { type: 'integer' },
189
+ created_at: { type: 'text' },
190
+ },
191
+ });
192
+ export const SessionSchema = new EntitySchema({
193
+ name: '_Session',
194
+ tableName: '_sessions',
195
+ properties: {
196
+ token_hash: { type: 'text', primary: true },
197
+ user_id: { type: 'integer' },
198
+ expires_at: { type: 'text' },
199
+ created_at: { type: 'text' },
200
+ },
201
+ });
202
+ export const ApiTokenSchema = new EntitySchema({
203
+ name: '_ApiToken',
204
+ tableName: '_api_tokens',
205
+ properties: {
206
+ id: { type: 'integer', primary: true, autoincrement: true },
207
+ user_id: { type: 'integer' },
208
+ name: { type: 'text' },
209
+ token_hash: { type: 'text', unique: true },
210
+ created_at: { type: 'text' },
211
+ last_used_at: { type: 'text', nullable: true },
212
+ revoked: { type: 'integer' },
213
+ },
214
+ });
179
215
  export const systemEntities = [
180
216
  EventSchema, PluginRowSchema, MigrationRowSchema, SeedRowSchema,
181
217
  EmbeddingSchema, PluginStateSchema, MsgLogSchema,
218
+ UserSchema, SessionSchema, ApiTokenSchema,
182
219
  ];
package/dist/index.js CHANGED
@@ -26,6 +26,9 @@ import { resolveEmbed } from "./embed.js";
26
26
  import { resolveWithinHome, filterSort, listDir } from "./fs-list.js";
27
27
  import { initPlugins, teardownPlugins, readDisabled, purgePluginData, getPluginSettings, getPlugins, } from "./plugin-runtime.js";
28
28
  import { registerPluginsApi } from "./plugins-api.js";
29
+ import { registerAuthApi, resolveRequestUser, requireAdmin, PUBLIC_API_PATHS } from "./auth-api.js";
30
+ import { discoverPluginAssets } from "./plugin-discovery.js";
31
+ import { checkLatestVersion, resolveUpdateTarget, runNpmInstall } from "./plugin-updates.js";
29
32
  import { buildClientSchema } from "./schema-api.js";
30
33
  import { recordList, recordGet, recordCreate, recordUpdate, recordDelete, rowMatch, UnknownTypeError } from "./records-api.js";
31
34
  import { maskTree, preserveTree } from "./secrets.js";
@@ -54,6 +57,17 @@ app.addHook('onSend', async (req, reply, payload) => {
54
57
  }
55
58
  return payload;
56
59
  });
60
+ app.addHook('onRequest', async (req, reply) => {
61
+ const gated = req.url.startsWith('/api/') || req.url.startsWith('/uploads/');
62
+ if (!gated)
63
+ return;
64
+ if (PUBLIC_API_PATHS.some((p) => req.url.startsWith(p)))
65
+ return;
66
+ const user = await resolveRequestUser(req);
67
+ if (!user)
68
+ return reply.code(401).send({ error: 'unauthorized' });
69
+ req.user = user;
70
+ });
57
71
  app.post('/api/upload', async (req, reply) => {
58
72
  const data = await req.file();
59
73
  if (!data)
@@ -144,6 +158,8 @@ app.get('/api/search', async (req) => {
144
158
  return scored.slice(0, lim).map((s) => s.result);
145
159
  });
146
160
  app.patch('/api/plugins/:id', async (req, reply) => {
161
+ if (!requireAdmin(req, reply))
162
+ return;
147
163
  const { id } = req.params;
148
164
  const { enabled } = (req.body ?? {});
149
165
  const plugins = await getPlugins();
@@ -176,6 +192,8 @@ app.patch('/api/plugins/:id', async (req, reply) => {
176
192
  return { ok: true, restartRequired: true };
177
193
  });
178
194
  app.delete('/api/plugins/:id/data', async (req, reply) => {
195
+ if (!requireAdmin(req, reply))
196
+ return;
179
197
  const { id } = req.params;
180
198
  const plugins = await getPlugins();
181
199
  const p = plugins.find((x) => x.id === id);
@@ -191,7 +209,27 @@ app.delete('/api/plugins/:id/data', async (req, reply) => {
191
209
  await purgePluginData(p, 'gui');
192
210
  return { ok: true, restartRequired: true };
193
211
  });
212
+ app.post('/api/plugins/:id/update', async (req, reply) => {
213
+ if (!requireAdmin(req, reply))
214
+ return;
215
+ const { id } = req.params;
216
+ const assets = await discoverPluginAssets();
217
+ const rec = assets.find((a) => a.id === id);
218
+ const latest = rec ? await checkLatestVersion(rec.packageName) : null;
219
+ const target = resolveUpdateTarget(assets, id, latest);
220
+ if (!target.ok) {
221
+ return reply.code(target.error === 'unknown_plugin' ? 404 : 400).send({ error: target.error });
222
+ }
223
+ const result = await runNpmInstall(target.packageName, target.version, process.cwd());
224
+ if (!result.ok) {
225
+ return reply.code(500).send({ error: 'install_failed', stderr: result.stderr });
226
+ }
227
+ reply.send({ ok: true });
228
+ setTimeout(() => process.exit(0), 500);
229
+ });
194
230
  app.get('/api/plugins/:id/settings', async (req, reply) => {
231
+ if (!requireAdmin(req, reply))
232
+ return;
195
233
  const { id } = req.params;
196
234
  const plugins = await getPlugins();
197
235
  const p = plugins.find((x) => x.id === id);
@@ -201,33 +239,39 @@ app.get('/api/plugins/:id/settings', async (req, reply) => {
201
239
  const row = await getPluginSettings(id);
202
240
  return maskTree(p.settings.fields, row);
203
241
  });
204
- app.put('/api/plugins/:id/settings', (req, reply) => guard(reply, async () => {
205
- const { id } = req.params;
206
- const plugins = await getPlugins();
207
- const p = plugins.find((x) => x.id === id);
208
- if (!p || !p.settings || p.settings.fields.length === 0) {
209
- return reply.code(404).send({ error: 'no_settings' });
210
- }
211
- const syntheticMod = {
212
- vault: '_settings',
213
- module: id,
214
- label: `${id}.plugin.label`,
215
- fields: p.settings.fields,
216
- };
217
- const existing = await getPluginSettings(id);
218
- const body = preserveTree(p.settings.fields, (req.body ?? {}), existing);
219
- const parsed = buildZodObject(syntheticMod).safeParse(body);
220
- if (!parsed.success) {
221
- throw new ValidationError(parsed.error.issues.map(toIssue));
222
- }
223
- const entityName = `_settings__${id}`;
224
- const row = { plugin_id: id, ...parsed.data };
225
- await getEm()
226
- .fork()
227
- .upsert(entityName, row);
228
- return { ok: true, row: maskTree(p.settings.fields, row) };
229
- }));
230
- app.post('/api/plugins/nextcloud/test', async (_req, reply) => {
242
+ app.put('/api/plugins/:id/settings', (req, reply) => {
243
+ if (!requireAdmin(req, reply))
244
+ return;
245
+ return guard(reply, async () => {
246
+ const { id } = req.params;
247
+ const plugins = await getPlugins();
248
+ const p = plugins.find((x) => x.id === id);
249
+ if (!p || !p.settings || p.settings.fields.length === 0) {
250
+ return reply.code(404).send({ error: 'no_settings' });
251
+ }
252
+ const syntheticMod = {
253
+ vault: '_settings',
254
+ module: id,
255
+ label: `${id}.plugin.label`,
256
+ fields: p.settings.fields,
257
+ };
258
+ const existing = await getPluginSettings(id);
259
+ const body = preserveTree(p.settings.fields, (req.body ?? {}), existing);
260
+ const parsed = buildZodObject(syntheticMod).safeParse(body);
261
+ if (!parsed.success) {
262
+ throw new ValidationError(parsed.error.issues.map(toIssue));
263
+ }
264
+ const entityName = `_settings__${id}`;
265
+ const row = { plugin_id: id, ...parsed.data };
266
+ await getEm()
267
+ .fork()
268
+ .upsert(entityName, row);
269
+ return { ok: true, row: maskTree(p.settings.fields, row) };
270
+ });
271
+ });
272
+ app.post('/api/plugins/nextcloud/test', async (req, reply) => {
273
+ if (!requireAdmin(req, reply))
274
+ return;
231
275
  const settings = await getPluginSettings('nextcloud');
232
276
  const url = settings['nextcloud_url'];
233
277
  const login = settings['login'];
@@ -242,7 +286,9 @@ app.post('/api/plugins/nextcloud/test', async (_req, reply) => {
242
286
  return { ok: true };
243
287
  return reply.code(400).send({ error: result.error });
244
288
  });
245
- app.post('/api/plugins/finance/test', async (_req, reply) => {
289
+ app.post('/api/plugins/finance/test', async (req, reply) => {
290
+ if (!requireAdmin(req, reply))
291
+ return;
246
292
  const settings = await getPluginSettings('finance');
247
293
  const url = settings['firefly_url'];
248
294
  const token = settings['token'];
@@ -256,7 +302,9 @@ app.post('/api/plugins/finance/test', async (_req, reply) => {
256
302
  return { ok: true };
257
303
  return reply.code(400).send({ error: result.error });
258
304
  });
259
- app.post('/api/plugins/finance/sync', async (_req, reply) => {
305
+ app.post('/api/plugins/finance/sync', async (req, reply) => {
306
+ if (!requireAdmin(req, reply))
307
+ return;
260
308
  const finRuntime = '@coffer-org/plugin-finance/runtime';
261
309
  const { runSync } = (await import(__rewriteRelativeImportExtension(finRuntime)));
262
310
  try {
@@ -267,7 +315,9 @@ app.post('/api/plugins/finance/sync', async (_req, reply) => {
267
315
  return reply.code(400).send({ error: e.message });
268
316
  }
269
317
  });
270
- app.post('/api/plugins/finance/import-wise', async (_req, reply) => {
318
+ app.post('/api/plugins/finance/import-wise', async (req, reply) => {
319
+ if (!requireAdmin(req, reply))
320
+ return;
271
321
  const finRuntime = '@coffer-org/plugin-finance/runtime';
272
322
  const { importWise } = (await import(__rewriteRelativeImportExtension(finRuntime)));
273
323
  try {
@@ -277,7 +327,9 @@ app.post('/api/plugins/finance/import-wise', async (_req, reply) => {
277
327
  return reply.code(400).send({ error: e.message });
278
328
  }
279
329
  });
280
- app.post('/api/plugins/devices/scan', async (_req, reply) => {
330
+ app.post('/api/plugins/devices/scan', async (req, reply) => {
331
+ if (!requireAdmin(req, reply))
332
+ return;
281
333
  const devRuntime = '@coffer-org/plugin-devices/runtime';
282
334
  const { runScan } = (await import(__rewriteRelativeImportExtension(devRuntime)));
283
335
  try {
@@ -292,7 +344,9 @@ app.post('/api/plugins/devices/scan', async (_req, reply) => {
292
344
  return reply.code(409).send({ error: e.message });
293
345
  }
294
346
  });
295
- app.post('/api/plugins/media/test', async (_req, reply) => {
347
+ app.post('/api/plugins/media/test', async (req, reply) => {
348
+ if (!requireAdmin(req, reply))
349
+ return;
296
350
  const settings = await getPluginSettings('media');
297
351
  const url = settings['jellyfin_url'];
298
352
  const token = settings['jellyfin_token'];
@@ -306,7 +360,9 @@ app.post('/api/plugins/media/test', async (_req, reply) => {
306
360
  return { ok: true };
307
361
  return reply.code(400).send({ error: result.error });
308
362
  });
309
- app.post('/api/plugins/media/sync', async (_req, reply) => {
363
+ app.post('/api/plugins/media/sync', async (req, reply) => {
364
+ if (!requireAdmin(req, reply))
365
+ return;
310
366
  const mediaRuntime = '@coffer-org/plugin-media/runtime';
311
367
  const { runJellyfinSync } = (await import(__rewriteRelativeImportExtension(mediaRuntime)));
312
368
  try {
@@ -318,6 +374,8 @@ app.post('/api/plugins/media/sync', async (_req, reply) => {
318
374
  }
319
375
  });
320
376
  app.post('/api/plugins/media/add', async (req, reply) => {
377
+ if (!requireAdmin(req, reply))
378
+ return;
321
379
  const { query, tmdb_id, kind } = (req.body ?? {});
322
380
  const mediaRuntime = '@coffer-org/plugin-media/runtime';
323
381
  const { addTitle } = (await import(__rewriteRelativeImportExtension(mediaRuntime)));
@@ -457,6 +515,7 @@ app.delete('/api/:vault/:type/:id', (req, reply) => guard(reply, async () => {
457
515
  throw e;
458
516
  }
459
517
  }));
518
+ await registerAuthApi(app);
460
519
  await registerPluginsApi(app);
461
520
  const WEB_DIST = process.env.WEB_DIST;
462
521
  if (WEB_DIST && existsSync(join(WEB_DIST, 'index.html'))) {
@@ -0,0 +1,15 @@
1
+ import type { PluginAssetRecord } from './plugin-discovery.ts';
2
+ export declare function checkLatestVersion(pkgName: string): Promise<string | null>;
3
+ export type UpdateTarget = {
4
+ ok: true;
5
+ packageName: string;
6
+ version: string;
7
+ } | {
8
+ ok: false;
9
+ error: 'unknown_plugin' | 'no_update_available';
10
+ };
11
+ export declare function resolveUpdateTarget(assets: PluginAssetRecord[], id: string, latestVersion: string | null): UpdateTarget;
12
+ export declare function runNpmInstall(packageName: string, version: string, cwd: string): Promise<{
13
+ ok: boolean;
14
+ stderr: string;
15
+ }>;
@@ -0,0 +1,52 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { getPluginState, setPluginState } from "./plugin-state.js";
3
+ const TTL_MS = 60 * 60 * 1000;
4
+ const STATE_PLUGIN = 'core';
5
+ const stateKey = (pkgName) => `update-check:${pkgName}`;
6
+ function readCache(raw) {
7
+ if (!raw)
8
+ return null;
9
+ try {
10
+ return JSON.parse(raw);
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ export async function checkLatestVersion(pkgName) {
17
+ const key = stateKey(pkgName);
18
+ let cached = null;
19
+ try {
20
+ cached = readCache(await getPluginState(STATE_PLUGIN, key));
21
+ if (cached && Date.now() - cached.checkedAt < TTL_MS)
22
+ return cached.latestVersion;
23
+ const res = await fetch(`https://registry.npmjs.org/${pkgName}/latest`, { signal: AbortSignal.timeout(5000) });
24
+ if (!res.ok)
25
+ return cached?.latestVersion ?? null;
26
+ const data = (await res.json());
27
+ if (!data.version)
28
+ return cached?.latestVersion ?? null;
29
+ await setPluginState(STATE_PLUGIN, key, JSON.stringify({ latestVersion: data.version, checkedAt: Date.now() }));
30
+ return data.version;
31
+ }
32
+ catch {
33
+ return cached?.latestVersion ?? null;
34
+ }
35
+ }
36
+ export function resolveUpdateTarget(assets, id, latestVersion) {
37
+ const rec = assets.find((a) => a.id === id);
38
+ if (!rec)
39
+ return { ok: false, error: 'unknown_plugin' };
40
+ if (!latestVersion || latestVersion === rec.version)
41
+ return { ok: false, error: 'no_update_available' };
42
+ return { ok: true, packageName: rec.packageName, version: latestVersion };
43
+ }
44
+ export function runNpmInstall(packageName, version, cwd) {
45
+ return new Promise((resolve) => {
46
+ execFile('npm', ['install', `${packageName}@${version}`], { cwd, timeout: 120_000, encoding: 'utf8' }, (err, _stdout, stderr) => {
47
+ if (err)
48
+ return resolve({ ok: false, stderr: (stderr || err.message || '').trim() });
49
+ resolve({ ok: true, stderr: '' });
50
+ });
51
+ });
52
+ }
@@ -1,2 +1,19 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
+ import type { PluginManifest } from '@coffer-org/sdk/plugin';
3
+ import type { PluginAssetRecord } from './plugin-discovery.ts';
4
+ export interface PluginListEntry {
5
+ id: string;
6
+ installedVersion: string;
7
+ latestVersion: string | null;
8
+ packageName: string | null;
9
+ dependsOn: string[];
10
+ enabled: boolean;
11
+ vaults: string[];
12
+ extends: string[];
13
+ hasSettings: boolean;
14
+ schema?: string;
15
+ web?: string;
16
+ css?: string;
17
+ }
18
+ export declare function buildPluginListResponse(plugins: PluginManifest[], assets: PluginAssetRecord[], disabled: Set<string>): Promise<PluginListEntry[]>;
2
19
  export declare function registerPluginsApi(app: FastifyInstance): Promise<void>;
@@ -2,27 +2,33 @@ import { join } from 'node:path';
2
2
  import { createReadStream, existsSync, readFileSync } from 'node:fs';
3
3
  import { discoverPluginAssets } from "./plugin-discovery.js";
4
4
  import { getPlugins, readDisabled } from "./plugin-runtime.js";
5
+ import { checkLatestVersion } from "./plugin-updates.js";
5
6
  const nmRoot = () => join(process.cwd(), 'node_modules');
6
7
  const ASSET_KEY = { 'schema.js': 'schema', 'web.js': 'web', 'web.css': 'css' };
8
+ export async function buildPluginListResponse(plugins, assets, disabled) {
9
+ const assetById = new Map(assets.map((a) => [a.id, a]));
10
+ return Promise.all(plugins.map(async (p) => {
11
+ const a = assetById.get(p.id);
12
+ return {
13
+ id: p.id,
14
+ installedVersion: a?.version ?? p.version,
15
+ latestVersion: a ? await checkLatestVersion(a.packageName) : null,
16
+ packageName: a?.packageName ?? null,
17
+ dependsOn: p.dependsOn,
18
+ enabled: !disabled.has(p.id),
19
+ vaults: (p.vaults ?? []).map((v) => v.meta.id),
20
+ extends: (p.extends_ ?? []).map((e) => e.id),
21
+ hasSettings: Boolean(p.settings && p.settings.fields.length > 0),
22
+ schema: a?.schema,
23
+ web: a?.web,
24
+ css: a?.css,
25
+ };
26
+ }));
27
+ }
7
28
  export async function registerPluginsApi(app) {
8
29
  app.get('/api/plugins', async () => {
9
30
  const [plugins, assets, disabled] = await Promise.all([getPlugins(), discoverPluginAssets(), readDisabled()]);
10
- const assetById = new Map(assets.map((a) => [a.id, a]));
11
- return plugins.map((p) => {
12
- const a = assetById.get(p.id);
13
- return {
14
- id: p.id,
15
- version: p.version,
16
- dependsOn: p.dependsOn,
17
- enabled: !disabled.has(p.id),
18
- vaults: (p.vaults ?? []).map((v) => v.meta.id),
19
- extends: (p.extends_ ?? []).map((e) => e.id),
20
- hasSettings: Boolean(p.settings && p.settings.fields.length > 0),
21
- schema: a?.schema,
22
- web: a?.web,
23
- css: a?.css,
24
- };
25
- });
31
+ return buildPluginListResponse(plugins, assets, disabled);
26
32
  });
27
33
  app.get('/plugins/:id/:file', async (req, reply) => {
28
34
  const { id, file } = req.params;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffer-org/server",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -24,8 +24,8 @@
24
24
  "postpack": "node ../../scripts/swap-exports.mjs src"
25
25
  },
26
26
  "dependencies": {
27
- "@coffer-org/core": "^1.2.4",
28
- "@coffer-org/sdk": "^1.2.4",
27
+ "@coffer-org/core": "^1.3.0",
28
+ "@coffer-org/sdk": "^1.3.0",
29
29
  "@extractus/oembed-extractor": "^4.1.0",
30
30
  "@fastify/cors": "^11.2.0",
31
31
  "@fastify/multipart": "^10.0.0",