@axium/server 0.37.0 → 0.37.2

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
@@ -19,6 +19,7 @@ export type PermissionsFor<TB extends TableName> = Omit<kysely.Selectable<db.Sch
19
19
  export type Result<TB extends TableName> = AccessControlInternal & PermissionsFor<TB>;
20
20
  export type WithACL<TB extends TargetName> = kysely.Selectable<db.Schema[TB]> & {
21
21
  userId: string;
22
+ parentId?: string | null;
22
23
  acl: Result<`acl.${TB}`>[];
23
24
  };
24
25
  export interface ACLSelectionOptions {
@@ -64,7 +64,7 @@ async function POST(request) {
64
64
  deviceType: registrationInfo.credentialDeviceType,
65
65
  backedUp: registrationInfo.credentialBackedUp,
66
66
  }).catch(withError('Failed to create passkey', 500));
67
- return await createSessionData(userId);
67
+ return await createSessionData(userId, request);
68
68
  }
69
69
  addRoute({
70
70
  path: '/api/register',
package/dist/api/users.js CHANGED
@@ -100,7 +100,7 @@ addRoute({
100
100
  path: '/api/users/:id/auth',
101
101
  params,
102
102
  async OPTIONS(request, { id: userId }) {
103
- const { type } = await parseBody(request, UserAuthOptions);
103
+ const { type, client } = await parseBody(request, UserAuthOptions);
104
104
  const user = await getUser(userId).catch(withError('User does not exist', 404));
105
105
  if (user.isSuspended)
106
106
  error(403, 'User is suspended');
@@ -111,7 +111,7 @@ addRoute({
111
111
  rpID: config.auth.rp_id,
112
112
  allowCredentials: passkeys.map(passkey => pick(passkey, 'id', 'transports')),
113
113
  });
114
- challenges.set(userId, { data: options.challenge, type });
114
+ challenges.set(userId, { data: options.challenge, type, client });
115
115
  return options;
116
116
  },
117
117
  async POST(request, { id: userId }) {
@@ -119,7 +119,7 @@ addRoute({
119
119
  const auth = challenges.get(userId);
120
120
  if (!auth)
121
121
  error(404, 'No challenge');
122
- const { data: expectedChallenge, type } = auth;
122
+ const { data: expectedChallenge, type, client } = auth;
123
123
  challenges.delete(userId);
124
124
  const passkey = await getPasskey(response.id).catch(withError('Passkey does not exist', 404));
125
125
  if (passkey.userId !== userId)
@@ -137,14 +137,13 @@ addRoute({
137
137
  error(401, 'Verification failed');
138
138
  switch (type) {
139
139
  case 'login':
140
- return await createSessionData(userId);
140
+ return await createSessionData(userId, request);
141
141
  case 'client_login':
142
- /** @todo tag in DB so users can manage easier */
143
- return await createSessionData(userId, { noCookie: true });
142
+ return await createSessionData(userId, request, { noCookie: true, clientUA: client });
144
143
  case 'action':
145
144
  if ((Date.now() - passkey.createdAt.getTime()) / 60_000 < config.auth.passkey_probation)
146
145
  error(403, 'You can not authorize sensitive actions with a newly created passkey');
147
- return await createSessionData(userId, { elevated: true });
146
+ return await createSessionData(userId, request, { elevated: true });
148
147
  }
149
148
  },
150
149
  });
@@ -266,6 +265,6 @@ addRoute({
266
265
  async POST(request, { id: userId }) {
267
266
  const { token } = await parseBody(request, z.object({ token: z.string() }));
268
267
  await useVerification('login', userId, token).catch(withError('Invalid or expired verification token', 400));
269
- return await createSessionData(userId);
268
+ return await createSessionData(userId, request);
270
269
  },
271
270
  });
package/dist/auth.d.ts CHANGED
@@ -8,7 +8,7 @@ export declare function updateUser({ id, ...user }: WithRequired<Insertable<Sche
8
8
  export interface SessionInternal extends Session {
9
9
  token: string;
10
10
  }
11
- export declare function createSession(userId: string, elevated?: boolean): Promise<SessionInternal>;
11
+ export declare function createSession(userId: string, name: string | null, elevated?: boolean): Promise<SessionInternal>;
12
12
  export interface SessionAndUser extends SessionInternal {
13
13
  user: UserInternal;
14
14
  }
@@ -41,9 +41,9 @@ export interface ItemAuthResult<TB extends acl.TargetName> {
41
41
  user?: UserInternal;
42
42
  session?: SessionInternal;
43
43
  }
44
- export declare function authSessionForItem<const TB extends acl.TargetName>(itemType: TB, itemId: string, permissions: Partial<acl.PermissionsFor<`acl.${TB}`>>, session?: SessionAndUser | null): Promise<ItemAuthResult<TB>>;
44
+ export declare function authSessionForItem<const TB extends acl.TargetName>(itemType: TB, itemId: string, permissions: Partial<acl.PermissionsFor<`acl.${TB}`>>, session?: SessionAndUser | null, recursive?: boolean): Promise<ItemAuthResult<TB>>;
45
45
  /**
46
46
  * Authenticate a request against an "item" which has an ACL table.
47
47
  * This will fetch the item, ACLs, users, and the authenticating session.
48
48
  */
49
- export declare function authRequestForItem<const TB extends acl.TargetName>(request: Request, itemType: TB, itemId: string, permissions: Partial<acl.PermissionsFor<`acl.${TB}`>>): Promise<ItemAuthResult<TB>>;
49
+ export declare function authRequestForItem<const TB extends acl.TargetName>(request: Request, itemType: TB, itemId: string, permissions: Partial<acl.PermissionsFor<`acl.${TB}`>>, recursive?: boolean): Promise<ItemAuthResult<TB>>;
package/dist/auth.js CHANGED
@@ -13,10 +13,11 @@ export async function updateUser({ id, ...user }) {
13
13
  }
14
14
  const in30days = () => new Date(Date.now() + 2592000000);
15
15
  const in10minutes = () => new Date(Date.now() + 600000);
16
- export async function createSession(userId, elevated = false) {
16
+ export async function createSession(userId, name, elevated = false) {
17
17
  const session = {
18
18
  id: randomUUID(),
19
19
  userId,
20
+ name,
20
21
  token: randomBytes(64).toString('base64'),
21
22
  expires: elevated ? in10minutes() : in30days(),
22
23
  elevated,
@@ -110,7 +111,7 @@ export async function checkAuthForUser(request, userId, sensitive = false) {
110
111
  error(403, 'This token can not be used for sensitive actions');
111
112
  return Object.assign(session, { accessor: session.user });
112
113
  }
113
- export async function authSessionForItem(itemType, itemId, permissions, session) {
114
+ export async function authSessionForItem(itemType, itemId, permissions, session, recursive = false) {
114
115
  const { userId, user } = session ?? {};
115
116
  // Note: we need to do casting because of TS limitations with generics
116
117
  const item = (await db
@@ -137,21 +138,42 @@ export async function authSessionForItem(itemType, itemId, permissions, session)
137
138
  if (userId == item.userId)
138
139
  return result;
139
140
  result.fromACL = true;
140
- if (!item.acl || !item.acl.length)
141
- error(403, 'Item is not shared with you');
142
- const missing = Array.from(acl.check(item.acl, permissions));
143
- if (missing.length)
144
- error(403, 'Missing permissions: ' + missing.join(', '));
145
- return result;
141
+ let current = item;
142
+ for (let i = 0; i < 25; i++) {
143
+ try {
144
+ if (!current.acl || !current.acl.length)
145
+ error(403, 'Item is not shared with you');
146
+ const missing = Array.from(acl.check(current.acl, permissions));
147
+ if (missing.length)
148
+ error(403, 'Missing permissions: ' + missing.join(', '));
149
+ return result;
150
+ }
151
+ catch (e) {
152
+ if (!current.parentId || !recursive)
153
+ throw e;
154
+ current = (await db
155
+ .selectFrom(itemType)
156
+ .selectAll()
157
+ .where('id', '=', current.parentId)
158
+ .$if(!!userId, eb => eb.select(acl.from(itemType, { user })))
159
+ .executeTakeFirstOrThrow()
160
+ .catch(e => {
161
+ if (e.message.includes('no rows'))
162
+ error(404, itemType + ' not found');
163
+ throw e;
164
+ }));
165
+ }
166
+ }
167
+ error(403, 'You do not have permissions for any of the last 25 parent items');
146
168
  }
147
169
  /**
148
170
  * Authenticate a request against an "item" which has an ACL table.
149
171
  * This will fetch the item, ACLs, users, and the authenticating session.
150
172
  */
151
- export async function authRequestForItem(request, itemType, itemId, permissions) {
173
+ export async function authRequestForItem(request, itemType, itemId, permissions, recursive = false) {
152
174
  const token = getToken(request, false);
153
175
  if (!token)
154
176
  error(401, 'Missing token');
155
177
  const session = await getSessionAndUser(token).catch(() => null);
156
- return await authSessionForItem(itemType, itemId, permissions, session);
178
+ return await authSessionForItem(itemType, itemId, permissions, session, recursive);
157
179
  }
@@ -77,6 +77,16 @@
77
77
  }
78
78
  }
79
79
  }
80
+ },
81
+ {
82
+ "delta": true,
83
+ "alter_tables": {
84
+ "sessions": {
85
+ "add_columns": {
86
+ "name": { "type": "text" }
87
+ }
88
+ }
89
+ }
80
90
  }
81
91
  ],
82
92
  "wipe": ["users", "verifications", "passkeys", "sessions", "audit_log"]
@@ -35,11 +35,13 @@ export declare function json(data: object, init?: ResponseInit): Response;
35
35
  export declare function parseBody<const Schema extends z.ZodType>(request: Request, schema: Schema): Promise<z.infer<Schema>>;
36
36
  export declare function parseSearch<const Schema extends z.ZodType>(request: Request, schema: Schema): z.infer<Schema>;
37
37
  export declare function getToken(request: Request, sensitive?: boolean): string | undefined;
38
+ export declare function getPrettyUA(request: Request, uaOverride?: string): Promise<string | null>;
38
39
  export interface CreateSessionOptions {
39
40
  elevated?: boolean;
40
41
  noCookie?: boolean;
42
+ clientUA?: string;
41
43
  }
42
- export declare function createSessionData(userId: string, { elevated, noCookie }?: CreateSessionOptions): Promise<Response>;
44
+ export declare function createSessionData(userId: string, request: Request, { elevated, noCookie, clientUA }?: CreateSessionOptions): Promise<Response>;
43
45
  export declare function stripUser(user: UserInternal, includeProtected?: boolean): User;
44
46
  export declare function withError(text: string, code?: number): (e: Error | ResponseError) => never;
45
47
  export declare function handleAPIRequest(request: Request, params: Record<string, any>, route: ServerRoute): Promise<Response>;
package/dist/requests.js CHANGED
@@ -62,8 +62,29 @@ export function getToken(request, sensitive = false) {
62
62
  return cookie.parse(request.headers.get('cookie') || '')[sensitive ? 'elevated_token' : 'session_token'];
63
63
  }
64
64
  }
65
- export async function createSessionData(userId, { elevated = false, noCookie } = {}) {
66
- const { token, expires } = await createSession(userId, elevated);
65
+ const uaParserExtension = {
66
+ browser: [[/(axium[- ]client)\/([\d.]+)/i], ['name', 'version']],
67
+ };
68
+ export async function getPrettyUA(request, uaOverride) {
69
+ const uaString = uaOverride || request.headers.get('User-Agent') || '';
70
+ try {
71
+ const { UAParser } = await import('ua-parser-js');
72
+ const ua = UAParser(uaString, uaParserExtension);
73
+ return [
74
+ ua.browser.name,
75
+ ua.browser.name && /axium[- ]client/i.test(ua.browser.name) ? ua.browser.version : ua.browser.major,
76
+ ua.os.name && ' on ' + ua.os.name,
77
+ ]
78
+ .filter(p => p)
79
+ .join(' ');
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ export async function createSessionData(userId, request, { elevated = false, noCookie, clientUA } = {}) {
86
+ const name = await getPrettyUA(request, clientUA);
87
+ const { token, expires } = await createSession(userId, name, elevated);
67
88
  const response = json({ userId, token: elevated ? '[[redacted:elevated]]' : token }, { status: 201 });
68
89
  if (noCookie)
69
90
  return response;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/server",
3
- "version": "0.37.0",
3
+ "version": "0.37.2",
4
4
  "author": "James Prevett <axium@jamespre.dev>",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -66,5 +66,8 @@
66
66
  "devDependencies": {
67
67
  "@sveltejs/adapter-node": "^5.2.12",
68
68
  "vite-plugin-mkcert": "^1.17.8"
69
+ },
70
+ "optionalDependencies": {
71
+ "ua-parser-js": "^2.0.9"
69
72
  }
70
73
  }
@@ -13,12 +13,14 @@ export async function load({ parent, url }: Omit<PageLoadEvent, 'parent'> & { pa
13
13
  const port = parseInt(url.searchParams.get('port') ?? '!');
14
14
  const localCallback = new URL('http://localhost');
15
15
 
16
+ const client = url.searchParams.get('client') ?? '';
17
+
16
18
  let options,
17
19
  error: string | null = null;
18
20
  try {
19
21
  if (Number.isNaN(port)) throw new Error('Invalid port number provided by local client');
20
22
  localCallback.port = port.toString();
21
- options = await fetchAPI('OPTIONS', 'users/:id/auth', { type: 'client_login' }, session.userId);
23
+ options = await fetchAPI('OPTIONS', 'users/:id/auth', { type: 'client_login', client }, session.userId);
22
24
  } catch (e: any) {
23
25
  error = e.message;
24
26
  }