@lobb-js/lobb-ext-auth 0.11.0 → 0.11.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.
Files changed (49) hide show
  1. package/README.md +1 -0
  2. package/dist/auth.d.ts +2 -1
  3. package/dist/auth.js +23 -4
  4. package/dist/index.js +41 -2
  5. package/dist/lib/components/pages/settings/index.svelte +1 -1
  6. package/dist/lib/components/pages/settings/pages/activityFeed.svelte +1 -1
  7. package/dist/lib/components/pages/settings/pages/rolesAndPermissions.svelte +1 -1
  8. package/dist/lib/components/pages/settings/pages/users.svelte +1 -1
  9. package/dist/lib/components/pages/userSettings/index.svelte +45 -32
  10. package/dist/onStartup.js +17 -2
  11. package/dist/shared/permissions.d.ts +2 -0
  12. package/dist/shared/permissions.js +35 -0
  13. package/extensions/auth/collections/collections.ts +2 -0
  14. package/extensions/auth/collections/shares.ts +60 -0
  15. package/extensions/auth/config/extensionConfigSchema.d.ts +41 -0
  16. package/extensions/auth/config/permissionsAction/create.d.ts +18 -0
  17. package/extensions/auth/config/permissionsAction/delete.d.ts +3 -0
  18. package/extensions/auth/config/permissionsAction/read.d.ts +11 -0
  19. package/extensions/auth/config/permissionsAction/update.d.ts +18 -0
  20. package/extensions/auth/index.ts +0 -2
  21. package/extensions/auth/studio/auth.ts +25 -5
  22. package/extensions/auth/studio/index.ts +44 -2
  23. package/extensions/auth/studio/lib/components/pages/settings/index.svelte +1 -1
  24. package/extensions/auth/studio/lib/components/pages/settings/pages/activityFeed.svelte +1 -1
  25. package/extensions/auth/studio/lib/components/pages/settings/pages/rolesAndPermissions.svelte +1 -1
  26. package/extensions/auth/studio/lib/components/pages/settings/pages/users.svelte +1 -1
  27. package/extensions/auth/studio/lib/components/pages/userSettings/index.svelte +45 -32
  28. package/extensions/auth/studio/onStartup.ts +14 -2
  29. package/extensions/auth/studio/shared/permissions.ts +42 -0
  30. package/extensions/auth/tests/collections/shares.test.ts +657 -0
  31. package/extensions/auth/tests/configs/auth.ts +17 -0
  32. package/extensions/auth/tests/controllers/me.test.ts +104 -0
  33. package/extensions/auth/tests/permissions.test.ts +127 -0
  34. package/extensions/auth/tests/workflows/shareIntersection.test.ts +158 -0
  35. package/extensions/auth/workflows/baseWorkflow.ts +48 -26
  36. package/extensions/auth/workflows/currentUserPermissionsWorkflow.ts +32 -0
  37. package/extensions/auth/workflows/index.ts +12 -0
  38. package/extensions/auth/workflows/meAliasWorkflows.ts +26 -0
  39. package/extensions/auth/workflows/policiesWorkflows.ts +64 -117
  40. package/extensions/auth/workflows/shareIntersection.ts +64 -0
  41. package/extensions/auth/workflows/sharesWorkflows.ts +135 -0
  42. package/extensions/auth/workflows/utils.ts +132 -224
  43. package/package.json +3 -3
  44. package/dist/lib/components/pages/userSettings/components/account.svelte +0 -106
  45. package/dist/lib/components/pages/userSettings/components/account.svelte.d.ts +0 -14
  46. package/dist/lib/components/pages/userSettings/components/profile.svelte +0 -87
  47. package/dist/lib/components/pages/userSettings/components/profile.svelte.d.ts +0 -14
  48. package/extensions/auth/studio/lib/components/pages/userSettings/components/account.svelte +0 -106
  49. package/extensions/auth/studio/lib/components/pages/userSettings/components/profile.svelte +0 -87
@@ -1,7 +1,11 @@
1
1
  import type { Lobb, Workflow } from "@lobb-js/core";
2
2
  import type { Context } from "hono";
3
- import type { ExtensionConfig, User } from "../config/extensionConfigSchema.ts";
4
- import { handlePolicy, handleReadFields } from "./utils.ts";
3
+ import type { ExtensionConfig } from "../config/extensionConfigSchema.ts";
4
+ import {
5
+ handlePolicy,
6
+ handleReadFields,
7
+ resolveRequestPermissions,
8
+ } from "./utils.ts";
5
9
 
