@axium/server 0.27.0 → 0.28.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,61 +1,41 @@
1
- import type { AccessControl, AccessMap, Permission, UserInternal } from '@axium/core';
2
- import type { AliasedRawBuilder, Expression, ExpressionBuilder, Selectable } from 'kysely';
1
+ import type { AccessControl, AccessMap, UserInternal } from '@axium/core';
2
+ import type * as kysely from 'kysely';
3
+ import type { WithRequired } from 'utilium';
3
4
  import * as db from './database.js';
4
- export interface Target {
5
- userId: string;
6
- publicPermission: Permission;
7
- }
8
- type _TableNames = (string & keyof db.Schema) & keyof {
9
- [K in Exclude<keyof db.Schema, `acl.${string}`> as Selectable<db.Schema[K]> extends Omit<Target, 'acl'> ? K : never]: null;
5
+ type _TableNames = keyof {
6
+ [K in keyof db.Schema as db.Schema[K] extends db.DBAccessControl ? K : never]: null;
7
+ };
8
+ type _TargetNames = keyof db.Schema & keyof {
9
+ [K in _TableNames as K extends `acl.${infer TB extends keyof db.Schema}` ? TB : never]: null;
10
10
  };
11
11
  /**
12
12
  * `never` causes a ton of problems, so we use `string` if none of the tables are shareable.
13
13
  */
14
- export type TargetName = _TableNames extends never ? keyof db.Schema : _TableNames;
14
+ export type TableName = _TableNames extends never ? keyof db.Schema : _TableNames;
15
+ export type TargetName = _TargetNames extends never ? keyof db.Schema : _TargetNames;
15
16
  export interface AccessControlInternal extends AccessControl {
16
- user?: UserInternal;
17
+ tag?: string | null;
17
18
  }
18
- export declare const expectedTypes: {
19
- userId: {
20
- type: string;
21
- required: true;
22
- };
23
- createdAt: {
24
- type: string;
25
- required: true;
26
- hasDefault: true;
27
- };
28
- itemId: {
29
- type: string;
30
- required: true;
31
- };
32
- permission: {
33
- type: string;
34
- required: true;
35
- hasDefault: true;
36
- };
37
- };
38
- /**
39
- * Adds an Access Control List (ACL) in the database for managing access to rows in an existing table.
40
- * @category Plugin API
41
- */
42
- export declare function createTable(table: TargetName): Promise<void>;
43
- export declare function dropTable(table: TargetName): Promise<void>;
44
- export declare function wipeTable(table: TargetName): Promise<void>;
45
- export declare function createEntry(itemType: TargetName, data: Omit<AccessControl, 'createdAt'>): Promise<AccessControlInternal>;
46
- export declare function deleteEntry(itemType: TargetName, itemId: string, userId: string): Promise<void>;
19
+ export type PermissionsFor<TB extends TableName> = Omit<kysely.Selectable<db.Schema[TB]>, keyof AccessControlInternal | number | symbol>;
20
+ export type Result<TB extends TableName> = AccessControlInternal & PermissionsFor<TB>;
21
+ export type WithACL<TB extends TargetName> = kysely.Selectable<db.Schema[TB]> & {
22
+ userId: string;
23
+ acl: Result<`acl.${TB}`>[];
24
+ } & Record<string, any>;
47
25
  export interface ACLSelectionOptions {
48
- /** If specified, only returns the access control for the given user ID. */
49
- onlyId?: string | Expression<any>;
26
+ /** If specified, files by user UUID */
27
+ user?: Pick<UserInternal, 'id' | 'roles' | 'tags'>;
50
28
  /** Instead of using the `id` from `table`, use the `id` from this instead */
51
29
  alias?: string;
52
30
  }
53
31
  /**
54
32
  * Helper to select all access controls for a given table, including the user information.
55
- *
56
- * @param onlyId If specified, only returns the access control for the given user ID.
33
+ * Optionally filter for the entries applicable to a specific user.
34
+ * This includes entries matching the user's ID, roles, or tags along with the "public" entry where all three "target" columns are null.
57
35
  */
58
- export declare function from(table: TargetName, opt?: ACLSelectionOptions): (eb: ExpressionBuilder<db.Schema, any>) => AliasedRawBuilder<Required<AccessControl>[], 'acl'>;
59
- export declare function get(itemType: TargetName, itemId: string): Promise<Required<AccessControlInternal>[]>;
60
- export declare function set(itemType: TargetName, itemId: string, data: AccessMap): Promise<AccessControlInternal[]>;
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]>)[]>;
39
+ export declare function check<const TB extends TableName>(acl: Result<TB>[], permissions: Partial<PermissionsFor<TB>>): Set<keyof PermissionsFor<TB>>;
40
+ export declare function listTables(): Record<string, TableName>;
61
41
  export {};
