@lobb-js/lobb-ext-auth 0.14.0 → 0.15.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/index.js CHANGED
@@ -19,36 +19,36 @@ export default function extension(utils) {
19
19
  {
20
20
  // Generic auth primitive: callers ask "can the current user access X?"
21
21
  // and pass any combination of { collection, action, role }. The handler
22
- // returns a boolean directly; callers read that and decide what to render.
23
- // Note: returning a boolean means downstream handlers can't read the
24
- // original request if you ever need multiple extensions to influence
25
- // this decision, switch back to a `{ ...input, allowed }` object shape.
22
+ // takes over with a boolean (`ctx.takeOver(bool)`); callers read that
23
+ // and decide what to render. A take-over is required because the
24
+ // dispatch ignores plain returns a bare `return false` would be
25
+ // dropped and the caller would default to allow.
26
26
  eventName: "auth.canAccess",
27
- handler: async (input) => {
27
+ handler: async (input, ctx) => {
28
28
  // Admin shortcut — admins bypass everything, regardless of how
29
29
  // their permissions snapshot was populated.
30
30
  const user = utils.ctx.extensions.auth?.user;
31
31
  if (user?.role === "admin")
32
- return true;
32
+ return ctx.takeOver(true);
33
33
  // Walk the permissions snapshot. Works for both logged-in non-admin
34
34
  // users (snapshot from their role) and share-token recipients
35
35
  // (snapshot from the share's embedded permissions). If nothing
36
36
  // is populated — anonymous viewer — default to deny.
37
37
  const permissions = utils.ctx.extensions.auth?.permissions;
38
38
  if (permissions === undefined || permissions === null)
39
- return false;
39
+ return ctx.takeOver(false);
40
40
  if (permissions === true)
41
- return true;
41
+ return ctx.takeOver(true);
42
42
  const action = input.action ?? "read";
43
43
  const requiredCollections = Array.isArray(input.collection)
44
44
  ? input.collection
45
45
  : input.collection ? [input.collection] : [];
46
46
  for (const collectionName of requiredCollections) {
47
47
  if (!isActionAllowed(collectionName, action, permissions)) {
48
- return false;
48
+ return ctx.takeOver(false);
49
49
  }
50
50
  }
51
- return true;
51
+ return ctx.takeOver(true);
52
52
  },
53
53
  },
54
54
  ],
@@ -22,24 +22,24 @@ export default function extension(utils: ExtensionUtils): Extension {
22
22
  {
23
23
  // Generic auth primitive: callers ask "can the current user access X?"
24
24
  // and pass any combination of { collection, action, role }. The handler
25
- // returns a boolean directly; callers read that and decide what to render.
26
- // Note: returning a boolean means downstream handlers can't read the
27
- // original request if you ever need multiple extensions to influence
28
- // this decision, switch back to a `{ ...input, allowed }` object shape.
25
+ // takes over with a boolean (`ctx.takeOver(bool)`); callers read that
26
+ // and decide what to render. A take-over is required because the
27
+ // dispatch ignores plain returns a bare `return false` would be
28
+ // dropped and the caller would default to allow.
29
29
  eventName: "auth.canAccess",
30
- handler: async (input) => {
30
+ handler: async (input, ctx) => {
31
31
  // Admin shortcut — admins bypass everything, regardless of how
32
32
  // their permissions snapshot was populated.
33
33
  const user = utils.ctx.extensions.auth?.user;
34
- if (user?.role === "admin") return true;
34
+ if (user?.role === "admin") return ctx.takeOver(true);
35
35
 
36
36
  // Walk the permissions snapshot. Works for both logged-in non-admin
37
37
  // users (snapshot from their role) and share-token recipients
38
38
  // (snapshot from the share's embedded permissions). If nothing
39
39
  // is populated — anonymous viewer — default to deny.
40
40
  const permissions = utils.ctx.extensions.auth?.permissions;
41
- if (permissions === undefined || permissions === null) return false;
42
- if (permissions === true) return true;
41
+ if (permissions === undefined || permissions === null) return ctx.takeOver(false);
42
+ if (permissions === true) return ctx.takeOver(true);
43
43
 
44
44
  const action: CollectionPermissionActionsKeys = input.action ?? "read";
45
45
 
@@ -49,11 +49,11 @@ export default function extension(utils: ExtensionUtils): Extension {
49
49
 
50
50
  for (const collectionName of requiredCollections) {
51
51
  if (!isActionAllowed(collectionName, action, permissions)) {
52
- return false;
52
+ return ctx.takeOver(false);
53
53
  }
54
54
  }
55
55
 
56
- return true;
56
+ return ctx.takeOver(true);
57
57
  },
58
58
  },
59
59
  ],
@@ -15,19 +15,20 @@ export function getAuthActionControllerWorkflows(): Workflow[] {
15
15
  name: "authLogoutActionController",
16
16
  eventName: "core.actions.controller.override",
17
17
  handler: async (input, ctx) => {
18
- if (input.name !== "auth_logout") return;
18
+ if (input.name === "auth_logout") {
19
+ const context = input.context as Context;
20
+ const token = getBearerToken(context);
21
+ if (!token) {
22
+ throw new ctx.LobbError({
23
+ code: "BAD_REQUEST",
24
+ message:
25
+ "You should pass session's token through the bearer header",
26
+ });
27
+ }
19
28
 
20
- const context = input.context as Context;
21
- const token = getBearerToken(context);
22
- if (!token) {
23
- throw new ctx.LobbError({
24
- code: "BAD_REQUEST",
25
- message: "You should pass session's token through the bearer header",
26
- });
29
+ await ctx.lobb.runAction("auth_logout", { token });
30
+ return ctx.takeOver(context.body(null, 204));
27
31
  }
28
-
29
- await ctx.lobb.runAction("auth_logout", { token });
30
- return context.body(null, 204);
31
32
  },
32
33
  },
33
34
  ];
@@ -9,32 +9,42 @@ export function getBaseWorkflows(_extensionConfig: ExtensionConfig): Workflow[]
9
9
  name: "auth_preventAdminUserDeletion",
10
10
  eventName: "core.store.preDeleteOne",
11
11
  handler: async (input, ctx) => {
12
- if (input.collectionName !== "auth_users") return input;
13
- if (input.triggeredBy !== "API") return input;
14
-
15
- if (Number(input.id) === 1) {
12
+ if (
13
+ input.collectionName === "auth_users" &&
14
+ input.triggeredBy === "API" &&
15
+ Number(input.id) === 1
16
+ ) {
16
17
  throw new ctx.LobbError({
17
18
  code: "FORBIDDEN",
18
19
  message: "The admin user cannot be deleted.",
19
20
  });
20
21
  }
21
- return input;
22
22
  },
23
23
  },
24
24
  {
25
25
  name: "auth_preventAdminUserUpdate",
26
26
  eventName: "core.store.preUpdateOne",
27
27
  handler: async (input, ctx) => {
28
- if (input.collectionName !== "auth_users") return input;
29
- if (input.triggeredBy !== "API") return input;
30
-
31
- if (Number(input.id) === 1) {
32
- throw new ctx.LobbError({
33
- code: "FORBIDDEN",
34
- message: "The admin user cannot be updated.",
35
- });
28
+ if (
29
+ input.collectionName === "auth_users" &&
30
+ input.triggeredBy === "API" &&
31
+ Number(input.id) === 1
32
+ ) {
33
+ // Only the security-sensitive core fields are locked on the admin —
34
+ // changing them could lock out or hijack the account. Other fields
35
+ // (e.g. extension-added ones) are fine to update.
36
+ const protectedFields = ["email", "password", "role"];
37
+ const touched = Object.keys(input.data ?? {}).filter((field) =>
38
+ protectedFields.includes(field)
39
+ );
40
+ if (touched.length) {
41
+ throw new ctx.LobbError({
42
+ code: "FORBIDDEN",
43
+ message:
44
+ `The admin user's ${touched.join(", ")} cannot be updated.`,
45
+ });
46
+ }
36
47
  }
37
- return input;
38
48
  },
39
49
  },
40
50
  ...baseWorkflows,
@@ -51,58 +61,55 @@ export const baseWorkflows: Workflow[] = [
51
61
  eventName: "core.webserver.middlwares.pre",
52
62
  handler: async (input, ctx) => {
53
63
  const token = getBearerToken(input.context);
54
- if (!token) return input;
55
-
56
- const context = input.context as Context;
57
-
58
- // 1) Try the token as a normal user session.
59
- const sessionsResult = await ctx.workflows.collectionStore({
60
- method: "findAll",
61
- props: {
62
- collectionName: "auth_sessions",
63
- params: { filter: { token } },
64
- },
65
- });
66
- const session = sessionsResult.data[0];
64
+ if (token) {
65
+ const context = input.context as Context;
67
66
 
68
- if (session) {
69
- const usersResult = await ctx.workflows.collectionStore({
67
+ // 1) Try the token as a normal user session.
68
+ const sessionsResult = await ctx.workflows.collectionStore({
70
69
  method: "findAll",
71
70
  props: {
72
- collectionName: "auth_users",
73
- params: { filter: { id: session.user_id } },
71
+ collectionName: "auth_sessions",
72
+ params: { filter: { token } },
74
73
  },
75
74
  });
76
- const user = usersResult.data[0];
75
+ const session = sessionsResult.data[0];
77
76
 
78
- context.set("auth_session", session);
79
- context.set("auth_user", user);
80
- return input;
81
- }
77
+ if (session) {
78
+ const usersResult = await ctx.workflows.collectionStore({
79
+ method: "findAll",
80
+ props: {
81
+ collectionName: "auth_users",
82
+ params: { filter: { id: session.user_id } },
83
+ },
84
+ });
85
+ const user = usersResult.data[0];
82
86
 
83
- // 2) Otherwise try the token as a share. Shares carry their own
84
- // permissions snapshot and have a fixed expiry — no user identity.
85
- const sharesResult = await ctx.workflows.collectionStore({
86
- method: "findAll",
87
- props: {
88
- collectionName: "auth_shares",
89
- params: { filter: { token } },
90
- },
91
- });
92
- const share = sharesResult.data[0];
87
+ context.set("auth_session", session);
88
+ context.set("auth_user", user);
89
+ } else {
90
+ // 2) Otherwise try the token as a share. Shares carry their own
91
+ // permissions snapshot and have a fixed expiry — no user identity.
92
+ const sharesResult = await ctx.workflows.collectionStore({
93
+ method: "findAll",
94
+ props: {
95
+ collectionName: "auth_shares",
96
+ params: { filter: { token } },
97
+ },
98
+ });
99
+ const share = sharesResult.data[0];
93
100
 
94
- if (share && new Date(share.expires_at) > new Date()) {
95
- let permissions: any = {};
96
- try {
97
- permissions = JSON.parse(share.permissions ?? "{}");
98
- } catch {
99
- // malformed permissions — treat as no access
100
- permissions = {};
101
+ if (share && new Date(share.expires_at) > new Date()) {
102
+ let permissions: any = {};
103
+ try {
104
+ permissions = JSON.parse(share.permissions ?? "{}");
105
+ } catch {
106
+ // malformed permissions — treat as no access
107
+ permissions = {};
108
+ }
109
+ context.set("auth_share", { ...share, permissions });
110
+ }
101
111
  }
102
- context.set("auth_share", { ...share, permissions });
103
112
  }
104
-
105
- return input;
106
113
  },
107
114
  },
108
115
  ];
@@ -15,18 +15,19 @@ export function getCurrentUserPermissionsWorkflow(
15
15
  name: "auth_attachCurrentUserPermissions",
16
16
  eventName: "core.controllers.findOne",
17
17
  handler: async (input) => {
18
- if (input.collectionName !== "auth_users") return input;
19
18
  const context = input.context as Context | undefined;
20
- if (context?.req.param("id") !== "me") return input;
21
-
22
- const role = input.response?.data?.role;
23
- // Strip functions (e.g. dynamic filter predicates) — they can't be
24
- // serialised to the client.
25
- const permissions = JSON.parse(JSON.stringify(
26
- extensionConfig.roles?.[role]?.permissions ?? {},
27
- ));
28
- input.response = { ...input.response, permissions };
29
- return input;
19
+ if (
20
+ input.collectionName === "auth_users" &&
21
+ context?.req.param("id") === "me"
22
+ ) {
23
+ const role = input.response?.data?.role;
24
+ // Strip functions (e.g. dynamic filter predicates) — they can't be
25
+ // serialised to the client.
26
+ const permissions = JSON.parse(JSON.stringify(
27
+ extensionConfig.roles?.[role]?.permissions ?? {},
28
+ ));
29
+ input.response = { ...input.response, permissions };
30
+ }
30
31
  },
31
32
  };
32
33
  }
@@ -22,14 +22,15 @@ function resolveMeAlias(input: any, ctx: EventContext): number {
22
22
 
23
23
  export const meAliasWorkflows: Workflow[] = [
24
24
  {
25
- name: "auth_userFindOneMeAlias",
26
- eventName: "core.store.preFindOne",
25
+ // Share bearers don't have an auth_users row to fetch — take over the /me
26
+ // findOne at the controller boundary with the share's permissions snapshot.
27
+ // Studio uses the same /me endpoint to learn what the current bearer can do
28
+ // regardless of bearer type. Done at the controller level (not the store)
29
+ // so the HTTP response never originates from the data layer; the store-level
30
+ // resolveMeAlias below only runs for real users (where it resolves the id).
31
+ name: "auth_userFindOneMeShare",
32
+ eventName: "core.controllers.preFindOne",
27
33
  handler: async (input, ctx) => {
28
- // Share bearers don't have an auth_users row to fetch — short-circuit
29
- // the findOne with the share's permissions snapshot. Studio uses the
30
- // same /me endpoint to learn what the current bearer can do regardless
31
- // of bearer type. Falls through to resolveMeAlias otherwise (which
32
- // handles the session case and throws for unauthenticated requests).
33
34
  if (input.collectionName === "auth_users" && input.id === "me") {
34
35
  const context = input.context as Context;
35
36
  const user = context.get("auth_user") as User | undefined;
@@ -38,7 +39,7 @@ export const meAliasWorkflows: Workflow[] = [
38
39
  | { permissions: any }
39
40
  | undefined;
40
41
  if (share) {
41
- throw new Response(
42
+ return ctx.takeOver(new Response(
42
43
  JSON.stringify({
43
44
  data: null,
44
45
  permissions: share.permissions,
@@ -47,12 +48,17 @@ export const meAliasWorkflows: Workflow[] = [
47
48
  status: 200,
48
49
  headers: { "Content-Type": "application/json" },
49
50
  },
50
- );
51
+ ));
51
52
  }
52
53
  }
53
54
  }
55
+ },
56
+ },
57
+ {
58
+ name: "auth_userFindOneMeAlias",
59
+ eventName: "core.store.preFindOne",
60
+ handler: async (input, ctx) => {
54
61
  input.id = resolveMeAlias(input, ctx);
55
- return input;
56
62
  },
57
63
  },
58
64
  {
@@ -60,7 +66,6 @@ export const meAliasWorkflows: Workflow[] = [
60
66
  eventName: "core.store.preUpdateOne",
61
67
  handler: async (input, ctx) => {
62
68
  input.id = resolveMeAlias(input, ctx);
63
- return input;
64
69
  },
65
70
  },
66
71
  {
@@ -68,7 +73,6 @@ export const meAliasWorkflows: Workflow[] = [
68
73
  eventName: "core.store.preDeleteOne",
69
74
  handler: async (input, ctx) => {
70
75
  input.id = resolveMeAlias(input, ctx);
71
- return input;
72
76
  },
73
77
  },
74
78
  ];
@@ -33,7 +33,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
33
33
  });
34
34
  if (output?.payload) input.data = output.payload;
35
35
  }
36
- return input;
37
36
  },
38
37
  },
39
38
  {
@@ -60,7 +59,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
60
59
  });
61
60
  if (output?.filter) input.filter = output.filter;
62
61
  }
63
- return input;
64
62
  },
65
63
  },
66
64
  {
@@ -88,7 +86,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
88
86
  });
89
87
  if (output?.payload) input.data = output.payload;
90
88
  }
