@digimuza/system-core 0.1.0 → 0.2.0-rc.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 (53) hide show
  1. package/INTEGRATION.md +75 -11
  2. package/README.md +8 -0
  3. package/dist/convex/functions.d.ts +28 -3
  4. package/dist/convex/functions.d.ts.map +1 -1
  5. package/dist/convex/functions.js +23 -4
  6. package/dist/convex/functions.js.map +1 -1
  7. package/dist/convex/index.d.ts +1 -1
  8. package/dist/convex/index.d.ts.map +1 -1
  9. package/dist/convex/index.js +1 -1
  10. package/dist/convex/index.js.map +1 -1
  11. package/dist/convex/schema.d.ts +12 -0
  12. package/dist/convex/schema.d.ts.map +1 -1
  13. package/dist/convex/schema.js +2 -0
  14. package/dist/convex/schema.js.map +1 -1
  15. package/dist/core/access.d.ts +13 -0
  16. package/dist/core/access.d.ts.map +1 -1
  17. package/dist/core/access.js +42 -7
  18. package/dist/core/access.js.map +1 -1
  19. package/dist/core/index.d.ts +1 -0
  20. package/dist/core/index.d.ts.map +1 -1
  21. package/dist/core/index.js +1 -0
  22. package/dist/core/index.js.map +1 -1
  23. package/dist/core/modules-service.d.ts +5 -0
  24. package/dist/core/modules-service.d.ts.map +1 -1
  25. package/dist/core/modules-service.js +5 -3
  26. package/dist/core/modules-service.js.map +1 -1
  27. package/dist/core/role-view.d.ts +19 -0
  28. package/dist/core/role-view.d.ts.map +1 -0
  29. package/dist/core/role-view.js +41 -0
  30. package/dist/core/role-view.js.map +1 -0
  31. package/dist/core/store.d.ts +8 -0
  32. package/dist/core/store.d.ts.map +1 -1
  33. package/dist/core/store.js +1 -0
  34. package/dist/core/store.js.map +1 -1
  35. package/dist/finance/index.d.ts +118 -0
  36. package/dist/finance/index.d.ts.map +1 -0
  37. package/dist/finance/index.js +144 -0
  38. package/dist/finance/index.js.map +1 -0
  39. package/dist/react/index.d.ts +7 -4
  40. package/dist/react/index.d.ts.map +1 -1
  41. package/dist/react/index.js +10 -1
  42. package/dist/react/index.js.map +1 -1
  43. package/package.json +7 -7
  44. package/src/convex/functions.ts +28 -5
  45. package/src/convex/index.ts +1 -1
  46. package/src/convex/schema.ts +3 -0
  47. package/src/core/access.ts +41 -8
  48. package/src/core/index.ts +1 -0
  49. package/src/core/modules-service.ts +9 -3
  50. package/src/core/role-view.ts +43 -0
  51. package/src/core/store.ts +5 -0
  52. package/src/finance/index.ts +189 -0
  53. package/src/react/index.tsx +12 -2
@@ -11,11 +11,8 @@ export type SessionState = ({
11
11
  } & SessionSummary & {
12
12
  actor: NonNullable<SessionSummary["actor"]>;
13
13
  });