package/dist/acl.js CHANGED
@@ -1,84 +1,64 @@
1
- import * as io from '@axium/core/node/io';
2
- import { sql } from 'kysely';
3
1
  import { jsonArrayFrom } from 'kysely/helpers/postgres';
4
2
  import * as db from './database.js';
5
- const accessControllableTypes = {
6
- userId: { type: 'uuid', required: true },
7
- publicPermission: { type: 'int4', required: true, hasDefault: true },
8
- };
9
- export const expectedTypes = {
10
- userId: { type: 'uuid', required: true },
11
- createdAt: { type: 'timestamptz', required: true, hasDefault: true },
12
- itemId: { type: 'uuid', required: true },
13
- permission: { type: 'int4', required: true, hasDefault: true },
14
- };
15
- /**
16
- * Adds an Access Control List (ACL) in the database for managing access to rows in an existing table.
17
- * @category Plugin API
18
- */
19
- export async function createTable(table) {
20
- await db.checkTableTypes(table, accessControllableTypes, { strict: true, extra: false });
21
- io.start(`Creating table acl.${table}`);
22
- await db.database.schema
23
- .createTable(`acl.${table}`)
24
- .addColumn('userId', 'uuid', col => col.references('users.id').onDelete('cascade'))
25
- .addColumn('itemId', 'uuid', col => col.references(`${table}.id`).onDelete('cascade'))
26
- .addPrimaryKeyConstraint('PK_acl_' + table, ['userId', 'itemId'])
27
- .addColumn('createdAt', 'timestamptz', col => col.notNull().defaultTo(sql `now()`))
28
- .addColumn('permission', 'integer', col => col.notNull().check(sql `permission >= 0 AND permission <= 5`))
29
- .execute()
30
- .then(io.done)
31
- .catch(db.warnExists);
32
- await db.createIndex(`acl.${table}`, 'userId');
33
- await db.createIndex(`acl.${table}`, 'itemId');
34
- }
35
- export async function dropTable(table) {
36
- io.start(`Dropping table acl.${table}`);
37
- await db.database.schema.dropTable(`acl.${table}`).execute().then(io.done).catch(db.warnExists);
38
- }
39
- export async function wipeTable(table) {
40
- io.start(`Wiping table acl.${table}`);
41
- await db.database.deleteFrom(`acl.${table}`).execute().then(io.done).catch(db.warnExists);
42
- }
43
- export async function createEntry(itemType, data) {
44
- return await db.database.insertInto(`acl.${itemType}`).values(data).returningAll().executeTakeFirstOrThrow();
45
- }
46
- export async function deleteEntry(itemType, itemId, userId) {
47
- await db.database.deleteFrom(`acl.${itemType}`).where('itemId', '=', itemId).where('userId', '=', userId).execute();
48
- }
49
3
  /**
50
4
  * Helper to select all access controls for a given table, including the user information.
51
- *
52
- * @param onlyId If specified, only returns the access control for the given user ID.
5
+ * Optionally filter for the entries applicable to a specific user.
6
+ * This includes entries matching the user's ID, roles, or tags along with the "public" entry where all three "target" columns are null.
53
7
  */
54
8
  export function from(table, opt = {}) {
55
9
  return (eb) => jsonArrayFrom(eb
56
10
  .selectFrom(`acl.${table} as _acl`)
57
11
  .selectAll()
58
- .select(db.userFromId)
59
12
  .whereRef(`_acl.itemId`, '=', `${opt.alias || table}.id`)
60
- .$if(!!opt.onlyId, qb => qb.where('userId', '=', opt.onlyId)))
13
+ .where(eb => {
14
+ const allNull = eb.and([eb('userId', 'is', null), eb('role', 'is', null), eb('tag', 'is', null)]);
15
+ if (!opt.user)
16
+ return allNull;
17
+ const ors = [allNull, eb('userId', '=', opt.user.id)];
18
+ if (opt.user.roles.length)
19
+ ors.push(eb('role', 'in', opt.user.roles));
20
+ if (opt.user.tags.length)
21
+ ors.push(eb('tag', 'in', opt.user.tags));
22
+ return eb.or(ors);
23
+ }))
61
24
  .$castTo()
62
25
  .as('acl');
63
26
  }