91
- return input;
92
89
  },
93
90
  },
94
91
  {
@@ -114,7 +111,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
114
111
  recordId: input.id,
115
112
  });
116
113
  }
117
- return input;
118
114
  },
119
115
  },
120
116
  // Post-fetch field-level filtering using the role/share's `read.fields`
@@ -132,7 +128,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
132
128
  );
133
129
  input.data = handleReadFields(permissions, input.collectionName, input.data);
134
130
  }
135
- return input;
136
131
  },
137
132
  },
138
133
  {
@@ -148,7 +143,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
148
143
  );
149
144
  input.data = handleReadFields(permissions, input.collectionName, input.data);
150
145
  }
151
- return input;
152
146
  },
153
147
  },
154
148
  {
@@ -164,7 +158,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
164
158
  );
165
159
  input.data = handleReadFields(permissions, input.collectionName, input.data);
166
160
  }
167
- return input;
168
161
  },
169
162
  },
170
163
  {
@@ -180,7 +173,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
180
173
  );
181
174
  input.data = handleReadFields(permissions, input.collectionName, input.data);
182
175
  }
183
- return input;
184
176
  },
185
177
  },
186
178
  ];
@@ -22,7 +22,6 @@ export function getSharesWorkflows(extensionConfig: ExtensionConfig): Workflow[]
22
22
  if (input.collectionName === "auth_shares") {
23
23
  input.data.token = generateRandomId();
24
24
  }