6
10
  export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow[] {
7
11
  return [
@@ -12,22 +16,22 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
12
16
  if (input.triggeredBy === "API") {
13
17
  const context = input.context as Context;
14
18
  const lobb = context.get("lobb") as Lobb;
15
- const user = context.get("auth_user") as User | undefined;
19
+ const { permissions, user } = resolveRequestPermissions(
20
+ context,
21
+ extensionConfig,
22
+ ctx,
23
+ );
16
24
 
17
25
  const output = handlePolicy({
18
- action: "create",
19
26
  collectionName: input.collectionName,
20
- role: user?.role,
21
- user,
22
- input,
27
+ action: "create",
28
+ permissions,
23
29
  lobb,
24
30
  ctx,
25
- extensionConfig,
31
+ user,
32
+ payload: input.data,
26
33
  });
27
-
28
- if (output && output.payload) {
29
- input.data = output.payload;
30
- }
34
+ if (output?.payload) input.data = output.payload;
31
35
  }
32
36
  return input;
33
37
  },
@@ -39,55 +43,22 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
39
43
  if (input.triggeredBy === "API") {
40
44
  const context = input.context as Context;
41
45
  const lobb = context.get("lobb") as Lobb;
42
- const user = context.get("auth_user") as User | undefined;
43
-
44
- const output = handlePolicy({
45
- action: "read",
46
- collectionName: input.collectionName,
47
- role: user?.role,
48
- user,
49
- input,
50
- lobb,
51
- ctx,
46
+ const { permissions, user } = resolveRequestPermissions(
47
+ context,
52
48
  extensionConfig,
53
- });
54
-
55
- if (output && output.filter) {
56
- input.filter = output.filter;
57
- }
58
- }
59
- return input;
60
- },
61
- },
62
- {
63
- name: "auth_policyPreFindOne",
64
- eventName: "core.store.preFindOne",
65
- handler: async (input, ctx) => {
66
- if (input.triggeredBy === "API") {
67
- const context = input.context as Context;
68
- const lobb = context.get("lobb") as Lobb;
69
- const user = context.get("auth_user") as User | undefined;
70
-
71
- // pass if the user is effecting himself
72
- const currentUser = input.id === user?.id;
73
- if (input.collectionName === "auth_users" && currentUser) {
74
- return input;
75
- }
49
+ ctx,
50
+ );
76
51
 
77
52
  const output = handlePolicy({
78
- action: "read",
79
53
  collectionName: input.collectionName,
80
- role: user?.role,
81
- user,
82
- input,
54
+ action: "read",
55
+ permissions,
83
56
  lobb,
84
57
  ctx,
85
- extensionConfig,
58
+ user,
59
+ filter: input.params?.filter,
86
60
  });
87
-
88
- if (output && output.filter) {
89
- input.filter = output.filter;
90
- }
61
+ if (output?.filter) input.filter = output.filter;
91
62
  }
92
63
  return input;
93
64
  },
@@ -99,24 +70,23 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
99
70
  if (input.triggeredBy === "API") {
100
71
  const context = input.context as Context;
101
72
  const lobb = context.get("lobb") as Lobb;
102
- const user = context.get("auth_user") as User | undefined;
103
-
104
- // pass if the user is effecting himself
105
- const currentUser = input.id === user?.id;
106
- if (input.collectionName === "auth_users" && currentUser) {
107
- return input;
108
- }
73
+ const { permissions, user } = resolveRequestPermissions(
74
+ context,
75
+ extensionConfig,
76
+ ctx,
77
+ );
109
78
 
110
- handlePolicy({
111
- action: "update",
79
+ const output = handlePolicy({
112
80
  collectionName: input.collectionName,
113
- role: user?.role,
114
- user,
115
- input,
81
+ action: "update",
82
+ permissions,
116
83
  lobb,
117
84
  ctx,
118
- extensionConfig,
85
+ user,
86
+ payload: input.data,
87
+ recordId: input.id,
119
88
  });
89
+ if (output?.payload) input.data = output.payload;
120
90
  }
121
91
  return input;
122
92
  },
@@ -128,42 +98,39 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
128
98
  if (input.triggeredBy === "API") {
129
99
  const context = input.context as Context;
130
100
  const lobb = context.get("lobb") as Lobb;
131
- const user = context.get("auth_user") as User | undefined;
132
-
133
- // pass if the user is effecting himself
134
- const currentUser = input.id === user?.id;
135
- if (input.collectionName === "auth_users" && currentUser) {
136
- return input;
137
- }
101
+ const { permissions, user } = resolveRequestPermissions(
102
+ context,
103
+ extensionConfig,
104
+ ctx,
105
+ );
138
106
 
139
107
  handlePolicy({
140
- action: "delete",
141
108
  collectionName: input.collectionName,
142
- role: user?.role,
143
- user,
144
- input,
109
+ action: "delete",
110
+ permissions,
145
111
  lobb,
146
112
  ctx,
147
- extensionConfig,
113
+ user,
114
+ recordId: input.id,
148
115
  });
149
116
  }
150
117
  return input;
151
118
  },
152
119
  },
