@axium/server 0.33.2 → 0.34.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.
- package/dist/acl.d.ts +6 -4
- package/dist/acl.js +34 -14
- package/dist/api/acl.js +25 -11
- package/dist/api/users.js +24 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +3 -0
- package/package.json +3 -3
- package/routes/admin/+layout.svelte +4 -91
- package/schemas/config.json +9 -0
package/dist/acl.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AccessControl, type AccessTarget, type UserInternal } from '@axium/core';
|
|
2
2
|
import type * as kysely from 'kysely';
|
|
3
3
|
import type { WithRequired } from 'utilium';
|
|
4
4
|
import * as db from './database.js';
|
|
@@ -23,7 +23,7 @@ export type WithACL<TB extends TargetName> = kysely.Selectable<db.Schema[TB]> &
|
|
|
23
23
|
acl: Result<`acl.${TB}`>[];
|
|
24
24
|
};
|
|
25
25
|
export interface ACLSelectionOptions {
|
|
26
|
-
/** If specified,
|
|
26
|
+
/** If specified, filters by user UUID */
|
|
27
27
|
user?: Pick<UserInternal, 'id' | 'roles' | 'tags'>;
|
|
28
28
|
/** Instead of using the `id` from `table`, use the `id` from this instead */
|
|
29
29
|
alias?: string;
|
|
@@ -34,8 +34,10 @@ export interface ACLSelectionOptions {
|
|
|
34
34
|
* This includes entries matching the user's ID, roles, or tags along with the "public" entry where all three "target" columns are null.
|
|
35
35
|
*/
|
|
36
36
|
export declare function from<const TB extends TargetName>(table: TB, opt?: ACLSelectionOptions): (eb: kysely.ExpressionBuilder<db.Schema, any>) => kysely.AliasedRawBuilder<Result<`acl.${TB}`>[], 'acl'>;
|
|
37
|
-
export declare function get<const TB extends TableName>(table: TB, itemId: string): Promise<WithRequired<
|
|
38
|
-
export declare function
|
|
37
|
+
export declare function get<const TB extends TableName>(table: TB, itemId: string): Promise<WithRequired<Result<TB>, 'user'>[]>;
|
|
38
|
+
export declare function update<const TB extends TableName>(table: TB, itemId: string, target: AccessTarget, permissions: PermissionsFor<TB>): Promise<Result<TB>>;
|
|
39
|
+
export declare function remove<const TB extends TableName>(table: TB, itemId: string, target: AccessTarget): Promise<Result<TB>>;
|
|
40
|
+
export declare function add<const TB extends TableName>(table: TB, itemId: string, target: AccessTarget): Promise<Result<TB>>;
|
|
39
41
|
export declare function check<const TB extends TableName>(acl: Result<TB>[], permissions: Partial<PermissionsFor<TB>>): Set<keyof PermissionsFor<TB>>;
|
|
40
42
|
export declare function listTables(): Record<string, TableName>;
|
|
41
43
|
export {};
|
package/dist/acl.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fromTarget } from '@axium/core';
|
|
1
2
|
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
|
2
3
|
import * as db from './database.js';
|
|
3
4
|
/**
|
|
@@ -9,8 +10,9 @@ export function from(table, opt = {}) {
|
|
|
9
10
|
return (eb) => jsonArrayFrom(eb
|
|
10
11
|
.selectFrom(`acl.${table} as _acl`)
|
|
11
12
|
.selectAll()
|
|
12
|
-
|
|
13
|
-
.
|
|
13
|
+
.$if(!opt.user, qb => qb.select(db.userFromId))
|
|
14
|
+
.whereRef('_acl.itemId', '=', `${opt.alias || table}.id`)
|
|
15
|
+
.$if(!!opt.user, qb => qb.where(eb => {
|
|
14
16
|
const allNull = eb.and([eb('userId', 'is', null), eb('role', 'is', null), eb('tag', 'is', null)]);
|
|
15
17
|
if (!opt.user)
|
|
16
18
|
return allNull;
|
|
@@ -20,27 +22,45 @@ export function from(table, opt = {}) {
|
|
|
20
22
|
if (opt.user.tags.length)
|
|
21
23
|
ors.push(eb('tag', 'in', opt.user.tags));
|
|
22
24
|
return eb.or(ors);
|
|
23
|
-
}))
|
|
25
|
+
})))
|
|
24
26
|
.$castTo()
|
|
25
27
|
.as('acl');
|
|
26
28
|
}
|
|
27
29
|
export async function get(table, itemId) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
return await db.database
|
|
31
|
+
.selectFrom(table)
|
|
32
|
+
.where('itemId', '=', itemId)
|
|
33
|
+
.selectAll()
|
|
34
|
+
.select(db.userFromId)
|
|
35
|
+
.$castTo()
|
|
36
|
+
.execute();
|
|
30
37
|
}
|
|
31
|
-
export async function
|
|
32
|
-
const entries = Object.entries(data).map(([userId, perm]) => ({ userId, ...perm }));
|
|
33
|
-
if (!entries.length)
|
|
34
|
-
return [];
|
|
38
|
+
export async function update(table, itemId, target, permissions) {
|
|
35
39
|
return await db.database
|
|
36
40
|
.updateTable(table)
|
|
37
|
-
|
|
38
|
-
.from(db.values(entries, 'data'))
|
|
39
|
-
.set()
|
|
40
|
-
.whereRef(`${table}.userId`, '=', 'data.userId')
|
|
41
|
+
.set(permissions)
|
|
41
42
|
.where('itemId', '=', itemId)
|
|
43
|
+
.where(eb => eb.and(fromTarget(target)))
|
|
42
44
|
.returningAll()
|
|
43
|
-
|
|
45
|
+
.$castTo()
|
|
46
|
+
.executeTakeFirstOrThrow();
|
|
47
|
+
}
|
|
48
|
+
export async function remove(table, itemId, target) {
|
|
49
|
+
return await db.database
|
|
50
|
+
.deleteFrom(table)
|
|
51
|
+
.where('itemId', '=', itemId)
|
|
52
|
+
.where(eb => eb.and(fromTarget(target)))
|
|
53
|
+
.returningAll()
|
|
54
|
+
.$castTo()
|
|
55
|
+
.executeTakeFirstOrThrow();
|
|
56
|
+
}
|
|
57
|
+
export async function add(table, itemId, target) {
|
|
58
|
+
return await db.database
|
|
59
|
+
.insertInto(table)
|
|
60
|
+
.values({ itemId, ...fromTarget(target) })
|
|
61
|
+
.returningAll()
|
|
62
|
+
.$castTo()
|
|
63
|
+
.executeTakeFirstOrThrow();
|
|
44
64
|
}
|
|
45
65
|
export function check(acl, permissions) {
|
|
46
66
|
const allowed = new Set();
|
package/dist/api/acl.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { AccessMap } from '@axium/core/access';
|
|
2
1
|
import * as z from 'zod';
|
|
3
2
|
import * as acl from '../acl.js';
|
|
4
3
|
import { error, parseBody, withError } from '../requests.js';
|
|
5
4
|
import { addRoute } from '../routes.js';
|
|
6
5
|
import { checkAuthForItem } from '../auth.js';
|
|
6
|
+
import { AccessControlUpdate, AccessTarget } from '@axium/core';
|
|
7
|
+
function getTable(itemType) {
|
|
8
|
+
const tables = acl.listTables();
|
|
9
|
+
if (!(itemType in tables))
|
|
10
|
+
error(400, 'Invalid item type: ' + itemType);
|
|
11
|
+
return tables[itemType];
|
|
12
|
+
}
|
|
7
13
|
addRoute({
|
|
8
14
|
path: '/api/acl/:itemType/:itemId',
|
|
9
15
|
params: {
|
|
@@ -11,17 +17,25 @@ addRoute({
|
|
|
11
17
|
itemId: z.uuid(),
|
|
12
18
|
},
|
|
13
19
|
async GET(request, { itemType, itemId }) {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
error(400, 'Invalid item type: ' + itemType);
|
|
17
|
-
return await acl.get(tables[itemType], itemId).catch(withError('Failed to get access controls'));
|
|
20
|
+
const table = getTable(itemType);
|
|
21
|
+
return await acl.get(table, itemId).catch(withError('Failed to get access controls'));
|
|
18
22
|
},
|
|
19
|
-
async
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
if (!(itemType in tables))
|
|
23
|
-
error(400, 'Invalid item type: ' + itemType);
|
|
23
|
+
async PATCH(request, { itemType, itemId }) {
|
|
24
|
+
const table = getTable(itemType);
|
|
25
|
+
const { target, permissions } = await parseBody(request, AccessControlUpdate);
|
|
24
26
|
await checkAuthForItem(request, itemType, itemId, { manage: true });
|
|
25
|
-
return await acl.
|
|
27
|
+
return await acl.update(table, itemId, target, permissions);
|
|
28
|
+
},
|
|
29
|
+
async PUT(request, { itemType, itemId }) {
|
|
30
|
+
const table = getTable(itemType);
|
|
31
|
+
const target = await parseBody(request, AccessTarget);
|
|
32
|
+
await checkAuthForItem(request, itemType, itemId, { manage: true });
|
|
33
|
+
return await acl.add(table, itemId, target);
|
|
34
|
+
},
|
|
35
|
+
async DELETE(request, { itemType, itemId }) {
|
|
36
|
+
const table = getTable(itemType);
|
|
37
|
+
const target = await parseBody(request, AccessTarget);
|
|
38
|
+
await checkAuthForItem(request, itemType, itemId, { manage: true });
|
|
39
|
+
return await acl.remove(table, itemId, target);
|
|
26
40
|
},
|
|
27
41
|
});
|
package/dist/api/users.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as webauthn from '@simplewebauthn/server';
|
|
|
4
4
|
import { encodeUUID, omit, pick } from 'utilium';
|
|
5
5
|
import * as z from 'zod';
|
|
6
6
|
import { audit } from '../audit.js';
|
|
7
|
-
import { checkAuthForUser, createPasskey, createVerification, getPasskey, getPasskeysByUserId, getSessions, getUser, useVerification, } from '../auth.js';
|
|
7
|
+
import { checkAuthForUser, createPasskey, createVerification, getPasskey, getPasskeysByUserId, getSessions, getUser, requireSession, useVerification, } from '../auth.js';
|
|
8
8
|
import { config } from '../config.js';
|
|
9
9
|
import { database as db } from '../database.js';
|
|
10
10
|
import { createSessionData, error, parseBody, stripUser, withError } from '../requests.js';
|
|
@@ -27,6 +27,29 @@ addRoute({
|
|
|
27
27
|
return { id };
|
|
28
28
|
},
|
|
29
29
|
});
|
|
30
|
+
addRoute({
|
|
31
|
+
path: '/api/users/discover',
|
|
32
|
+
async POST(request) {
|
|
33
|
+
const input = await parseBody(request, z.string());
|
|
34
|
+
if (config.user_discovery === 'disabled')
|
|
35
|
+
error(503, 'User discovery is disabled');
|
|
36
|
+
const { user } = (await requireSession(request).catch(() => null)) ?? {};
|
|
37
|
+
if (config.user_discovery != 'public' && !user)
|
|
38
|
+
error(401, 'User discovery restricted');
|
|
39
|
+
if (config.user_discovery == 'admin' && !user?.isAdmin)
|
|
40
|
+
error(403, 'User discovery is restricted to administrators');
|
|
41
|
+
const search = `%${input.trim().replaceAll(/[\\%_]/g, '\\$&')}%`;
|
|
42
|
+
const results = await db
|
|
43
|
+
.selectFrom('users')
|
|
44
|
+
.selectAll()
|
|
45
|
+
.limit(10)
|
|
46
|
+
.$if(!!user, qb => qb.where('id', '!=', user.id))
|
|
47
|
+
// @todo make `%...%` more robust against DoS? Consider using vectors or fancy indexing.
|
|
48
|
+
.where(eb => eb.or([eb('email', 'ilike', search), eb('name', 'ilike', search)]))
|
|
49
|
+
.execute();
|
|
50
|
+
return results.map(u => stripUser(u));
|
|
51
|
+
},
|
|
52
|
+
});
|
|
30
53
|
addRoute({
|
|
31
54
|
path: '/api/users/:id',
|
|
32
55
|
params,
|
package/dist/config.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ export declare const Config: z.ZodObject<{
|
|
|
41
41
|
origin: z.ZodOptional<z.ZodString>;
|
|
42
42
|
request_size_limit: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
|
|
43
43
|
show_duplicate_state: z.ZodOptional<z.ZodBoolean>;
|
|
44
|
+
user_discovery: z.ZodOptional<z.ZodLiteral<"disabled" | "user" | "admin" | "public">>;
|
|
44
45
|
verifications: z.ZodOptional<z.ZodObject<{
|
|
45
46
|
timeout: z.ZodOptional<z.ZodNumber>;
|
|
46
47
|
email: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -117,6 +118,7 @@ export declare const ConfigFile: z.ZodObject<{
|
|
|
117
118
|
origin: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
118
119
|
request_size_limit: z.ZodOptional<z.ZodOptional<z.ZodOptional<z.ZodNumber>>>;
|
|
119
120
|
show_duplicate_state: z.ZodOptional<z.ZodOptional<z.ZodBoolean>>;
|
|
121
|
+
user_discovery: z.ZodOptional<z.ZodOptional<z.ZodLiteral<"disabled" | "user" | "admin" | "public">>>;
|
|
120
122
|
verifications: z.ZodOptional<z.ZodOptional<z.ZodObject<{
|
|
121
123
|
timeout: z.ZodOptional<z.ZodNumber>;
|
|
122
124
|
email: z.ZodOptional<z.ZodBoolean>;
|
package/dist/config.js
CHANGED
|
@@ -61,6 +61,8 @@ export const Config = z
|
|
|
61
61
|
origin: z.string(),
|
|
62
62
|
request_size_limit: z.number().min(0).optional(),
|
|
63
63
|
show_duplicate_state: z.boolean(),
|
|
64
|
+
/** Who can use the user discovery API. For example, setting to `admin` means regular users need to type a full email in the ACL dialog and won't be shown results */
|
|
65
|
+
user_discovery: z.literal(['disabled', 'admin', 'user', 'public']),
|
|
64
66
|
verifications: z
|
|
65
67
|
.looseObject({
|
|
66
68
|
/** In minutes */
|
|
@@ -127,6 +129,7 @@ export const defaultConfig = {
|
|
|
127
129
|
origin: 'https://test.localhost',
|
|
128
130
|
show_duplicate_state: false,
|
|
129
131
|
request_size_limit: 0,
|
|
132
|
+
user_discovery: 'user',
|
|
130
133
|
verifications: {
|
|
131
134
|
timeout: 60,
|
|
132
135
|
email: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axium/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"author": "James Prevett <axium@jamespre.dev>",
|
|
5
5
|
"funding": {
|
|
6
6
|
"type": "individual",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"clean": "rm -rf build .svelte-kit node_modules/{.vite,.vite-temp}"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@axium/client": ">=0.
|
|
51
|
-
"@axium/core": ">=0.
|
|
50
|
+
"@axium/client": ">=0.13.0",
|
|
51
|
+
"@axium/core": ">=0.19.0",
|
|
52
52
|
"kysely": "^0.28.0",
|
|
53
53
|
"utilium": "^2.6.0",
|
|
54
54
|
"zod": "^4.0.5"
|
|
@@ -1,95 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
3
|
-
import { capitalize } from 'utilium';
|
|
4
|
-
|
|
2
|
+
import SidebarLayout from '@axium/client/components/SidebarLayout';
|
|
5
3
|
let { children, data } = $props();
|
|
6
4
|
</script>
|
|
7
5
|
|
|
8
|
-
<
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<a {href} class={['item', 'icon-text', active && 'active']}><Icon {i} /> <span class="sidebar-text">{capitalize(name)}</span></a
|
|
12
|
-
>
|
|
13
|
-
{/each}
|
|
14
|
-
</div>
|
|
15
|
-
|
|
16
|
-
<div id="admin-content">
|
|
17
|
-
{@render children()}
|
|
18
|
-
</div>
|
|
19
|
-
</div>
|
|
20
|
-
|
|
21
|
-
<style>
|
|
22
|
-
#admin-container {
|
|
23
|
-
display: grid;
|
|
24
|
-
grid-template-columns: 15em 1fr;
|
|
25
|
-
height: 100%;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
#admin-sidebar {
|
|
29
|
-
grid-column: 1;
|
|
30
|
-
width: 100%;
|
|
31
|
-
display: inline-flex;
|
|
32
|
-
flex-direction: column;
|
|
33
|
-
gap: 0.5em;
|
|
34
|
-
background-color: var(--bg-alt);
|
|
35
|
-
padding: 1em;
|
|
36
|
-
padding-left: 0;
|
|
37
|
-
border-radius: 0 1em 1em 0;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
.item {
|
|
41
|
-
padding: 0.3em 0.5em;
|
|
42
|
-
border-radius: 0.25em 1em 1em 0.25em;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
.item:hover {
|
|
46
|
-
background-color: var(--bg-strong);
|
|
47
|
-
cursor: pointer;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
.item.active {
|
|
51
|
-
background-color: var(--bg-strong);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
#admin-content {
|
|
55
|
-
grid-column: 2;
|
|
56
|
-
padding: 1em;
|
|
57
|
-
overflow-x: hidden;
|
|
58
|
-
overflow-y: scroll;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
@media (width < 700px) {
|
|
62
|
-
#admin-container {
|
|
63
|
-
grid-template-columns: 1fr;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
#admin-content {
|
|
67
|
-
padding-bottom: 4em;
|
|
68
|
-
grid-column: 1;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
#admin-sidebar {
|
|
72
|
-
position: fixed;
|
|
73
|
-
grid-column: unset;
|
|
74
|
-
inset: auto 0 0;
|
|
75
|
-
border-radius: 1em;
|
|
76
|
-
display: flex;
|
|
77
|
-
flex-direction: row;
|
|
78
|
-
justify-content: space-around;
|
|
79
|
-
gap: 1em;
|
|
80
|
-
padding: 0.5em;
|
|
81
|
-
z-index: 6;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
.sidebar-text {
|
|
85
|
-
display: none;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
.item {
|
|
89
|
-
flex: 1 1 0;
|
|
90
|
-
border-radius: 1em;
|
|
91
|
-
padding: 1em;
|
|
92
|
-
justify-content: center;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
</style>
|
|
6
|
+
<SidebarLayout tabs={data.tabs}>
|
|
7
|
+
{@render children()}
|
|
8
|
+
</SidebarLayout>
|
package/schemas/config.json
CHANGED
|
@@ -154,6 +154,15 @@
|
|
|
154
154
|
"show_duplicate_state": {
|
|
155
155
|
"type": "boolean"
|
|
156
156
|
},
|
|
157
|
+
"user_discovery": {
|
|
158
|
+
"type": "string",
|
|
159
|
+
"enum": [
|
|
160
|
+
"disabled",
|
|
161
|
+
"admin",
|
|
162
|
+
"user",
|
|
163
|
+
"public"
|
|
164
|
+
]
|
|
165
|
+
},
|
|
157
166
|
"verifications": {
|
|
158
167
|
"type": "object",
|
|
159
168
|
"properties": {
|