@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 CHANGED
@@ -1,4 +1,4 @@
1
- import type { AccessControl, AccessMap, UserInternal } from '@axium/core';
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, files by user UUID */
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<AccessControlInternal & kysely.Selectable<db.Schema[TB]>, 'user'>[]>;
38
- export declare function set<const TB extends TableName>(table: TB, itemId: string, data: AccessMap): Promise<(AccessControlInternal & kysely.Selectable<db.Schema[TB]>)[]>;
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
- .whereRef(`_acl.itemId`, '=', `${opt.alias || table}.id`)
13
- .where(eb => {
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
- // @ts-expect-error 2349
29
- return await db.database.selectFrom(table).where('itemId', '=', itemId).selectAll().select(db.userFromId).execute();
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 set(table, itemId, data) {
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
- // @ts-expect-error 2349
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
- .execute();
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 tables = acl.listTables();
15
- if (!(itemType in tables))
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 POST(request, { itemType, itemId }) {
20
- const data = await parseBody(request, AccessMap);
21
- const tables = acl.listTables();
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.set(tables[itemType], itemId, data);
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.33.2",
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.12.0",
51
- "@axium/core": ">=0.18.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 { Icon } from '@axium/client/components';
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
- <div id="admin-container">
9
- <div id="admin-sidebar">
10
- {#each data.tabs as { href, name, icon: i, active }}
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>
@@ -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": {