153
- // handling read fields property
120
+ // Post-fetch field-level filtering using the role/share's `read.fields`
121
+ // allowlist (static — works the same for both sources).
154
122
  {
155
123
  name: "auth_policyPostQuery",
156
124
  eventName: "core.store.findAll",
157
125
  handler: async (input, ctx) => {
158
126
  if (input.triggeredBy === "API") {
159
127
  const context = input.context as Context;
160
- const user = context.get("auth_user") as User | undefined;
161
- input.data = handleReadFields(
162
- user?.role,
163
- input.collectionName,
164
- input.data,
128
+ const { permissions } = resolveRequestPermissions(
129
+ context,
165
130
  extensionConfig,
131
+ ctx,
166
132
  );
133
+ input.data = handleReadFields(permissions, input.collectionName, input.data);
167
134
  }
168
135
  return input;
169
136
  },
@@ -174,13 +141,12 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
174
141
  handler: async (input, ctx) => {
175
142
  if (input.triggeredBy === "API") {
176
143
  const context = input.context as Context;
177
- const user = context.get("auth_user") as User | undefined;
178
- input.data = handleReadFields(
179
- user?.role,
180
- input.collectionName,
181
- input.data,
144
+ const { permissions } = resolveRequestPermissions(
145
+ context,
182
146
  extensionConfig,
147
+ ctx,
183
148
  );
149
+ input.data = handleReadFields(permissions, input.collectionName, input.data);
184
150
  }
185
151
  return input;
186
152
  },
@@ -191,13 +157,12 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
191
157
  handler: async (input, ctx) => {
192
158
  if (input.triggeredBy === "API") {
193
159
  const context = input.context as Context;
194
- const user = context.get("auth_user") as User | undefined;
195
- input.data = handleReadFields(
196
- user?.role,
197
- input.collectionName,
198
- input.data,
160
+ const { permissions } = resolveRequestPermissions(
161
+ context,
199
162
  extensionConfig,
163
+ ctx,
200
164
  );
165
+ input.data = handleReadFields(permissions, input.collectionName, input.data);
201
166
  }
202
167
  return input;
203
168
  },
@@ -208,30 +173,12 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
208
173
  handler: async (input, ctx) => {
209
174
  if (input.triggeredBy === "API") {
210
175
  const context = input.context as Context;
211
- const user = context.get("auth_user") as User | undefined;
212
- input.data = handleReadFields(
213
- user?.role,
214
- input.collectionName,
215
- input.data,
216
- extensionConfig,
217
- );
218
- }
219
- return input;
220
- },
221
- },
222
- {
223
- name: "auth_policyPostReadForDelete",
224
- eventName: "core.store.updateOne",
225
- handler: async (input, ctx) => {
226
- if (input.triggeredBy === "API") {
227
- const context = input.context as Context;
228
- const user = context.get("auth_user") as User | undefined;
229
- input.data = handleReadFields(
230
- user?.role,
231
- input.collectionName,
232
- input.data,
176
+ const { permissions } = resolveRequestPermissions(
177
+ context,
233
178
  extensionConfig,
179
+ ctx,
234
180
  );
181
+ input.data = handleReadFields(permissions, input.collectionName, input.data);
235
182
  }
236
183
  return input;
237
184
  },
@@ -0,0 +1,64 @@
1
+ import type {
2
+ CollectionPermissionsConfig,
3
+ PermissionsConfig,
4
+ } from "../config/extensionConfigSchema.ts";
5
+
6
+ // Returns true if `share` permissions are fully covered by `creator`
7
+ // permissions — i.e. a user with `creator` is allowed to mint a share that
8
+ // grants `share`. Used to enforce that share creators can never grant more
9
+ // than they have themselves.
10
+ //
11
+ // Filter comparison is intentionally not structural: when both sides are
12
+ // conditional reads with their own filter, the share filter is accepted
13
+ // as-is. Statically deciding whether one filter is a subset of another is
14
+ // intractable in the general case; the documented behaviour is that the
15
+ // share's filter applies as written to share-token requests.
16
+ export function isShareSubsetOfCreator(
17
+ share: PermissionsConfig | undefined,
18
+ creator: PermissionsConfig | undefined,
19
+ ): boolean {
20
+ if (creator === true) return true;
21
+ if (share === true) return false;
22
+ if (!share) return true;
23
+
24
+ for (const [collection, sharePerm] of Object.entries(share)) {
25
+ if (sharePerm === undefined) continue;
26
+ if (!isCollectionSubset(sharePerm, creator?.[collection])) return false;
27
+ }
28
+ return true;
29
+ }
30
+
31
+ function isCollectionSubset(
32
+ share: CollectionPermissionsConfig,
33
+ creator: CollectionPermissionsConfig | undefined,
34
+ ): boolean {
35
+ if (creator === true) return true;
36
+ if (!creator) return false;
37
+ if (share === true) return false;
38
+
39
+ for (const [action, sharePerm] of Object.entries(share)) {
40
+ if (sharePerm === undefined) continue;
41
+ const creatorPerm = (creator as Record<string, unknown>)[action];
42
+ if (!isActionSubset(sharePerm, creatorPerm)) return false;
43
+ }
44
+ return true;
45
+ }
46
+
47
+ function isActionSubset(share: unknown, creator: unknown): boolean {
48
+ if (creator === true) return true;
49
+ if (creator === undefined || creator === null) return false;
50
+ if (share === true) return false;
51
+
52
+ // Both conditional — verify the share's fields allowlist is a subset of
53
+ // the creator's (if the creator has one).
54
+ const creatorObj = creator as { fields?: Record<string, true> };
55
+ const shareObj = share as { fields?: Record<string, true> };
56
+ if (creatorObj.fields) {
57
+ if (!shareObj.fields) return false;
58
+ const creatorFields = Object.keys(creatorObj.fields);
59
+ for (const f of Object.keys(shareObj.fields)) {
60
+ if (!creatorFields.includes(f)) return false;
61
+ }
62
+ }
63
+ return true;
64
+ }
@@ -0,0 +1,135 @@
1
+ import type { Workflow } from "@lobb-js/core";
2
+ import type { Context } from "hono";
3
+ import type {
4
+ ExtensionConfig,
5
+ PermissionsConfig,
6
+ User,
7
+ } from "../config/extensionConfigSchema.ts";
8
+ import { generateRandomId } from "../utils.ts";
9
+ import { resolveRequestPermissions } from "./utils.ts";
10
+ import { isShareSubsetOfCreator } from "./shareIntersection.ts";
11
+
12
+ export function getSharesWorkflows(extensionConfig: ExtensionConfig): Workflow[] {
13
+ return [
14
+ // Server-side generates the share's bearer token on create so callers
15
+ // can't pick a guessable value. The freshly generated token is returned
16
+ // in the createOne response (since we mutate input.data before the row
17
+ // is written).
18
+ {
19
+ name: "auth_generateShareToken",
20
+ eventName: "core.store.preCreateOne",
21
+ handler: async (input) => {
22
+ if (input.collectionName === "auth_shares") {
23
+ input.data.token = generateRandomId();
24
+ }
25
+ return input;
26
+ },
27
+ },
28
+ // 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.
35
+ {
36
+ name: "auth_normalizeShareExpiry",
37
+ eventName: "core.service.preCreateOne",
38
+ handler: async (input, ctx) => {
39
+ if (input.collectionName !== "auth_shares") return input;
40
+
41
+ const seconds = input.data.expires_in_seconds;
42
+ if (typeof seconds === "number") {
43
+ if (!Number.isFinite(seconds) || seconds <= 0) {
44
+ throw new ctx.LobbError({
45
+ code: "BAD_REQUEST",
46
+ message: "expires_in_seconds must be a positive number.",
47
+ });
48
+ }
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
+ }
61
+
62
+ return input;
63
+ },
64
+ },
65
+ // Auto-set created_by from the authenticated request user so callers
66
+ // can't forge the field. Internal callers (tests/server code using the
67
+ // service layer directly) are trusted and must supply created_by
68
+ // themselves.
69
+ {
70
+ name: "auth_setShareCreatedBy",
71
+ eventName: "core.store.preCreateOne",
72
+ 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;
79
+ },
80
+ },
81
+ // Intersection guard: a share's embedded permissions snapshot must be a
82
+ // subset of the creator's own permissions. Without this, any user who can
83
+ // create rows in auth_shares could mint a token that grants admin-level
84
+ // access. Only runs for API-triggered creates — internal callers (server
85
+ // code, tests using collectionService directly) are trusted.
86
+ {
87
+ name: "auth_validateShareSubset",
88
+ eventName: "core.store.preCreateOne",
89
+ handler: async (input, ctx) => {
90
+ if (input.collectionName !== "auth_shares") return input;
91
+ if (input.triggeredBy !== "API") return input;
92
+
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
+ }
109
+
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
+ });
116
+ }
117
+
118
+ return input;
119
+ },
120
+ },
121
+ // Hourly housekeeping: delete shares whose expires_at is in the past.
122
+ // Expired shares are already rejected at auth time, so this is just to
123
+ // keep the table from growing unbounded.
124
+ {
125
+ name: "auth_cleanupExpiredShares",
126
+ eventName: "0 3 * * *",
127
+ handler: async (_input, ctx) => {
128
+ await ctx.lobb.collectionService.deleteMany({
129
+ collectionName: "auth_shares",
130
+ filter: { expires_at: { $lt: new Date().toISOString() } },
131
+ });
132
+ },
133
+ },
134
+ ];
135
+ }