64
- export async function get(itemType, itemId) {
65
- return await db.database.selectFrom(`acl.${itemType}`).where('itemId', '=', itemId).selectAll().select(db.userFromId).execute();
27
+ 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();
66
30
  }
67
- export async function set(itemType, itemId, data) {
68
- if ('public' in data) {
69
- // @ts-expect-error 2353 - TS misses the column
70
- await db.database.updateTable(itemType).set({ publicPermission: data.public }).where('id', '=', itemId).execute();
71
- delete data.public;
72
- }
73
- const entries = Object.entries(data).map(([userId, perm]) => ({ userId, perm }));
31
+ export async function set(table, itemId, data) {
32
+ const entries = Object.entries(data).map(([userId, perm]) => ({ userId, ...perm }));
74
33
  if (!entries.length)
75
34
  return [];
76
35
  return await db.database
77
- .updateTable(`acl.${itemType}`)
36
+ .updateTable(table)
37
+ // @ts-expect-error 2349
78
38
  .from(db.values(entries, 'data'))
79
- .set('permission', eb => eb.ref('data.perm'))
80
- .whereRef(`acl.${itemType}.userId`, '=', 'data.userId')
39
+ .set()
40
+ .whereRef(`${table}.userId`, '=', 'data.userId')
81
41
  .where('itemId', '=', itemId)
82
42
  .returningAll()
83
43
  .execute();
84
44
  }
45
+ export function check(acl, permissions) {
46
+ const allowed = new Set();
47
+ const all = new Set(Object.keys(permissions));
48
+ const entries = Object.entries(permissions);
49
+ for (const control of acl) {
50
+ for (const [key, needed] of entries) {
51
+ const value = control[key];
52
+ if (value === needed)
53
+ allowed.add(key);
54
+ }
55
+ }
56
+ return all.difference(allowed);
57
+ }
58
+ export function listTables() {
59
+ const tables = {};
60
+ for (const [, file] of db.getSchemaFiles()) {
61
+ Object.assign(tables, file.acl_tables || {});
62
+ }
63
+ return tables;
64
+ }
package/dist/api/acl.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { AccessMap } from '@axium/core/access';
2
2
  import * as z from 'zod';
3
3
  import * as acl from '../acl.js';
4
- import { parseBody, withError } from '../requests.js';
4
+ import { error, parseBody, withError } from '../requests.js';
5
5
  import { addRoute } from '../routes.js';
6
+ import { checkAuthForItem } from '../auth.js';
6
7
  addRoute({
7
8
  path: '/api/acl/:itemType/:itemId',
8
9
  params: {
@@ -10,10 +11,17 @@ addRoute({
10
11
  itemId: z.uuid(),
11
12
  },
12
13
  async GET(request, { itemType, itemId }) {
13
- return await acl.get(itemType, itemId).catch(withError('Failed to get access controls'));
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'));
14
18
  },
15
19
  async POST(request, { itemType, itemId }) {
16
20
  const data = await parseBody(request, AccessMap);
17
- return await acl.set(itemType, itemId, data).catch(withError('Failed to set access controls'));
21
+ const tables = acl.listTables();
22
+ if (!(itemType in tables))
23
+ error(400, 'Invalid item type: ' + itemType);
24
+ await checkAuthForItem(request, itemType, itemId, { manage: true });
25
+ return await acl.set(tables[itemType], itemId, data);
18
26
  },
19
27
  });
package/dist/api/admin.js CHANGED
@@ -5,16 +5,13 @@ import { omit } from 'utilium';
5
5
  import * as z from 'zod';
6
6
  import pkg from '../../package.json' with { type: 'json' };
7
7
  import { audit, events, getEvents } from '../audit.js';
8
- import { getSessionAndUser } from '../auth.js';
8
+ import { requireSession } from '../auth.js';
9
9
  import { config } from '../config.js';
10
10
  import { count, database as db } from '../database.js';
11
- import { error, getToken, withError } from '../requests.js';
11
+ import { error, withError } from '../requests.js';
12
12
  import { addRoute } from '../routes.js';
13
13
  async function assertAdmin(route, req) {
14
- const token = getToken(req);
15
- if (!token)
16
- error(401, 'Missing token');
17
- const admin = await getSessionAndUser(token).catch(withError('Invalid session', 400));
14
+ const admin = await requireSession(req);
18
15
  if (!admin.user.isAdmin)
19
16
  error(403, 'Not an administrator');
20
17
  if (!config.admin_api)
@@ -2,24 +2,17 @@ import { apps } from '@axium/core';
2
2
  import { plugins } from '@axium/core/plugins';
3
3
  import { requestMethods } from '@axium/core/requests';
4
4
  import pkg from '../../package.json' with { type: 'json' };
5
- import { getSessionAndUser } from '../auth.js';
5
+ import { requireSession } from '../auth.js';
6
6
  import { config } from '../config.js';
7
- import { error, getToken } from '../requests.js';
7
+ import { error } from '../requests.js';
8
8
  import { addRoute, routes } from '../routes.js';
9
9
  addRoute({
10
10
  path: '/api/metadata',
11
11
  async GET(request) {
12
12
  if (!config.debug) {
13
- const token = getToken(request);
14
- if (!token)
15
- error(401, 'Missing session token');
16
- const session = await getSessionAndUser(token);
17
- if (!session)
18
- error(401, 'Invalid session');
19
- if (!session.user.isAdmin)
13
+ const { user } = await requireSession(request);
14
+ if (!user.isAdmin)
20
15
  error(403, 'User is not an administrator');
21
- if (session.user.isSuspended)
22
- error(403, 'User is suspended');
23
16
  }
24
17
  return {
25
18
  version: pkg.version,
package/dist/audit.d.ts CHANGED
@@ -15,9 +15,6 @@ export interface $EventTypes {
15
15
  logout: {
16
16
  sessions: string[];
17
17
  };
18
- acl_id_mismatch: {
19
- item: string;
20
- };
21
18
  admin_change: {
22
19
  user: string;
23
20
  };
package/dist/audit.js CHANGED
@@ -105,14 +105,6 @@ addEvent({
105
105
  tags: ['cli'],
106
106
  extra: { user: z.string() },
107
107
  });
108
- addEvent({
109
- source: '@axium/server',
110
- name: 'acl_id_mismatch',
111
- severity: Severity.Critical,
112
- tags: ['acl', 'auth'],
113
- extra: { item: z.string() },
114
- noAutoSuspend: true,
115
- });
116
108
  addEvent({
117
109
  source: '@axium/server',
118
110
  name: 'admin_api',
package/dist/auth.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { Passkey, Permission, Session, UserInternal, Verification } from '@axium/core';
2
- import type { Insertable } from 'kysely';
1
+ import type { Passkey, Session, UserInternal, Verification } from '@axium/core';
2
+ import type { Insertable, Selectable } from 'kysely';
3
3
  import * as acl from './acl.js';
4
4
  import { type Schema } from './database.js';
5
5
  export declare function getUser(id: string): Promise<UserInternal>;
@@ -13,6 +13,7 @@ export interface SessionAndUser extends SessionInternal {
13
13
  }
14
14
  export declare function getSessionAndUser(token: string): Promise<SessionAndUser>;
15
15
  export declare function getSession(sessionId: string): Promise<SessionInternal>;
16
+ export declare function requireSession(request: Request, sensitive?: boolean): Promise<SessionAndUser>;
16
17
  export declare function getSessions(userId: string): Promise<SessionInternal[]>;
17
18
  export type VerificationRole = 'verify_email' | 'login';
18
19
  export interface VerificationInternal extends Verification {
@@ -38,10 +39,14 @@ export interface UserAuthResult extends SessionAndUser {
38
39
  accessor: UserInternal;
39
40
  }
40
41
  export declare function checkAuthForUser(request: Request, userId: string, sensitive?: boolean): Promise<UserAuthResult>;
41
- export interface ItemAuthResult<T extends acl.Target> {
42
+ export interface ItemAuthResult<TB extends acl.TargetName> {
42
43
  fromACL: boolean;
43
- item: T;
44
+ item: Selectable<Schema[TB]>;
44
45
  user?: UserInternal;
45
46
  session?: SessionInternal;
46
47
  }
47
- export declare function checkAuthForItem<const V extends acl.Target>(request: Request, itemType: acl.TargetName, itemId: string, permission: Permission): Promise<ItemAuthResult<V>>;
48
+ /**
49
+ * Authenticate a request against an "item" which has an ACL table.
50
+ * This will fetch the item, ACLs, users, and the authenticating session.
51
+ */
52
+ export declare function checkAuthForItem<const TB extends acl.TargetName>(request: Request, itemType: TB, itemId: string, permissions: Partial<acl.PermissionsFor<`acl.${TB}`>>): Promise<ItemAuthResult<TB>>;
package/dist/auth.js CHANGED
@@ -46,6 +46,15 @@ export async function getSession(sessionId) {
46
46
  .where('sessions.expires', '>', new Date())
47
47
  .executeTakeFirstOrThrow();
48
48
  }
49
+ export async function requireSession(request, sensitive = false) {
50
+ const token = getToken(request, sensitive);
51
+ if (!token)
52
+ error(401, 'Missing session token');
53
+ const session = await getSessionAndUser(token).catch(withError('Invalid or expired session token', 401));
54
+ if (session.user.isSuspended)
55
+ error(403, 'User is suspended');
56
+ return session;
57
+ }
49
58
  export async function getSessions(userId) {
50
59
  return await db.selectFrom('sessions').selectAll().where('userId', '=', userId).where('sessions.expires', '>', new Date()).execute();
51
60
  }
@@ -88,12 +97,7 @@ export async function updatePasskeyCounter(id, newCounter) {
88
97
  return passkey;
89
98
  }
90
99
  export async function checkAuthForUser(request, userId, sensitive = false) {
91
- const token = getToken(request, sensitive);
92
- if (!token)
93
- throw error(401, 'Missing token');
94
- const session = await getSessionAndUser(token).catch(withError('Invalid or expired session', 401));
95
- if (session.user.isSuspended)
96
- error(403, 'User is suspended');
100
+ const session = await requireSession(request);
97
101
  if (session.userId !== userId) {
98
102
  if (!session.user?.isAdmin)
99
103
  error(403, 'User ID mismatch');
@@ -106,42 +110,44 @@ export async function checkAuthForUser(request, userId, sensitive = false) {
106
110
  error(403, 'This token can not be used for sensitive actions');
107
111
  return Object.assign(session, { accessor: session.user });
108
112
  }
109
- export async function checkAuthForItem(request, itemType, itemId, permission) {
113
+ /**
114
+ * Authenticate a request against an "item" which has an ACL table.
115
+ * This will fetch the item, ACLs, users, and the authenticating session.
116
+ */
117
+ export async function checkAuthForItem(request, itemType, itemId, permissions) {
110
118
  const token = getToken(request, false);
111
119
  if (!token)
112
120
  error(401, 'Missing token');
113
121
  const session = await getSessionAndUser(token).catch(() => null);
122
+ const { userId, user } = session ?? {};
114
123
  const item = await db
115
124
  .selectFrom(itemType)
116
125
  .selectAll()
117
126
  .where('id', '=', itemId)
118
- .$if(!!session, eb => eb.select(acl.from(itemType, { onlyId: session.userId })))
127
+ .$if(!!userId, eb => eb.select(acl.from(itemType, { user })))
119
128
  .$castTo()
120
129
  .executeTakeFirstOrThrow()
121
- .catch(withError('Item not found', 404));
130
+ .catch(e => {
131
+ if (e.message.includes('no rows'))
132
+ error(404, itemType + ' not found');
133
+ throw e;
134
+ });
122
135
  const result = {
123
136
  session: session ? omit(session, 'user') : undefined,
124
137
  item: omit(item, 'acl'),
125
- user: session?.user,
138
+ user,
126
139
  fromACL: false,
127
140
  };
128
- if (item.publicPermission >= permission)
129
- return result;
130
- if (!session)
141
+ if (!session || !user)
131
142
  error(403, 'Access denied');
132
- if (session.user.isSuspended)
143
+ if (user.isSuspended)
133
144
  error(403, 'User is suspended');
134
- if (session.userId == item.userId)
145
+ if (userId == item.userId)
135
146
  return result;
136
147
  result.fromACL = true;
137
148
  if (!item.acl || !item.acl.length)
138
149
  error(403, 'Access denied');
139
- const [control] = item.acl;
140
- if (control.userId !== session.userId) {
141
- await audit('acl_id_mismatch', session.userId, { item: itemId });
142
- error(500, 'Access control entry does not match session user');
143
- }
144
- if (control.permission >= permission)
145
- return result;
146
- error(403, 'Access denied');
150
+ if (acl.check(item.acl, permissions).size)
151
+ error(403, 'Access denied');
152
+ return result;
147
153
  }
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,8 @@
1
- #!/usr/bin/env node
2
- export {};
1
+ import type { UserInternal } from '@axium/core';
2
+ export declare function userText(user: UserInternal, bold?: boolean): string;
3
+ export declare function lookupUser(lookup: string): Promise<UserInternal>;
4
+ /**
5
+ * Updates an array of strings by adding or removing items.
6
+ * Only returns whether the array was updated and diff text for what actually changed.
7
+ */
8
+ export declare function diffUpdate(original: string[], add?: string[], remove?: string[]): [updated: boolean, newValue: string[], diffText: string];