14
- type EmptySession = {
14
+ type EmptySession = Omit<SessionSummary, "actor"> & {
15
15
  actor: null;
16
- modules: ModuleState[];
17
- permissions: string[];
18
- flags: Record<string, boolean>;
19
16
  };
20
17
  export interface SystemProviderProps {
21
18
  /** Your exported `session` query, e.g. `api.system.session`. */
@@ -39,6 +36,12 @@ export declare function useCurrentUser(): {
39
36
  export declare function useVisibleModules(): ModuleState[];
40
37
  /** Whether the current actor holds `permission` right now (UI hint). */
41
38
  export declare function useCan(permission: string): boolean;
39
+ /**
40
+ * "View as role" state: `canViewAs` for a real Super, `roleView` while a preview
41
+ * is active (then the rest of the session is that role's). Start and stop with
42
+ * the `startRoleView` / `stopRoleView` mutations.
43
+ */
44
+ export declare function useRoleView(): Pick<SessionSummary, "canViewAs" | "roleView">;
42
45
  export declare function useFeatureFlag(flagId: string): boolean;
43
46
  export declare function Can(props: {
44
47
  permission: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AACtD,OAAO,EAAsC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAC1E,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAC7E,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAEnE,MAAM,MAAM,YAAY,GACpB,CAAC;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GAAG,YAAY,CAAC,GACtC,CAAC;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,GAAG,YAAY,CAAC,GACxC,CAAC;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,GAAG,cAAc,GAAG;IAAE,KAAK,EAAE,WAAW,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAAA;CAAE,CAAC,CAAA;AAE5F,KAAK,YAAY,GAAG;IAAE,KAAK,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,WAAW,EAAE,CAAC;IAAC,WAAW,EAAE,MAAM,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAA;AAOlH,MAAM,WAAW,mBAAmB;IAClC,gEAAgE;IAChE,OAAO,EAAE,iBAAiB,CAAC,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,cAAc,CAAC,CAAA;IAC/G,QAAQ,CAAC,EAAE,SAAS,CAAA;CACrB;AAED,2FAA2F;AAC3F,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,mBAAmB,+BAQxE;AAED,wBAAgB,gBAAgB,IAAI,YAAY,CAI/C;AAED,gEAAgE;AAChE,wBAAgB,cAAc;;;;;;SAG7B;AAED,6CAA6C;AAC7C,wBAAgB,iBAAiB,IAAI,WAAW,EAAE,CAEjD;AAED,wEAAwE;AACxE,wBAAgB,MAAM,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAElD;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED,wBAAgB,GAAG,CAAC,KAAK,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,EAAE,SAAS,CAAA;CAAE,+BAE5F;AAED,iFAAiF;AACjF,wBAAgB,aAAa,CAAC,CAAC,EAAE,OAAO,GAAG,WAAW,GAAG,IAAI,CAE5D;AAED,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AACtD,OAAO,EAAsC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAC1E,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAC7E,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAEnE,MAAM,MAAM,YAAY,GACpB,CAAC;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GAAG,YAAY,CAAC,GACtC,CAAC;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,GAAG,YAAY,CAAC,GACxC,CAAC;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,GAAG,cAAc,GAAG;IAAE,KAAK,EAAE,WAAW,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAAA;CAAE,CAAC,CAAA;AAE5F,KAAK,YAAY,GAAG,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC,GAAG;IAAE,KAAK,EAAE,IAAI,CAAA;CAAE,CAAA;AAOnE,MAAM,WAAW,mBAAmB;IAClC,gEAAgE;IAChE,OAAO,EAAE,iBAAiB,CAAC,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,cAAc,CAAC,CAAA;IAC/G,QAAQ,CAAC,EAAE,SAAS,CAAA;CACrB;AAED,2FAA2F;AAC3F,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,mBAAmB,+BAQxE;AAED,wBAAgB,gBAAgB,IAAI,YAAY,CAI/C;AAED,gEAAgE;AAChE,wBAAgB,cAAc;;;;;;SAG7B;AAED,6CAA6C;AAC7C,wBAAgB,iBAAiB,IAAI,WAAW,EAAE,CAEjD;AAED,wEAAwE;AACxE,wBAAgB,MAAM,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAElD;AAED;;;;GAIG;AACH,wBAAgB,WAAW,IAAI,IAAI,CAAC,cAAc,EAAE,WAAW,GAAG,UAAU,CAAC,CAG5E;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED,wBAAgB,GAAG,CAAC,KAAK,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,EAAE,SAAS,CAAA;CAAE,+BAE5F;AAED,iFAAiF;AACjF,wBAAgB,aAAa,CAAC,CAAC,EAAE,OAAO,GAAG,WAAW,GAAG,IAAI,CAE5D;AAED,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,CAAA"}
@@ -6,7 +6,7 @@ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
6
6
  import { useQuery } from "convex/react";
7
7
  import { createContext, useContext, useMemo } from "react";
8
8
  import { toSystemError } from "../core/errors.js";
9
- const EMPTY = { actor: null, modules: [], permissions: [], flags: {} };
9
+ const EMPTY = { actor: null, modules: [], permissions: [], flags: {}, canViewAs: false, roleView: null };
10
10
  const LOADING = { status: "loading", ...EMPTY };
11
11
  const SessionContext = createContext(null);
12
12
  /** Subscribes to the session summary; updates live when roles, modules or flags change. */
@@ -40,6 +40,15 @@ export function useVisibleModules() {
40
40
  export function useCan(permission) {
41
41
  return useSystemSession().permissions.includes(permission);
42
42
  }
43
+ /**
44
+ * "View as role" state: `canViewAs` for a real Super, `roleView` while a preview
45
+ * is active (then the rest of the session is that role's). Start and stop with
46
+ * the `startRoleView` / `stopRoleView` mutations.
47
+ */
48
+ export function useRoleView() {
49
+ const { canViewAs, roleView } = useSystemSession();
50
+ return { canViewAs, roleView };
51
+ }
43
52
  export function useFeatureFlag(flagId) {
44
53
  return useSystemSession().flags[flagId] === true;
45
54
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/react/index.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AACZ,yEAAyE;AACzE,2EAA2E;AAC3E,gCAAgC;AAChC,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAA;AAEvC,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,OAAO,EAAkB,MAAM,OAAO,CAAA;AAE1E,OAAO,EAAE,aAAa,EAAoB,MAAM,mBAAmB,CAAA;AASnE,MAAM,KAAK,GAAiB,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,WAAW,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,CAAA;AACpF,MAAM,OAAO,GAAiB,EAAE,MAAM,EAAE,SAAS,EAAE,GAAG,KAAK,EAAE,CAAA;AAE7D,MAAM,cAAc,GAAG,aAAa,CAAsB,IAAI,CAAC,CAAA;AAQ/D,2FAA2F;AAC3F,MAAM,UAAU,cAAc,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAuB;IACvE,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,EAAE,EAAE,CAAC,CAAA;IAClC,MAAM,KAAK,GAAG,OAAO,CAAe,GAAG,EAAE;QACvC,IAAI,IAAI,KAAK,SAAS;YAAE,OAAO,OAAO,CAAA;QACtC,IAAI,CAAC,IAAI,CAAC,KAAK;YAAE,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,KAAK,EAAE,CAAA;QACzD,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAA;IACxD,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAA;IACV,OAAO,KAAC,cAAc,CAAC,QAAQ,IAAC,KAAK,EAAE,KAAK,YAAG,QAAQ,GAA2B,CAAA;AACpF,CAAC;AAED,MAAM,UAAU,gBAAgB;IAC9B,MAAM,GAAG,GAAG,UAAU,CAAC,cAAc,CAAC,CAAA;IACtC,IAAI,CAAC,GAAG;QAAE,MAAM,IAAI,KAAK,CAAC,uDAAuD,CAAC,CAAA;IAClF,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,gEAAgE;AAChE,MAAM,UAAU,cAAc;IAC5B,MAAM,EAAE,KAAK,EAAE,GAAG,gBAAgB,EAAE,CAAA;IACpC,OAAO,KAAK,EAAE,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAA;AAC9C,CAAC;AAED,6CAA6C;AAC7C,MAAM,UAAU,iBAAiB;IAC/B,OAAO,gBAAgB,EAAE,CAAC,OAAO,CAAA;AACnC,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,MAAM,CAAC,UAAkB;IACvC,OAAO,gBAAgB,EAAE,CAAC,WAAW,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAA;AAC5D,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,MAAc;IAC3C,OAAO,gBAAgB,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,IAAI,CAAA;AAClD,CAAC;AAED,MAAM,UAAU,GAAG,CAAC,KAAyE;IAC3F,OAAO,4BAAG,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,IAAI,IAAI,CAAC,GAAI,CAAA;AACpF,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,aAAa,CAAC,CAAU;IACtC,OAAO,aAAa,CAAC,CAAC,CAAC,CAAA;AACzB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/react/index.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digimuza/system-core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-rc.2",
4
4
  "description": "Shared system core for per-tenant Next.js + Convex applications: config, identity, roles, permissions, invites, API keys, feature flags, logging, tracing and audit.",
5
5
  "keywords": [
6
6
  "nextjs",
@@ -28,8 +28,7 @@
28
28
  "dist",
29
29
  "src",
30
30
  "README.md",
31
- "INTEGRATION.md",
32
- "CHANGELOG.md"
31
+ "INTEGRATION.md"
33
32
  ],
34
33
  "exports": {
35
34
  ".": {
@@ -57,6 +56,10 @@
57
56
  "types": "./dist/react/index.d.ts",
58
57
  "default": "./dist/react/index.js"
59
58
  },
59
+ "./finance": {
60
+ "types": "./dist/finance/index.d.ts",
61
+ "default": "./dist/finance/index.js"
62
+ },
60
63
  "./testing": {
61
64
  "types": "./dist/testing/index.d.ts",
62
65
  "default": "./dist/testing/index.js"
@@ -70,9 +73,7 @@
70
73
  "check:pack": "node scripts/check-pack.mjs",
71
74
  "prepublishOnly": "npm run typecheck && npm run lint && npm test && npm run build && npm run check:pack",
72
75
  "lint": "biome lint",
73
- "changeset": "changeset",
74
- "version-packages": "changeset version",
75
- "release": "changeset publish"
76
+ "release:cut": "node scripts/release-cut.mjs"
76
77
  },
77
78
  "engines": {
78
79
  "node": ">=20"
@@ -93,7 +94,6 @@
93
94
  },
94
95
  "devDependencies": {
95
96
  "@biomejs/biome": "^2.6.6",
96
- "@changesets/cli": "^3.0.3",
97
97
  "@edge-runtime/vm": "^5.0.0",
98
98
  "@types/node": "^26.6.6",
99
99
  "@types/react": "^19.3.0",
@@ -28,13 +28,17 @@ import * as flags from "../core/feature-flags.js"
28
28
  import * as invites from "../core/invites.js"
29
29
  import * as mods from "../core/modules-service.js"
30
30
  import { registerCatalog } from "../core/registry.js"
31
+ import * as roleView from "../core/role-view.js"
31
32
  import * as roles from "../core/roles.js"
32
33
  import * as users from "../core/users.js"
33
34
  import { createLogger } from "../diagnostics/logging.js"
34
35
  import { createConvexStore } from "./store.js"
35
36
 
36
- type Db = GenericDatabaseReader<GenericDataModel> | GenericDatabaseWriter<GenericDataModel>
37
- /** The subset of a Convex query/mutation ctx the system needs. */
37
+ // `any` data model on purpose: an app's generated QueryCtx/MutationCtx (with its
38
+ // own tables) must be assignable here without a cast.
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ type Db = GenericDatabaseReader<any> | GenericDatabaseWriter<any>
41
+ /** The subset of a Convex query/mutation ctx the system needs. Any app's ctx fits. */
38
42
  export interface DbCtx { db: Db; auth: Auth }
39
43
 
40
44
  export interface ConvexSystemOptions extends SystemOptions {
@@ -55,10 +59,16 @@ export interface ContextOptions { apiKey?: string | undefined; operationId?: str
55
59
 
56
60
  const OPERATION_ID = /^[A-Za-z0-9_-]{8,64}$/
57
61
 
58
- const defaultIdentity = (i: UserIdentity): VerifiedIdentity => {
62
+ /**
63
+ * Default mapping. Convex sets `emailVerified` only for OIDC providers; custom
64
+ * JWT providers (WorkOS, Better Auth, most OIDC setups through customJwt) pass
65
+ * the claim through raw as `email_verified`, so read both.
66
+ */
67
+ export const defaultIdentity = (i: UserIdentity): VerifiedIdentity => {
59
68
  const out: VerifiedIdentity = { provider: i.issuer, subject: i.subject }
60
- if (i.email) out.email = i.email
61
- if (i.emailVerified !== undefined) out.emailVerified = i.emailVerified
69
+ if (typeof i.email === "string" && i.email) out.email = i.email
70
+ const verified = i.emailVerified ?? (i as Record<string, unknown>).email_verified
71
+ if (typeof verified === "boolean") out.emailVerified = verified
62
72
  return out
63
73
  }
64
74
 
@@ -303,6 +313,19 @@ export function defineConvexSystem(options: ConvexSystemOptions = {}) {
303
313
  apiKeys.rotateApiKey(sys, actor, a.apiKeyId, { prefix: a.prefix, hash: a.hash }),
304
314
  ),
305
315
 
316
+ // --- Super tools: view as role -----------------------------------------------------------
317
+ /** `{ canViewAs, roleView }` for the caller. */
318
+ roleViewStatus: query({}, (sys, actor) => roleView.roleViewStatus(sys, actor)),
319
+ /** A real Super previews the system as `roleId`. Human sessions only; audited. */
320
+ startRoleView: mutationGeneric({
321
+ args: { roleId: v.string(), operationId: v.optional(v.string()) },
322
+ handler: (ctx, a) => run(ctx, { operationId: a.operationId }, (sys, actor) => roleView.startRoleView(sys, actor, a.roleId)),
323
+ }),
324
+ stopRoleView: mutationGeneric({
325
+ args: { operationId: v.optional(v.string()) },
326
+ handler: (ctx, a) => run(ctx, { operationId: a.operationId }, (sys, actor) => roleView.stopRoleView(sys, actor)),
327
+ }),
328
+
306
329
  // --- Feature flags & audit ------------------------------------------------------------
307
330
  listFeatureFlags: query({}, (sys, actor) => flags.listFlags(sys, actor)),
308
331
  setFeatureFlag: mutation({ flagId: v.string(), value: v.boolean() }, (sys, actor, a) => flags.setFlag(sys, actor, a.flagId, a.value)),
@@ -1,7 +1,7 @@
1
1
  // Convex integration: schema tables, store adapter, system functions and guards.
2
2
  export { systemTables } from "./schema.js"
3
3
  export { createConvexStore } from "./store.js"
4
- export { defineConvexSystem, toConvexError, type ConvexSystem, type ConvexSystemOptions, type ContextOptions, type DbCtx } from "./functions.js"
4
+ export { defaultIdentity, defineConvexSystem, toConvexError, type ConvexSystem, type ConvexSystemOptions, type ContextOptions, type DbCtx } from "./functions.js"
5
5
 
6
6
  // Runtime-agnostic services, for custom Convex functions built on `system.context(ctx)`.
7
7
  export * from "../core/index.js"
@@ -169,4 +169,7 @@ export const systemTables = {
169
169
  }).index("by_key", ["key"]),
170
170
 
171
171
  systemMeta: defineTable({ key: v.string(), catalogVersion: v.string(), registeredAt: v.number() }).index("by_key", ["key"]),
172
+
173
+ /** "View as role": one row per Super previewing a role (key = user ID). */
174
+ roleViews: defineTable({ key: v.string(), roleId: v.string(), startedAt: v.number() }).index("by_key", ["key"]),
172
175
  }
@@ -74,7 +74,34 @@ export function isSuperUser(sys: SystemContext, user: UserRow | null | undefined
74
74
  return !!user && user.status === "active" && user.emailVerified && sys.superEmails.has(normalizeEmail(user.email))
75
75
  }
76
76
 
77
+ /**
78
+ * The role a Super is previewing ("view as role"), or null. Only a real Super
79
+ * can have one; an archived or deleted role ends the preview.
80
+ */
81
+ export async function activeRoleView(sys: SystemContext, user: UserRow | null | undefined): Promise<string | null> {
82
+ if (!user || !isSuperUser(sys, user)) return null
83
+ const view = await sys.store.get("roleViews", user.key)
84
+ if (!view) return null
85
+ const role = await sys.store.get("roles", view.roleId)
86
+ return role && !role.archived ? view.roleId : null
87
+ }
88
+
89
+ /** Super with Super rights in effect: false while previewing a role. */
90
+ export async function isEffectiveSuperUser(sys: SystemContext, user: UserRow | null | undefined): Promise<boolean> {
91
+ return isSuperUser(sys, user) && !(await activeRoleView(sys, user))
92
+ }
93
+
94
+ /**
95
+ * Whether the actor acts as Super right now. A Super previewing a role does
96
+ * not: every check then treats them as a holder of that role.
97
+ */
77
98
  export async function isSuperActor(sys: SystemContext, actor: Actor | null): Promise<boolean> {
99
+ if (!actor || actor.kind !== "user") return false
100
+ return isEffectiveSuperUser(sys, await sys.store.get("users", actor.userId))
101
+ }
102
+
103
+ /** Super by account and deployment list, ignoring a role preview (to start or stop one). */
104
+ export async function isRealSuperActor(sys: SystemContext, actor: Actor | null): Promise<boolean> {
78
105
  if (!actor || actor.kind !== "user") return false
79
106
  return isSuperUser(sys, await sys.store.get("users", actor.userId))
80
107
  }
@@ -123,10 +150,14 @@ type Grantee = { userId: string } | { apiKeyId: string }
123
150
 
124
151
  /** Roles currently held by a grantee (excluding archived). Includes implicit roles for users. */
125
152
  export async function heldRoleIds(sys: SystemContext, grantee: Grantee): Promise<string[]> {
126
- const ids =
127
- "userId" in grantee
128
- ? [...(await sys.store.findBy("userRoles", "userId", grantee.userId)).map((r) => r.roleId), ...implicitRoleIds(sys)]
129
- : (await sys.store.findBy("apiKeyRoles", "apiKeyId", grantee.apiKeyId)).map((r) => r.roleId)
153
+ let ids: string[]
154
+ if ("userId" in grantee) {
155
+ // A Super previewing a role holds exactly that role (plus implicit ones).
156
+ const view = await activeRoleView(sys, await sys.store.get("users", grantee.userId))
157
+ ids = [...(view ? [view] : (await sys.store.findBy("userRoles", "userId", grantee.userId)).map((r) => r.roleId)), ...implicitRoleIds(sys)]
158
+ } else {
159
+ ids = (await sys.store.findBy("apiKeyRoles", "apiKeyId", grantee.apiKeyId)).map((r) => r.roleId)
160
+ }
130
161
  const out: string[] = []
131
162
  for (const id of new Set(ids)) {
132
163
  const role = await sys.store.get("roles", id)
@@ -167,15 +198,17 @@ async function decideUnsafe(sys: SystemContext, actor: Actor | null, permission:
167
198
  if (actor.kind === "user") {
168
199
  const user = await sys.store.get("users", actor.userId)
169
200
  if (!user || user.status !== "active") return deny(permission, "account_inactive", moduleId)
170
- // Active Super may use every registered module and permission, including disabled modules.
171
- if (isSuperUser(sys, user)) return { allowed: true, permission, reason: "super", moduleId }
201
+ // Active Super may use every registered module and permission, including disabled
202
+ // modules, unless previewing a role: then the role's grants below apply.
203
+ if (await isEffectiveSuperUser(sys, user)) return { allowed: true, permission, reason: "super", moduleId }
172
204
  if (!isModuleEnabled(sys, moduleRow)) return deny(permission, "module_disabled", moduleId)
173
205
  const paths = await grantPaths(sys, { userId: user.key }, permission)
174
206
  return paths.length ? { allowed: true, permission, reason: "granted", moduleId, paths } : deny(permission, "not_granted", moduleId)
175
207
  }
176
208
 
177
209
  // Agent: active key, active owner, enabled module (no Super exception),
178
- // permission through key roles AND the owner's current permission.
210
+ // permission through key roles AND the owner's current permission. The owner's
211
+ // real Super status counts here: a role preview never breaks their agents.
179
212
  const key = await sys.store.get("apiKeys", actor.apiKeyId)
180
213
  if (!key || key.ownerId !== actor.ownerId || apiKeyStatus(key, sys.now()) !== "active") return deny(permission, "key_inactive", moduleId)
181
214
  if (perm.humanOnly) return deny(permission, "human_only", moduleId)
@@ -273,7 +306,7 @@ export async function permissionsOfSubrole(sys: SystemContext, subroleId: string
273
306
  export async function entitlements(sys: SystemContext, userId: string): Promise<Set<string>> {
274
307
  const user = await sys.store.get("users", userId)
275
308
  if (!user || user.status !== "active") return new Set()
276
- if (isSuperUser(sys, user)) {
309
+ if (await isEffectiveSuperUser(sys, user)) {
277
310
  const rows = await sys.store.all("permissions")
278
311
  return new Set(rows.filter((r) => r.status === "active" && sys.def.catalog.permissions.has(r.key)).map((r) => r.key))
279
312
  }
package/src/core/index.ts CHANGED
@@ -17,3 +17,4 @@ export * from "./api-keys.js"
17
17
  export * from "./modules-service.js"
18
18
  export * from "./feature-flags.js"
19
19
  export * from "./jobs.js"
20
+ export * from "./role-view.js"
@@ -11,6 +11,7 @@ import {
11
11
  type Decision,
12
12
  } from "./access.js"
13
13
  import { recordAudit } from "./audit.js"
14
+ import { roleViewStatus, type RoleViewStatus } from "./role-view.js"
14
15
  import type { SystemContext } from "./context.js"
15
16
  import { notFound, validation } from "./errors.js"
16
17
  import { flagValue } from "./feature-flags.js"
@@ -89,14 +90,18 @@ export interface SessionSummary {
89
90
  permissions: string[]
90
91
  /** Effective flag values in modules the actor can use. */
91
92
  flags: Record<string, boolean>
93
+ /** The caller is a real Super and may use "view as role". */
94
+ canViewAs: boolean
95
+ /** The role a Super is previewing; then `actor.isSuper` is false and everything else is that role's. */
96
+ roleView: RoleViewStatus["roleView"]
92
97
  }
93
98
 
94
99
  export async function sessionSummary(sys: SystemContext, actor: Actor | null): Promise<SessionSummary> {
95
- if (!actor) return { actor: null, modules: [], permissions: [], flags: {} }
100
+ if (!actor) return { actor: null, modules: [], permissions: [], flags: {}, canViewAs: false, roleView: null }
96
101
  let actorOut: SessionSummary["actor"]
97
102
  if (actor.kind === "user") {
98
103
  const user = await sys.store.get("users", actor.userId)
99
- if (!user || user.status !== "active") return { actor: null, modules: [], permissions: [], flags: {} }
104
+ if (!user || user.status !== "active") return { actor: null, modules: [], permissions: [], flags: {}, canViewAs: false, roleView: null }
100
105
  actorOut = { kind: "user", userId: user.key, email: user.email, isSuper: await isSuperActor(sys, actor) }
101
106
  if (user.displayName) actorOut.displayName = user.displayName
102
107
  } else {
@@ -107,5 +112,6 @@ export async function sessionSummary(sys: SystemContext, actor: Actor | null): P
107
112
  const usable = new Set(modules.map((m) => m.id))
108
113
  const flags: Record<string, boolean> = {}
109
114
  for (const f of sys.def.catalog.flags.values()) if (usable.has(f.moduleId)) flags[f.id] = await flagValue(sys, f.id)
110
- return { actor: actorOut, modules, permissions: await effectivePermissions(sys, actor), flags }
115
+ const view = actor.kind === "user" ? await roleViewStatus(sys, actor) : { canViewAs: false, roleView: null }
116
+ return { actor: actorOut, modules, permissions: await effectivePermissions(sys, actor), flags, ...view }
111
117
  }
@@ -0,0 +1,43 @@
1
+ // "View as role": a Super previews the system as a holder of one role. While
2
+ // active, access decisions, entitlements, delegation and the session treat the
3
+ // Super as that role (see access.ts). Human Super sessions only; audited.
4
+ import { activeRoleView, isRealSuperActor, requireHuman, withOperation, type Actor } from "./access.js"
5
+ import { recordAudit } from "./audit.js"
6
+ import type { SystemContext } from "./context.js"
7
+ import { forbidden, notFound, validation } from "./errors.js"
8
+ import { PROFILE_ROLE_ID } from "./system-modules.js"
9
+
10
+ export interface RoleViewStatus {
11
+ /** The caller is a real Super and may start a preview. */
12
+ canViewAs: boolean
13
+ roleView: { roleId: string; roleName: string } | null
14
+ }
15
+
16
+ export async function roleViewStatus(sys: SystemContext, actor: Actor | null): Promise<RoleViewStatus> {
17
+ if (!actor || actor.kind !== "user" || !(await isRealSuperActor(sys, actor))) return { canViewAs: false, roleView: null }
18
+ const roleId = await activeRoleView(sys, await sys.store.get("users", actor.userId))
19
+ const role = roleId ? await sys.store.get("roles", roleId) : null
20
+ return { canViewAs: true, roleView: role ? { roleId: role.key, roleName: role.name } : null }
21
+ }
22
+
23
+ export async function startRoleView(sys: SystemContext, actor: Actor | null, roleId: string): Promise<{ roleId: string; roleName: string }> {
24
+ const human = requireHuman(sys, actor)
25
+ if (!(await isRealSuperActor(sys, human))) throw withOperation(sys, forbidden("super_required"))
26
+ if (roleId === PROFILE_ROLE_ID) throw withOperation(sys, validation("profile_role", ["roleId"]))
27
+ const role = await sys.store.get("roles", roleId)
28
+ if (!role || role.archived) throw withOperation(sys, notFound())
29
+ const existing = await sys.store.get("roleViews", human.userId)
30
+ if (existing) await sys.store.patch("roleViews", human.userId, { roleId, startedAt: sys.now() })
31
+ else await sys.store.insert("roleViews", { key: human.userId, roleId, startedAt: sys.now() })
32
+ await recordAudit(sys, human, { action: "super.role_view.start", targetType: "role", targetId: roleId })
33
+ return { roleId, roleName: role.name }
34
+ }
35
+
36
+ export async function stopRoleView(sys: SystemContext, actor: Actor | null): Promise<{ stopped: boolean }> {
37
+ const human = requireHuman(sys, actor)
38
+ const existing = await sys.store.get("roleViews", human.userId)
39
+ if (!existing) return { stopped: false }
40
+ await sys.store.delete("roleViews", human.userId)
41
+ await recordAudit(sys, human, { action: "super.role_view.stop", targetType: "role", targetId: existing.roleId })
42
+ return { stopped: true }
43
+ }
package/src/core/store.ts CHANGED
@@ -143,6 +143,9 @@ export interface SystemOperationRow {
143
143
 
144
144
  export interface SystemMetaRow { key: string; catalogVersion: string; registeredAt: number }
145
145
 
146
+ /** A Super previewing the system as a role. `key` is the Super's user ID. */
147
+ export interface RoleViewRow { key: string; roleId: string; startedAt: number }
148
+
146
149
  export interface Tables {
147
150
  users: UserRow
148
151
  identities: IdentityRow
@@ -161,6 +164,7 @@ export interface Tables {
161
164
  auditEvents: AuditEventRow
162
165
  systemOperations: SystemOperationRow
163
166
  systemMeta: SystemMetaRow
167
+ roleViews: RoleViewRow
164
168
  }
165
169
 
166
170
  export type TableName = keyof Tables
@@ -184,6 +188,7 @@ export const INDEXES = {
184
188
  auditEvents: ["operationId", "actorUserId", "targetId"],
185
189
  systemOperations: [],
186
190
  systemMeta: [],
191
+ roleViews: [],
187
192
  } as const satisfies { [T in TableName]: readonly (keyof Tables[T] & string)[] }
188
193
 
189
194
  export type IndexField<T extends TableName> = (typeof INDEXES)[T][number] & keyof Tables[T]
@@ -0,0 +1,189 @@
1
+ // `@digimuza/system-core/finance`: the finance business module (invoices and
2
+ // their approval), reusable by every client app.
3
+ //
4
+ // // convex/schema.ts
5
+ // export default defineSchema({ ...systemTables, ...financeTables })
6
+ // // convex/system.ts
7
+ // export const system = defineConvexSystem({ modules: [finance] })
8
+ // // convex/finance.ts
9
+ // export const { listInvoices, createInvoice, approveInvoice, bulkApproveInvoices } = defineFinance(system)
10
+ //
11
+ // Every function guards itself on the server (permission, module enabled,
12
+ // role view) in the same transaction as the change and writes an audit event.
13
+ import { defineTable, mutationGeneric, queryGeneric } from "convex/server"
14
+ import type { GenericId } from "convex/values"
15
+ import { v } from "convex/values"
16
+ import { isFeatureEnabled, recordAudit, SystemError, type Actor } from "../core/index.js"
17
+ import { toConvexError, type ConvexSystem } from "../convex/functions.js"
18
+ import { defineModule, type PermissionOf } from "../modules/index.js"
19
+
20
+ export const finance = defineModule({
21
+ id: "finance",
22
+ kind: "business",
23
+ description: "Sąskaitų peržiūra ir tvirtinimas",
24
+ permissions: [
25
+ { id: "finance:invoice:read", name: "Skaityti", description: "Peržiūrėti sąskaitas" },
26
+ { id: "finance:invoice:create", name: "Kurti", description: "Sukurti sąskaitą" },
27
+ { id: "finance:invoice:approve", name: "Tvirtinti", description: "Patvirtinti sąskaitą" },
28
+ ],
29
+ initialSubroles: [
30
+ { id: "finance:reader", name: "Skaitytojas", description: "Gali peržiūrėti sąskaitas", permissions: ["finance:invoice:read"] },
31
+ {
32
+ id: "finance:accountant",
33
+ name: "Buhalteris",
34
+ description: "Gali kurti, peržiūrėti ir tvirtinti sąskaitas",
35
+ permissions: ["finance:invoice:read", "finance:invoice:create", "finance:invoice:approve"],
36
+ },
37
+ ],
38
+ featureFlags: [{ id: "finance:bulk-approve", description: "Tvirtinti kelias sąskaitas vienu veiksmu", default: false }],
39
+ })
40
+
41
+ export const FinancePermissions = {
42
+ read: "finance:invoice:read",
43
+ create: "finance:invoice:create",
44
+ approve: "finance:invoice:approve",
45
+ } as const satisfies Record<string, PermissionOf<typeof finance>>
46
+
47
+ export const FINANCE_BULK_APPROVE_FLAG = "finance:bulk-approve"
48
+ export const FINANCE_BULK_LIMIT = 50
49
+
50
+ /** Spread into the app schema next to `systemTables`. */
51
+ export const financeTables = {
52
+ invoices: defineTable({
53
+ number: v.string(),
54
+ customer: v.string(),
55
+ amountCents: v.number(),
56
+ currency: v.string(),
57
+ status: v.union(v.literal("draft"), v.literal("approved")),
58
+ createdBy: v.string(),
59
+ approvedBy: v.optional(v.string()),
60
+ approvedAt: v.optional(v.number()),
61
+ })
62
+ .index("by_number", ["number"])
63
+ .index("by_status", ["status"]),
64
+ }
65
+
66
+ export type InvoiceStatus = "draft" | "approved"
67
+
68
+ /** Convex ID of an invoice in the app's `invoices` table. */
69
+ export type InvoiceId = GenericId<"invoices">
70
+
71
+ export interface InvoiceView {
72
+ id: InvoiceId
73
+ number: string
74
+ customer: string
75
+ amountCents: number
76
+ currency: string
77
+ status: InvoiceStatus
78
+ createdAt: number
79
+ approvedAt: number | null
80
+ }
81
+
82
+ interface InvoiceDoc {
83
+ _id: InvoiceId
84
+ _creationTime: number
85
+ number: string
86
+ customer: string
87
+ amountCents: number
88
+ currency: string
89
+ status: InvoiceStatus
90
+ approvedAt?: number
91
+ }
92
+
93
+ const view = (i: InvoiceDoc): InvoiceView => ({
94
+ id: i._id,
95
+ number: i.number,
96
+ customer: i.customer,
97
+ amountCents: i.amountCents,
98
+ currency: i.currency,
99
+ status: i.status,
100
+ createdAt: i._creationTime,
101
+ approvedAt: i.approvedAt ?? null,
102
+ })
103
+
104
+ const actorId = (a: Actor) => (a.kind === "user" ? a.userId : a.apiKeyId)
105
+ const actorArgs = { apiKey: v.optional(v.string()), operationId: v.optional(v.string()) }
106
+ const opts = (a: { apiKey?: string | undefined; operationId?: string | undefined }) => ({ apiKey: a.apiKey, operationId: a.operationId })
107
+
108
+ function fail(code: "NOT_FOUND" | "CONFLICT" | "VALIDATION" | "FORBIDDEN", reason: string, operationId: string): never {
109
+ throw toConvexError(new SystemError(code, undefined, { reason, operationId }), operationId)
110
+ }
111
+
112
+ /** The module's Convex functions, bound to the app's system. Re-export them from `convex/finance.ts`. */
113
+ export function defineFinance(system: ConvexSystem) {
114
+ // A loosely typed db handle: the app's schema contains `financeTables`.
115
+ type Db = {
116
+ query(t: "invoices"): {
117
+ withIndex(i: string, f: (q: { eq(field: string, value: unknown): unknown }) => unknown): { order(o: "asc" | "desc"): { take(n: number): Promise<InvoiceDoc[]> }; unique(): Promise<InvoiceDoc | null> }
118
+ order(o: "asc" | "desc"): { take(n: number): Promise<InvoiceDoc[]> }
119
+ }
120
+ get(t: "invoices", id: string): Promise<InvoiceDoc | null>
121
+ insert(t: "invoices", doc: Record<string, unknown>): Promise<InvoiceId>
122
+ patch(t: "invoices", id: string, doc: Record<string, unknown>): Promise<void>
123
+ }
124
+ const db = (ctx: { db: unknown }) => ctx.db as Db
125
+
126
+ async function approveOne(ctx: { db: unknown }, operationId: string, invoiceId: string, by: string) {
127
+ const invoice = await db(ctx).get("invoices", invoiceId)
128
+ // A missing invoice and one the caller may not see look the same.
129
+ if (!invoice) fail("NOT_FOUND", "invoice_not_found", operationId)
130
+ if (invoice!.status !== "draft") fail("CONFLICT", "invoice_not_draft", operationId)
131
+ await db(ctx).patch("invoices", invoiceId, { status: "approved", approvedBy: by, approvedAt: Date.now() })
132
+ }
133
+
134
+ return {
135
+ listInvoices: queryGeneric({
136
+ args: { ...actorArgs, status: v.optional(v.union(v.literal("draft"), v.literal("approved"))) },
137
+ handler: async (ctx, a): Promise<InvoiceView[]> => {
138
+ await system.requirePermission(ctx, FinancePermissions.read, opts(a))
139
+ const q = db(ctx).query("invoices")
140
+ const rows = a.status ? await q.withIndex("by_status", (i) => i.eq("status", a.status)).order("desc").take(100) : await q.order("desc").take(100)
141
+ return rows.map(view)
142
+ },
143
+ }),
144
+
145
+ createInvoice: mutationGeneric({
146
+ args: { ...actorArgs, number: v.string(), customer: v.string(), amountCents: v.number(), currency: v.optional(v.string()) },
147
+ handler: async (ctx, a): Promise<{ invoiceId: InvoiceId; operationId: string }> => {
148
+ const { sys, actor } = await system.requirePermission(ctx, FinancePermissions.create, opts(a))
149
+ const number = a.number.trim()
150
+ const customer = a.customer.trim()
151
+ if (!number || !customer || !Number.isInteger(a.amountCents) || a.amountCents <= 0) fail("VALIDATION", "invalid_invoice", sys.operationId)
152
+ const currency = (a.currency ?? "EUR").toUpperCase()
153
+ if (!/^[A-Z]{3}$/.test(currency)) fail("VALIDATION", "invalid_currency", sys.operationId)
154
+ const existing = await db(ctx).query("invoices").withIndex("by_number", (i) => i.eq("number", number)).unique()
155
+ if (existing) fail("CONFLICT", "invoice_number_taken", sys.operationId)
156
+ const invoiceId = await db(ctx).insert("invoices", { number, customer, amountCents: a.amountCents, currency, status: "draft", createdBy: actorId(actor) })
157
+ await recordAudit(sys, actor, { action: "finance.invoice.create", targetType: "invoice", targetId: invoiceId, changes: { number } })
158
+ return { invoiceId, operationId: sys.operationId }
159
+ },
160
+ }),
161
+
162
+ approveInvoice: mutationGeneric({
163
+ args: { ...actorArgs, invoiceId: v.id("invoices") },
164
+ handler: async (ctx, a): Promise<{ operationId: string }> => {
165
+ const { sys, actor } = await system.requirePermission(ctx, FinancePermissions.approve, opts(a))
166
+ await approveOne(ctx, sys.operationId, a.invoiceId, actorId(actor))
167
+ await recordAudit(sys, actor, { action: "finance.invoice.approve", targetType: "invoice", targetId: a.invoiceId })
168
+ return { operationId: sys.operationId }
169
+ },
170
+ }),
171
+
172
+ /** Behind the `finance:bulk-approve` flag. The flag never grants the permission. */
173
+ bulkApproveInvoices: mutationGeneric({
174
+ args: { ...actorArgs, invoiceIds: v.array(v.id("invoices")) },
175
+ handler: async (ctx, a): Promise<{ approved: number; operationId: string }> => {
176
+ const { sys, actor } = await system.requirePermission(ctx, FinancePermissions.approve, opts(a))
177
+ if (!(await isFeatureEnabled(sys, FINANCE_BULK_APPROVE_FLAG))) fail("FORBIDDEN", "flag_off", sys.operationId)
178
+ if (a.invoiceIds.length > FINANCE_BULK_LIMIT) fail("VALIDATION", "too_many", sys.operationId)
179
+ for (const id of a.invoiceIds) {
180
+ await approveOne(ctx, sys.operationId, id, actorId(actor))
181
+ await recordAudit(sys, actor, { action: "finance.invoice.approve", targetType: "invoice", targetId: id, changes: { bulk: true } })
182
+ }
183
+ return { approved: a.invoiceIds.length, operationId: sys.operationId }
184
+ },
185
+ }),
186
+ }
187
+ }
188
+
189
+ export type FinanceFunctions = ReturnType<typeof defineFinance>
@@ -13,9 +13,9 @@ export type SessionState =
13
13
  | ({ status: "anonymous" } & EmptySession)
14
14
  | ({ status: "ready" } & SessionSummary & { actor: NonNullable<SessionSummary["actor"]> })
15
15
 
16
- type EmptySession = { actor: null; modules: ModuleState[]; permissions: string[]; flags: Record<string, boolean> }
16
+ type EmptySession = Omit<SessionSummary, "actor"> & { actor: null }
17
17
 
18
- const EMPTY: EmptySession = { actor: null, modules: [], permissions: [], flags: {} }
18
+ const EMPTY: EmptySession = { actor: null, modules: [], permissions: [], flags: {}, canViewAs: false, roleView: null }
19
19
  const LOADING: SessionState = { status: "loading", ...EMPTY }
20
20
 
21
21
  const SessionContext = createContext<SessionState | null>(null)
@@ -59,6 +59,16 @@ export function useCan(permission: string): boolean {
59
59
  return useSystemSession().permissions.includes(permission)
60
60
  }
61
61
 
62
+ /**
63
+ * "View as role" state: `canViewAs` for a real Super, `roleView` while a preview
64
+ * is active (then the rest of the session is that role's). Start and stop with
65
+ * the `startRoleView` / `stopRoleView` mutations.
66
+ */
67
+ export function useRoleView(): Pick<SessionSummary, "canViewAs" | "roleView"> {
68
+ const { canViewAs, roleView } = useSystemSession()
69
+ return { canViewAs, roleView }
70
+ }
71
+
62
72
  export function useFeatureFlag(flagId: string): boolean {
63
73
  return useSystemSession().flags[flagId] === true
64
74
  }