@lobb-js/lobb-ext-auth 0.13.0 → 0.14.1

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/auth.js CHANGED
@@ -17,7 +17,7 @@ export class Auth {
17
17
  async login(payload) {
18
18
  const response = await this.utils.lobb.request({
19
19
  method: "POST",
20
- route: "/api/collections/auth_sessions",
20
+ route: "/api/actions/auth_login",
21
21
  payload: {
22
22
  data: payload,
23
23
  },
@@ -41,8 +41,8 @@ export class Auth {
41
41
  }
42
42
  async logout() {
43
43
  await this.utils.lobb.request({
44
- method: "DELETE",
45
- route: "/api/extensions/auth/logout",
44
+ method: "POST",
45
+ route: "/api/actions/auth_logout",
46
46
  });
47
47
  localStorage.removeItem("lobb_session");
48
48
  const currentPath = window.location.pathname;
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
  ],
@@ -12,7 +12,7 @@ async function syncincAdminUserInDB(lobb: Lobb, extensionConfig: ExtensionConfig
12
12
  // syncinc the admin user in users collection
13
13
  const config = extensionConfig;
14
14
  const { email, password, ...extraFields } = config.admin;
15
- const entries = (await lobb.collectionService.findAll({
15
+ const entries = (await lobb.collectionStore.findAll({
16
16
  collectionName: "auth_users",
17
17
  params: {
18
18
  filter: {
@@ -23,7 +23,7 @@ async function syncincAdminUserInDB(lobb: Lobb, extensionConfig: ExtensionConfig
23
23
  })).data;
24
24
  const adminUser = entries[0];
25
25
  if (!adminUser) {
26
- await lobb.collectionService.createOne({
26
+ await lobb.collectionStore.createOne({
27
27
  collectionName: "auth_users",
28
28
  data: {
29
29
  ...extraFields,
@@ -39,7 +39,7 @@ async function syncincAdminUserInDB(lobb: Lobb, extensionConfig: ExtensionConfig
39
39
  );
40
40
 
41
41
  if (adminUser.email !== email || !passwordIdentical) {
42
- await lobb.collectionService.updateOne({
42
+ await lobb.collectionStore.updateOne({
43
43
  collectionName: "auth_users",
44
44
  id: adminUser.id,
45
45
  data: {
@@ -5,12 +5,14 @@ import { collections } from "./collections/collections.ts";
5
5
  import { meta } from "./meta/meta.ts";
6
6
  import { migrations } from "./database/migrations.ts";
7
7
  import { getWorkflows } from "./workflows/index.ts";
8
+ import { getAuthActions } from "./workflows/actions.ts";
8
9
 
9
10
  export default function auth(extensionConfig: ExtensionConfig): Extension {
10
11
  return {
11
12
  name: "auth",
12
13
  icon: "Key",
13
14
  collections: (lobb) => collections(lobb, extensionConfig),
15
+ actions: getAuthActions(extensionConfig),
14
16
  migrations: migrations,
15
17
  meta: (lobb) => meta(lobb, extensionConfig),
16
18
  workflows: getWorkflows(extensionConfig),
@@ -35,7 +35,7 @@ export const openapi = {
35
35
  },
36
36
  },
37
37
  paths: {
38
- "/api/extensions/auth/login": {
38
+ "/api/actions/auth_login": {
39
39
  post: {
40
40
  tags: [
41
41
  "auth",
@@ -223,8 +223,8 @@ export const openapi = {
223
223
  },
224
224
  },
225
225
  },
226
- "/api/extensions/auth/logout": {
227
- delete: {
226
+ "/api/actions/auth_logout": {
227
+ post: {
228
228
  tags: [
229
229
  "auth",
230
230
  ],
@@ -26,7 +26,7 @@ export class Auth {
26
26
  public async login(payload: LoginPayload) {
27
27
  const response = await this.utils.lobb.request({
28
28
  method: "POST",
29
- route: "/api/collections/auth_sessions",
29
+ route: "/api/actions/auth_login",
30
30
  payload: {
31
31
  data: payload,
32
32
  },
@@ -55,8 +55,8 @@ export class Auth {
55
55
 
56
56
  public async logout() {
57
57
  await this.utils.lobb.request({
58
- method: "DELETE",
59
- route: "/api/extensions/auth/logout",
58
+ method: "POST",
59
+ route: "/api/actions/auth_logout",
60
60
  });
61
61
  localStorage.removeItem("lobb_session");
62
62
  const currentPath = window.location.pathname;
@@ -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
  ],
@@ -0,0 +1,35 @@
1
+ import type { Workflow } from "@lobb-js/core";
2
+ import type { Context } from "hono";
3
+ import { getBearerToken } from "../utils.ts";
4
+
5
+ // `auth_logout` needs the session token, which arrives as a bearer header. Read
6
+ // it off the Hono context here and pass it to the action (keeping the action
7
+ // pure + internally callable with an explicit token), then return 204.
8
+ //
9
+ // `auth_login` needs no HTTP adaptation — its JSON body { email, password } maps
10
+ // straight to the default action flow.
11
+
12
+ export function getAuthActionControllerWorkflows(): Workflow[] {
13
+ return [
14
+ {
15
+ name: "authLogoutActionController",
16
+ eventName: "core.actions.controller.override",
17
+ handler: async (input, ctx) => {
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
+ }
28
+
29
+ await ctx.lobb.runAction("auth_logout", { token });
30
+ return ctx.takeOver(context.body(null, 204));
31
+ }
32
+ },
33
+ },
34
+ ];
35
+ }
@@ -0,0 +1,89 @@
1
+ import type { ActionsConfig } from "@lobb-js/core";
2
+ import { LobbError } from "@lobb-js/core";
3
+ import type { ExtensionConfig, User } from "../config/extensionConfigSchema.ts";
4
+ import { generateRandomId } from "../utils.ts";
5
+ import { verify } from "argon2";
6
+
7
+ // Login and logout used to hijack the auth_sessions CRUD endpoints (create =
8
+ // login, deleteMany = logout), which made it impossible to manage session rows
9
+ // normally. They're now first-class actions; the auth_sessions collection is a
10
+ // plain CRUD collection again.
11
+
12
+ export function getAuthActions(_extensionConfig: ExtensionConfig): ActionsConfig {
13
+ return {
14
+ // POST /api/actions/auth_login { data: { email, password } }
15
+ auth_login: {
16
+ handler: async (input: any, ctx) => {
17
+ const email = input.data?.email ?? input.email;
18
+ const password = input.data?.password ?? input.password;
19
+
20
+ const users = await ctx.lobb.collectionStore.findAll({
21
+ collectionName: "auth_users",
22
+ params: { filter: { email } },
23
+ });
24
+
25
+ if (!users.data.length) {
26
+ throw new LobbError({
27
+ code: "NOT_FOUND",
28
+ message: `The user with this email (${email}) doesnt exist.`,
29
+ });
30
+ }
31
+
32
+ const user = users.data[0] as User;
33
+
34
+ const passwordIsCorrect = await verify(user.password, password);
35
+ if (!passwordIsCorrect) {
36
+ throw new LobbError({
37
+ code: "UNAUTHORIZED",
38
+ message: "The password provided is incorrect. Please verify and try again.",
39
+ });
40
+ }
41
+
42
+ const sessionPayload = {
43
+ token: generateRandomId(),
44
+ expires_at: new Date(Date.now() + 3600 * 1000),
45
+ user_id: user.id,
46
+ };
47
+
48
+ const sessionResult = await ctx.lobb.collectionStore.createOne({
49
+ collectionName: "auth_sessions",
50
+ data: sessionPayload,
51
+ });
52
+
53
+ return {
54
+ data: {
55
+ access_token: {
56
+ token: sessionPayload.token,
57
+ expires_at: sessionResult.data.expires_at,
58
+ },
59
+ user: {
60
+ id: user.id,
61
+ email: user.email,
62
+ role: user.role,
63
+ },
64
+ },
65
+ };
66
+ },
67
+ },
68
+
69
+ // POST /api/actions/auth_logout (bearer header → resolved to { token })
70
+ auth_logout: {
71
+ handler: async (input: any, ctx) => {
72
+ const token = input.token;
73
+ if (!token) {
74
+ throw new LobbError({
75
+ code: "BAD_REQUEST",
76
+ message: "You should pass session's token through the bearer header",
77
+ });
78
+ }
79
+
80
+ await ctx.lobb.collectionStore.deleteMany({
81
+ collectionName: "auth_sessions",
82
+ filter: { token },
83
+ });
84
+
85
+ return { success: true };
86
+ },
87
+ },
88
+ };
89
+ }
@@ -1,18 +1,19 @@
1
1
  import type { Workflow } from "@lobb-js/core";
2
2
  import type { Context } from "hono";
3
- import type { ExtensionConfig, User } from "../config/extensionConfigSchema.ts";
4
- import { generateRandomId, getBearerToken } from "../utils.ts";
5
- import { verify } from "argon2";
3
+ import type { ExtensionConfig } from "../config/extensionConfigSchema.ts";
4
+ import { getBearerToken } from "../utils.ts";
6
5
 
7
6
  export function getBaseWorkflows(_extensionConfig: ExtensionConfig): Workflow[] {
8
7
  return [
9
8
  {
10
9
  name: "auth_preventAdminUserDeletion",
11
- eventName: "core.service.preDeleteOne",
10
+ eventName: "core.store.preDeleteOne",
12
11
  handler: async (input, ctx) => {
13
- if (input.collectionName !== "auth_users") return;
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.",
@@ -22,11 +23,13 @@ export function getBaseWorkflows(_extensionConfig: ExtensionConfig): Workflow[]
22
23
  },
23
24
  {
24
25
  name: "auth_preventAdminUserUpdate",
25
- eventName: "core.service.preUpdateOne",
26
+ eventName: "core.store.preUpdateOne",
26
27
  handler: async (input, ctx) => {
27
- if (input.collectionName !== "auth_users") return;
28
-
29
- if (Number(input.id) === 1) {
28
+ if (
29
+ input.collectionName === "auth_users" &&
30
+ input.triggeredBy === "API" &&
31
+ Number(input.id) === 1
32
+ ) {
30
33
  throw new ctx.LobbError({
31
34
  code: "FORBIDDEN",
32
35
  message: "The admin user cannot be updated.",
@@ -48,167 +51,54 @@ export const baseWorkflows: Workflow[] = [
48
51
  eventName: "core.webserver.middlwares.pre",
49
52
  handler: async (input, ctx) => {
50
53
  const token = getBearerToken(input.context);
51
- if (!token) return input;
52
-
53
- const context = input.context as Context;
54
-
55
- // 1) Try the token as a normal user session.
56
- const sessionsResult = await ctx.workflows.collectionService({
57
- method: "findAll",
58
- props: {
59
- collectionName: "auth_sessions",
60
- params: { filter: { token } },
61
- },
62
- });
63
- const session = sessionsResult.data[0];
54
+ if (token) {
55
+ const context = input.context as Context;
64
56
 
65
- if (session) {
66
- const usersResult = await ctx.workflows.collectionService({
57
+ // 1) Try the token as a normal user session.
58
+ const sessionsResult = await ctx.workflows.collectionStore({
67
59
  method: "findAll",
68
60
  props: {
69
- collectionName: "auth_users",
70
- params: { filter: { id: session.user_id } },
61
+ collectionName: "auth_sessions",
62
+ params: { filter: { token } },
71
63
  },
72
64
  });
73
- const user = usersResult.data[0];
74
-
75
- context.set("auth_session", session);
76
- context.set("auth_user", user);
77
- return input;
78
- }
79
-
80
- // 2) Otherwise try the token as a share. Shares carry their own
81
- // permissions snapshot and have a fixed expiry — no user identity.
82
- const sharesResult = await ctx.workflows.collectionService({
83
- method: "findAll",
84
- props: {
85
- collectionName: "auth_shares",
86
- params: { filter: { token } },
87
- },
88
- });
89
- const share = sharesResult.data[0];
90
-
91
- if (share && new Date(share.expires_at) > new Date()) {
92
- let permissions: any = {};
93
- try {
94
- permissions = JSON.parse(share.permissions ?? "{}");
95
- } catch {
96
- // malformed permissions — treat as no access
97
- permissions = {};
98
- }
99
- context.set("auth_share", { ...share, permissions });
100
- }
101
-
102
- return input;
103
- },
104
- },
105
- // auth_logins workflows
106
- {
107
- name: "auth_userLoginsHandler",
108
- eventName: "core.controllers.preCreateOne",
109
- handler: async (input, ctx) => {
110
- if (input.collectionName === "auth_sessions") {
111
- const payloadEmail = input.body.data.email;
112
- const payloadPassword = input.body.data.password;
113
-
114
- const users = await ctx.workflows.collectionService({
115
- method: "findAll",
116
- props: {
117
- collectionName: "auth_users",
118
- params: {
119
- filter: {
120
- email: payloadEmail,
121
- },
65
+ const session = sessionsResult.data[0];
66
+
67
+ if (session) {
68
+ const usersResult = await ctx.workflows.collectionStore({
69
+ method: "findAll",
70
+ props: {
71
+ collectionName: "auth_users",
72
+ params: { filter: { id: session.user_id } },
122
73
  },
123
- },
124
- });
125
-
126
- if (!users.data.length) {
127
- throw new ctx.LobbError({
128
- code: "NOT_FOUND",
129
- message: `The user with this email (${payloadEmail}) doesnt exist.`,
130
- });
131
- }
132
-
133
- const user = users.data[0] as User;
134
- const hashedPassword = user.password;
135
-
136
- const passwordIsCorrect = await verify(
137
- hashedPassword,
138
- payloadPassword,
139
- );
140
- if (!passwordIsCorrect) {
141
- throw new ctx.LobbError({
142
- code: "UNAUTHORIZED",
143
- message:
144
- "The password provided is incorrect. Please verify and try again.",
145
74
  });
146
- }
147
-
148
- const sessionPayload = {
149
- token: generateRandomId(),
150
- expires_at: new Date(Date.now() + 3600 * 1000),
151
- user_id: user.id,
152
- };
153
-
154
- const sessionResult = await ctx.workflows.collectionService({
155
- method: "createOne",
156
- props: {
157
- collectionName: "auth_sessions",
158
- data: sessionPayload,
159
- },
160
- });
161
-
162
- throw new Response(
163
- JSON.stringify({
164
- data: {
165
- access_token: {
166
- token: sessionPayload.token,
167
- expires_at: sessionResult.data.expires_at,
168
- },
169
- user: {
170
- id: user.id,
171
- email: user.email,
172
- role: user.role,
173
- },
75
+ const user = usersResult.data[0];
76
+
77
+ context.set("auth_session", session);
78
+ context.set("auth_user", user);
79
+ } else {
80
+ // 2) Otherwise try the token as a share. Shares carry their own
81
+ // permissions snapshot and have a fixed expiry — no user identity.
82
+ const sharesResult = await ctx.workflows.collectionStore({
83
+ method: "findAll",
84
+ props: {
85
+ collectionName: "auth_shares",
86
+ params: { filter: { token } },
174
87
  },
175
- }),
176
- );
177
- }
178
- },
179
- },
180
- {
181
- name: "auth_userLogoutHandler",
182
- eventName: "core.controllers.preDeleteMany",
183
- handler: async (input, ctx) => {
184
- if (input.collectionName === "auth_sessions") {
185
- const context = input.context as Context;
186
- const token = getBearerToken(context);
187
-
188
- if (!token) {
189
- throw new ctx.LobbError({
190
- code: "BAD_REQUEST",
191
- message:
192
- "You should pass session's token through the bearer header",
193
88
  });
89
+ const share = sharesResult.data[0];
90
+
91
+ if (share && new Date(share.expires_at) > new Date()) {
92
+ let permissions: any = {};
93
+ try {
94
+ permissions = JSON.parse(share.permissions ?? "{}");
95
+ } catch {
96
+ // malformed permissions — treat as no access
97
+ permissions = {};
98
+ }
99
+ context.set("auth_share", { ...share, permissions });
100
+ }
194
101
  }
195
-
196
- await ctx.workflows.collectionService({
197
- method: "deleteMany",
198
- props: {
199
- collectionName: "auth_sessions",
200
- filter: {
201
- token: token,
202
- },
203
- },
204
- });
205
-
206
- throw new Response(
207
- null,
208
- {
209
- status: 204,
210
- },
211
- );
212
102
  }
213
103
  },
214
104
  },
@@ -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
  }
@@ -5,6 +5,7 @@ import { meAliasWorkflows } from "./meAliasWorkflows.ts";
5
5
  import { getCurrentUserPermissionsWorkflow } from "./currentUserPermissionsWorkflow.ts";
6
6
  import { getBaseWorkflows } from "./baseWorkflow.ts";
7
7
  import { getSharesWorkflows } from "./sharesWorkflows.ts";
8
+ import { getAuthActionControllerWorkflows } from "./actionController.ts";
8
9
  import { init } from "../database/init.ts";
9
10
  export function getWorkflows(extensionConfig: ExtensionConfig): Workflow[] {
10
11
  return [
@@ -17,6 +18,7 @@ export function getWorkflows(extensionConfig: ExtensionConfig): Workflow[] {
17
18
  },
18
19
  ...getBaseWorkflows(extensionConfig),
19
20
  ...getSharesWorkflows(extensionConfig),
21
+ ...getAuthActionControllerWorkflows(),
20
22
 
21
23
  // TODO: think about putting the below workflows above at the beggining
22
24
  // this will give us the ability to specify which roles have the ability to login, register etc
@@ -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,44 +22,42 @@ 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
29
- // single `expires_at` column the rest of the system uses. Runs at the
30
- // service layer, which fires before the store-level required-field
31
- // check — so `expires_at` can stay schema-required while still letting
32
- // callers send only `expires_in_seconds`. Keeps an explicit "neither
33
- // provided" throw for a friendlier error message than the generic
34
- // required-field error.
28
+ // single `expires_at` column the rest of the system uses. `order: "pre"`
29
+ // makes it run before the unordered subscribers of preCreateOne — crucially
30
+ // before the required-field check — so `expires_at` can stay schema-required
31
+ // while still letting callers send only `expires_in_seconds`. Keeps an
32
+ // explicit "neither provided" throw for a friendlier error message than the
33
+ // generic required-field error.
35
34
  {
36
35
  name: "auth_normalizeShareExpiry",
37
- eventName: "core.service.preCreateOne",
36
+ eventName: "core.store.preCreateOne",
37
+ order: "pre",
38
38
  handler: async (input, ctx) => {
39
- 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
+ }
40
52
 
41
- const seconds = input.data.expires_in_seconds;
42
- if (typeof seconds === "number") {
43
- if (!Number.isFinite(seconds) || seconds <= 0) {
53
+ if (!input.data.expires_at) {
44
54
  throw new ctx.LobbError({
45
55
  code: "BAD_REQUEST",
46
- 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.",
47
58
  });
48
59
  }
49
- input.data.expires_at = new Date(Date.now() + seconds * 1000)
50
- .toISOString();
51
- delete input.data.expires_in_seconds;
52
- }
53
-
54
- if (!input.data.expires_at) {
55
- throw new ctx.LobbError({
56
- code: "BAD_REQUEST",
57
- message:
58
- "Must provide either expires_at or expires_in_seconds when creating a share.",
59
- });
60
60
  }
61
-
62
- return input;
63
61
  },
64
62
  },
65
63
  // Auto-set created_by from the authenticated request user so callers
@@ -70,52 +68,54 @@ export function getSharesWorkflows(extensionConfig: ExtensionConfig): Workflow[]
70
68
  name: "auth_setShareCreatedBy",
71
69
  eventName: "core.store.preCreateOne",
72
70
  handler: async (input) => {
73
- if (input.collectionName !== "auth_shares") return input;
74
- if (input.triggeredBy !== "API") return input;
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
- 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
+ }
79
79
  },
80
80
  },
81
81
  // Intersection guard: a share's embedded permissions snapshot must be a
82
82
  // subset of the creator's own permissions. Without this, any user who can
83
83
  // create rows in auth_shares could mint a token that grants admin-level
84
84
  // access. Only runs for API-triggered creates — internal callers (server
85
- // code, tests using collectionService directly) are trusted.
85
+ // code, tests using collectionStore directly) are trusted.
86
86
  {
87
87
  name: "auth_validateShareSubset",
88
88
  eventName: "core.store.preCreateOne",
89
89
  handler: async (input, ctx) => {
90
- if (input.collectionName !== "auth_shares") return input;
91
- 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
+ );
92
100
 
93
- const context = input.context as Context;
94
- const { permissions: creatorPermissions } = resolveRequestPermissions(
95
- context,
96
- extensionConfig,
97
- ctx,
98
- );
99
-
100
- let sharePermissions: PermissionsConfig;
101
- try {
102
- sharePermissions = JSON.parse(input.data.permissions);
103
- } catch {
104
- throw new ctx.LobbError({
105
- code: "BAD_REQUEST",
106
- message: "Share permissions must be valid JSON.",
107
- });
108
- }
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
+ }
109
110
 
110
- if (!isShareSubsetOfCreator(sharePermissions, creatorPermissions)) {
111
- throw new ctx.LobbError({
112
- code: "FORBIDDEN",
113
- message:
114
- "Cannot create a share with broader permissions than your own.",
115
- });
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
+ }
116
118
  }
117
-
118
- return input;
119
119
  },
120
120
  },
121
121
  // Hourly housekeeping: delete shares whose expires_at is in the past.
@@ -125,7 +125,7 @@ export function getSharesWorkflows(extensionConfig: ExtensionConfig): Workflow[]
125
125
  name: "auth_cleanupExpiredShares",
126
126
  eventName: "0 3 * * *",
127
127
  handler: async (_input, ctx) => {
128
- await ctx.lobb.collectionService.deleteMany({
128
+ await ctx.lobb.collectionStore.deleteMany({
129
129
  collectionName: "auth_shares",
130
130
  filter: { expires_at: { $lt: new Date().toISOString() } },
131
131
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobb-js/lobb-ext-auth",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
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.40.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.47.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",