25
- return input;
26
25
  },
27
26
  },
28
27
  // Normalize the two ways a caller can specify share lifetime into the
@@ -37,30 +36,28 @@ export function getSharesWorkflows(extensionConfig: ExtensionConfig): Workflow[]
37
36
  eventName: "core.store.preCreateOne",
38
37
  order: "pre",
39
38
  handler: async (input, ctx) => {
40
- if (input.collectionName !== "auth_shares") return input;
39
+ if (input.collectionName === "auth_shares") {
40
+ const seconds = input.data.expires_in_seconds;
41
+ if (typeof seconds === "number") {
42
+ if (!Number.isFinite(seconds) || seconds <= 0) {
43
+ throw new ctx.LobbError({
44
+ code: "BAD_REQUEST",
45
+ message: "expires_in_seconds must be a positive number.",
46
+ });
47
+ }
48
+ input.data.expires_at = new Date(Date.now() + seconds * 1000)
49
+ .toISOString();
50
+ delete input.data.expires_in_seconds;
51
+ }
41
52
 
42
- const seconds = input.data.expires_in_seconds;
43
- if (typeof seconds === "number") {
44
- if (!Number.isFinite(seconds) || seconds <= 0) {
53
+ if (!input.data.expires_at) {
45
54
  throw new ctx.LobbError({
46
55
  code: "BAD_REQUEST",
47
- message: "expires_in_seconds must be a positive number.",
56
+ message:
57
+ "Must provide either expires_at or expires_in_seconds when creating a share.",
48
58
  });
49
59
  }
50
- input.data.expires_at = new Date(Date.now() + seconds * 1000)
51
- .toISOString();
52
- delete input.data.expires_in_seconds;
53
- }
54
-
55
- if (!input.data.expires_at) {
56
- throw new ctx.LobbError({
57
- code: "BAD_REQUEST",
58
- message:
59
- "Must provide either expires_at or expires_in_seconds when creating a share.",
60
- });
61
60
  }
62
-
63
- return input;
64
61
  },
65
62
  },
66
63
  // Auto-set created_by from the authenticated request user so callers
@@ -71,12 +68,14 @@ export function getSharesWorkflows(extensionConfig: ExtensionConfig): Workflow[]
71
68
  name: "auth_setShareCreatedBy",
72
69
  eventName: "core.store.preCreateOne",
73
70
  handler: async (input) => {
74
- if (input.collectionName !== "auth_shares") return input;
75
- if (input.triggeredBy !== "API") return input;
76
- const context = input.context as Context;
77
- const user = context.get("auth_user") as User | undefined;
78
- if (user) input.data.created_by = user.id;
79
- return input;
71
+ if (
72
+ input.collectionName === "auth_shares" &&
73
+ input.triggeredBy === "API"
74
+ ) {
75
+ const context = input.context as Context;
76
+ const user = context.get("auth_user") as User | undefined;
77
+ if (user) input.data.created_by = user.id;
78
+ }
80
79
  },
81
80
  },
82
81
  // Intersection guard: a share's embedded permissions snapshot must be a
@@ -88,35 +87,35 @@ export function getSharesWorkflows(extensionConfig: ExtensionConfig): Workflow[]
88
87
  name: "auth_validateShareSubset",
89
88
  eventName: "core.store.preCreateOne",
90
89
  handler: async (input, ctx) => {
91
- if (input.collectionName !== "auth_shares") return input;
92
- if (input.triggeredBy !== "API") return input;
90
+ if (
91
+ input.collectionName === "auth_shares" &&
92
+ input.triggeredBy === "API"
93
+ ) {
94
+ const context = input.context as Context;
95
+ const { permissions: creatorPermissions } = resolveRequestPermissions(
96
+ context,
97
+ extensionConfig,
98
+ ctx,
99
+ );
93
100
 
94
- const context = input.context as Context;
95
- const { permissions: creatorPermissions } = resolveRequestPermissions(
96
- context,
97
- extensionConfig,
98
- ctx,
99
- );
100
-
101
- let sharePermissions: PermissionsConfig;
102
- try {
103
- sharePermissions = JSON.parse(input.data.permissions);
104
- } catch {
105
- throw new ctx.LobbError({
106
- code: "BAD_REQUEST",
107
- message: "Share permissions must be valid JSON.",
108
- });
109
- }
101
+ let sharePermissions: PermissionsConfig;
102
+ try {
103
+ sharePermissions = JSON.parse(input.data.permissions);
104
+ } catch {
105
+ throw new ctx.LobbError({
106
+ code: "BAD_REQUEST",
107
+ message: "Share permissions must be valid JSON.",
108
+ });
109
+ }
110
110
 
111
- if (!isShareSubsetOfCreator(sharePermissions, creatorPermissions)) {
112
- throw new ctx.LobbError({
113
- code: "FORBIDDEN",
114
- message:
115
- "Cannot create a share with broader permissions than your own.",
116
- });
111
+ if (!isShareSubsetOfCreator(sharePermissions, creatorPermissions)) {
112
+ throw new ctx.LobbError({
113
+ code: "FORBIDDEN",
114
+ message:
115
+ "Cannot create a share with broader permissions than your own.",
116
+ });
117
+ }
117
118
  }
118
-
119
- return input;
120
119
  },
121
120
  },
122
121
  // Hourly housekeeping: delete shares whose expires_at is in the past.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobb-js/lobb-ext-auth",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,12 +32,12 @@
32
32
  "package": "svelte-package --input extensions/auth/studio"
33
33
  },
34
34
  "dependencies": {
35
- "@lobb-js/core": "^0.41.0",
35
+ "@lobb-js/core": "^0.42.0",
36
36
  "argon2": "^0.40.3",
37
37
  "hono": "^4.7.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@lobb-js/studio": "^0.49.0",
40
+ "@lobb-js/studio": "^0.50.0",
41
41
  "@lucide/svelte": "^0.563.1",
42
42
  "@sveltejs/adapter-node": "^5.5.4",
43
43
  "@sveltejs/kit": "^2.60.1",