@adonis-agora/durable 1.0.0-rc.1 → 1.0.0-rc.10

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 (148) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/dist/assets/spa/assets/index-B2kIfzsc.css +1 -0
  3. package/dist/assets/spa/assets/index-Bgf_8IJ0.js +16 -0
  4. package/dist/assets/spa/assets/index-Bgf_8IJ0.js.map +1 -0
  5. package/dist/assets/spa/index.html +2 -2
  6. package/dist/commands/runs.d.ts.map +1 -1
  7. package/dist/commands/runs.js +4 -2
  8. package/dist/commands/runs.js.map +1 -1
  9. package/dist/providers/dashboard_provider.d.ts +15 -49
  10. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  11. package/dist/providers/dashboard_provider.js +69 -244
  12. package/dist/providers/dashboard_provider.js.map +1 -1
  13. package/dist/providers/durable_provider.d.ts.map +1 -1
  14. package/dist/providers/durable_provider.js +8 -3
  15. package/dist/providers/durable_provider.js.map +1 -1
  16. package/dist/src/commands/runs.js +1 -1
  17. package/dist/src/commands/runs.js.map +1 -1
  18. package/dist/src/concurrency.d.ts.map +1 -1
  19. package/dist/src/concurrency.js +1 -0
  20. package/dist/src/concurrency.js.map +1 -1
  21. package/dist/src/control-flow-signal.d.ts +5 -4
  22. package/dist/src/control-flow-signal.d.ts.map +1 -1
  23. package/dist/src/control-flow-signal.js +5 -4
  24. package/dist/src/control-flow-signal.js.map +1 -1
  25. package/dist/src/dashboard/access_denied_page.d.ts +5 -7
  26. package/dist/src/dashboard/access_denied_page.d.ts.map +1 -1
  27. package/dist/src/dashboard/access_denied_page.js +2 -6
  28. package/dist/src/dashboard/access_denied_page.js.map +1 -1
  29. package/dist/src/dashboard/console_authorize.d.ts +76 -0
  30. package/dist/src/dashboard/console_authorize.d.ts.map +1 -0
  31. package/dist/src/dashboard/console_authorize.js +101 -0
  32. package/dist/src/dashboard/console_authorize.js.map +1 -0
  33. package/dist/src/dashboard/define_config.d.ts +44 -81
  34. package/dist/src/dashboard/define_config.d.ts.map +1 -1
  35. package/dist/src/dashboard/define_config.js +16 -112
  36. package/dist/src/dashboard/define_config.js.map +1 -1
  37. package/dist/src/dashboard/handlers.d.ts.map +1 -1
  38. package/dist/src/dashboard/handlers.js +6 -3
  39. package/dist/src/dashboard/handlers.js.map +1 -1
  40. package/dist/src/dashboard/index.d.ts +5 -8
  41. package/dist/src/dashboard/index.d.ts.map +1 -1
  42. package/dist/src/dashboard/index.js +4 -6
  43. package/dist/src/dashboard/index.js.map +1 -1
  44. package/dist/src/dashboard/openapi.d.ts.map +1 -1
  45. package/dist/src/dashboard/openapi.js +11 -1
  46. package/dist/src/dashboard/openapi.js.map +1 -1
  47. package/dist/src/dashboard/run-filter.d.ts.map +1 -1
  48. package/dist/src/dashboard/run-filter.js +1 -0
  49. package/dist/src/dashboard/run-filter.js.map +1 -1
  50. package/dist/src/diagnostics/index.d.ts +11 -0
  51. package/dist/src/diagnostics/index.d.ts.map +1 -0
  52. package/dist/src/diagnostics/index.js +6 -0
  53. package/dist/src/diagnostics/index.js.map +1 -0
  54. package/dist/src/diagnostics/transport.d.ts +71 -0
  55. package/dist/src/diagnostics/transport.d.ts.map +1 -0
  56. package/dist/src/diagnostics/transport.js +188 -0
  57. package/dist/src/diagnostics/transport.js.map +1 -0
  58. package/dist/src/diagnostics-bridge.d.ts +2 -1
  59. package/dist/src/diagnostics-bridge.d.ts.map +1 -1
  60. package/dist/src/diagnostics-bridge.js +7 -1
  61. package/dist/src/diagnostics-bridge.js.map +1 -1
  62. package/dist/src/engine.d.ts +13 -8
  63. package/dist/src/engine.d.ts.map +1 -1
  64. package/dist/src/engine.js +136 -51
  65. package/dist/src/engine.js.map +1 -1
  66. package/dist/src/errors.d.ts +9 -0
  67. package/dist/src/errors.d.ts.map +1 -1
  68. package/dist/src/errors.js +13 -0
  69. package/dist/src/errors.js.map +1 -1
  70. package/dist/src/event-trigger-bridge.d.ts +5 -14
  71. package/dist/src/event-trigger-bridge.d.ts.map +1 -1
  72. package/dist/src/event-trigger-bridge.js +11 -99
  73. package/dist/src/event-trigger-bridge.js.map +1 -1
  74. package/dist/src/index.d.ts +1 -1
  75. package/dist/src/index.d.ts.map +1 -1
  76. package/dist/src/index.js +1 -1
  77. package/dist/src/index.js.map +1 -1
  78. package/dist/src/interfaces.d.ts +22 -6
  79. package/dist/src/interfaces.d.ts.map +1 -1
  80. package/dist/src/interfaces.js +8 -0
  81. package/dist/src/interfaces.js.map +1 -1
  82. package/dist/src/origin.js +3 -3
  83. package/dist/src/scheduler.d.ts.map +1 -1
  84. package/dist/src/scheduler.js +2 -1
  85. package/dist/src/scheduler.js.map +1 -1
  86. package/dist/src/singleton-admission.d.ts.map +1 -1
  87. package/dist/src/singleton-admission.js +2 -0
  88. package/dist/src/singleton-admission.js.map +1 -1
  89. package/dist/src/singleton-gate.d.ts +1 -1
  90. package/dist/src/singleton-gate.d.ts.map +1 -1
  91. package/dist/src/singleton-gate.js +4 -3
  92. package/dist/src/singleton-gate.js.map +1 -1
  93. package/dist/src/stores/lucid-pg-hot-path.d.ts +6 -2
  94. package/dist/src/stores/lucid-pg-hot-path.d.ts.map +1 -1
  95. package/dist/src/stores/lucid-pg-hot-path.js +7 -3
  96. package/dist/src/stores/lucid-pg-hot-path.js.map +1 -1
  97. package/dist/src/stores/lucid.d.ts +15 -2
  98. package/dist/src/stores/lucid.d.ts.map +1 -1
  99. package/dist/src/stores/lucid.js +191 -49
  100. package/dist/src/stores/lucid.js.map +1 -1
  101. package/dist/src/testing/in-memory-state-store.js +3 -3
  102. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  103. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  104. package/dist/src/testing-kit/state-store-conformance.js +13 -0
  105. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  106. package/dist/src/testing-kit/transport-conformance.d.ts.map +1 -1
  107. package/dist/src/testing-kit/transport-conformance.js +5 -1
  108. package/dist/src/testing-kit/transport-conformance.js.map +1 -1
  109. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  110. package/dist/src/transports/bullmq/bullmq-transport.js +13 -2
  111. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  112. package/dist/src/transports/db.d.ts.map +1 -1
  113. package/dist/src/transports/db.js +42 -3
  114. package/dist/src/transports/db.js.map +1 -1
  115. package/dist/src/transports/queue.d.ts.map +1 -1
  116. package/dist/src/transports/queue.js +44 -3
  117. package/dist/src/transports/queue.js.map +1 -1
  118. package/dist/src/workflow-discovery.d.ts +1 -1
  119. package/dist/src/workflow-discovery.d.ts.map +1 -1
  120. package/dist/src/workflow-discovery.js +4 -7
  121. package/dist/src/workflow-discovery.js.map +1 -1
  122. package/dist/src/workflow-events.d.ts +20 -51
  123. package/dist/src/workflow-events.d.ts.map +1 -1
  124. package/dist/src/workflow-events.js +12 -30
  125. package/dist/src/workflow-events.js.map +1 -1
  126. package/dist/src/workflow-ref.d.ts +1 -1
  127. package/dist/src/workflow-turn.d.ts.map +1 -1
  128. package/dist/src/workflow-turn.js +18 -1
  129. package/dist/src/workflow-turn.js.map +1 -1
  130. package/dist/stubs/config/durable_dashboard.stub +9 -29
  131. package/package.json +12 -2
  132. package/skills/durable-cluster/SKILL.md +8 -4
  133. package/skills/durable-reliability/SKILL.md +2 -2
  134. package/dist/assets/spa/assets/index-70hGzOnT.css +0 -1
  135. package/dist/assets/spa/assets/index-CJjMAUau.js +0 -16
  136. package/dist/assets/spa/assets/index-CJjMAUau.js.map +0 -1
  137. package/dist/src/dashboard/auth.d.ts +0 -134
  138. package/dist/src/dashboard/auth.d.ts.map +0 -1
  139. package/dist/src/dashboard/auth.js +0 -156
  140. package/dist/src/dashboard/auth.js.map +0 -1
  141. package/dist/src/dashboard/login_page.d.ts +0 -36
  142. package/dist/src/dashboard/login_page.d.ts.map +0 -1
  143. package/dist/src/dashboard/login_page.js +0 -158
  144. package/dist/src/dashboard/login_page.js.map +0 -1
  145. package/dist/src/dashboard/session_cookie.d.ts +0 -45
  146. package/dist/src/dashboard/session_cookie.d.ts.map +0 -1
  147. package/dist/src/dashboard/session_cookie.js +0 -97
  148. package/dist/src/dashboard/session_cookie.js.map +0 -1
@@ -10,8 +10,8 @@
10
10
  rel="stylesheet"
11
11
  />
12
12
  <title>durable · control plane</title>
13
- <script type="module" crossorigin src="/__DURABLE_DASHBOARD__/assets/index-CJjMAUau.js"></script>
14
- <link rel="stylesheet" crossorigin href="/__DURABLE_DASHBOARD__/assets/index-70hGzOnT.css">
13
+ <script type="module" crossorigin src="/__DURABLE_DASHBOARD__/assets/index-Bgf_8IJ0.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/__DURABLE_DASHBOARD__/assets/index-B2kIfzsc.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":3,"file":"runs.d.ts","sourceRoot":"","sources":["../../commands/runs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAS,MAAM,oBAAoB,CAAC;AACxD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAY/D;;;;;;;;;GASG;AACH,MAAM,CAAC,OAAO,OAAO,WAAY,SAAQ,WAAW;IAClD,OAAgB,WAAW,SAAkB;IAC7C,OAAgB,WAAW,SAAuC;IAClE,OAAgB,OAAO,EAAE,cAAc,CAAsB;IAKrD,MAAM,CAAC,EAAE,MAAM,CAAC;IAGhB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAGlB,KAAK,EAAE,MAAM,CAAC;IAQd,KAAK,CAAC,EAAE,MAAM,CAAC;IAER,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,CA0BlC;CACF"}
1
+ {"version":3,"file":"runs.d.ts","sourceRoot":"","sources":["../../commands/runs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAS,MAAM,oBAAoB,CAAC;AACxD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAY/D;;;;;;;;;GASG;AACH,MAAM,CAAC,OAAO,OAAO,WAAY,SAAQ,WAAW;IAClD,OAAgB,WAAW,SAAkB;IAC7C,OAAgB,WAAW,SAAuC;IAClE,OAAgB,OAAO,EAAE,cAAc,CAAsB;IAKrD,MAAM,CAAC,EAAE,MAAM,CAAC;IAGhB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAGlB,KAAK,EAAE,MAAM,CAAC;IAQd,KAAK,CAAC,EAAE,MAAM,CAAC;IAER,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,CA4BlC;CACF"}
@@ -26,10 +26,12 @@ export default class DurableRuns extends BaseCommand {
26
26
  const wantsStale = this.stale !== undefined;
27
27
  const runs = await listRuns(engine, {
28
28
  ...(this.status ? { status: this.status } : {}),
29
- // No explicit --status: scope the query to running/suspended, the only statuses a stranded
29
+ // No explicit --status: scope the query to running/suspended/cancelling, the only statuses a stranded
30
30
  // dispatch can show up in, so a stale candidate isn't pushed out of the window by newer
31
31
  // terminal runs.
32
- ...(!this.status && wantsStale ? { statuses: ['running', 'suspended'] } : {}),
32
+ ...(!this.status && wantsStale
33
+ ? { statuses: ['running', 'suspended', 'cancelling'] }
34
+ : {}),
33
35
  ...(this.workflow ? { workflow: this.workflow } : {}),
34
36
  limit: this.limit,
35
37
  });
@@ -1 +1 @@
1
- {"version":3,"file":"runs.js","sourceRoot":"","sources":["../../commands/runs.ts"],"names":[],"mappings":";;;;;;AAAA,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAExD,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,WAAW,EACX,QAAQ,EACR,eAAe,EACf,eAAe,EACf,SAAS,GACV,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAkB,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEjE;;;;;;;;;GASG;AACH,MAAM,CAAC,OAAO,OAAO,WAAY,SAAQ,WAAW;IAClD,MAAM,CAAU,WAAW,GAAG,cAAc,CAAC;IAC7C,MAAM,CAAU,WAAW,GAAG,mCAAmC,CAAC;IAClE,MAAM,CAAU,OAAO,GAAmB,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAqBpD,KAAK,CAAC,GAAG;QAChB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QAC7D,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,KAAK,SAAS,CAAC;QAC5C,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,MAAM,EAAE;YAClC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,MAAmB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC5D,2FAA2F;YAC3F,wFAAwF;YACxF,iBAAiB;YACjB,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,IAAI,UAAU,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,SAAS,EAAE,WAAW,CAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC5F,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACrD,KAAK,EAAE,IAAI,CAAC,KAAK;SAClB,CAAC,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,cAAc,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QAEpD,IAAI,CAAC,UAAU,EAAE,CAAC;YAChB,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC,CAAC;YAC3C,OAAO;QACT,CAAC;QAED,MAAM,WAAW,GACf,IAAI,CAAC,KAAK,KAAK,EAAE;YACf,CAAC,CAAC,gBAAgB;YAClB,CAAC,CAAC,CAAC,eAAe,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC,IAAI,gBAAgB,CAAC,CAAC;QAC9D,MAAM,KAAK,GAAG,WAAW,CAAC,QAAQ,EAAE,WAAW,CAAC,CAAC;QACjD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC,CAAC;QACxC,KAAK,MAAM,EAAE,GAAG,EAAE,IAAI,KAAK;YAAE,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IACtE,CAAC;CACF;AA3CS;IAHP,KAAK,CAAC,MAAM,CAAC;QACZ,WAAW,EAAE,8EAA8E;KAC5F,CAAC;2CACsB;AAGhB;IADP,KAAK,CAAC,MAAM,CAAC,EAAE,WAAW,EAAE,yBAAyB,EAAE,CAAC;6CAC/B;AAGlB;IADP,KAAK,CAAC,MAAM,CAAC,EAAE,WAAW,EAAE,kBAAkB,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC;0CACzC;AAQd;IANP,KAAK,CAAC,MAAM,CAAC;QACZ,WAAW,EACT,2FAA2F;YAC3F,6FAA6F;YAC7F,iDAAiD;KACpD,CAAC;0CACqB"}
1
+ {"version":3,"file":"runs.js","sourceRoot":"","sources":["../../commands/runs.ts"],"names":[],"mappings":";;;;;;AAAA,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAExD,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,WAAW,EACX,QAAQ,EACR,eAAe,EACf,eAAe,EACf,SAAS,GACV,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAkB,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEjE;;;;;;;;;GASG;AACH,MAAM,CAAC,OAAO,OAAO,WAAY,SAAQ,WAAW;IAClD,MAAM,CAAU,WAAW,GAAG,cAAc,CAAC;IAC7C,MAAM,CAAU,WAAW,GAAG,mCAAmC,CAAC;IAClE,MAAM,CAAU,OAAO,GAAmB,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAqBpD,KAAK,CAAC,GAAG;QAChB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QAC7D,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,KAAK,SAAS,CAAC;QAC5C,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,MAAM,EAAE;YAClC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,MAAmB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC5D,sGAAsG;YACtG,wFAAwF;YACxF,iBAAiB;YACjB,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,IAAI,UAAU;gBAC5B,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,SAAS,EAAE,WAAW,EAAE,YAAY,CAAgB,EAAE;gBACrE,CAAC,CAAC,EAAE,CAAC;YACP,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACrD,KAAK,EAAE,IAAI,CAAC,KAAK;SAClB,CAAC,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,cAAc,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QAEpD,IAAI,CAAC,UAAU,EAAE,CAAC;YAChB,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC,CAAC;YAC3C,OAAO;QACT,CAAC;QAED,MAAM,WAAW,GACf,IAAI,CAAC,KAAK,KAAK,EAAE;YACf,CAAC,CAAC,gBAAgB;YAClB,CAAC,CAAC,CAAC,eAAe,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC,IAAI,gBAAgB,CAAC,CAAC;QAC9D,MAAM,KAAK,GAAG,WAAW,CAAC,QAAQ,EAAE,WAAW,CAAC,CAAC;QACjD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC,CAAC;QACxC,KAAK,MAAM,EAAE,GAAG,EAAE,IAAI,KAAK;YAAE,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IACtE,CAAC;CACF;AA7CS;IAHP,KAAK,CAAC,MAAM,CAAC;QACZ,WAAW,EAAE,8EAA8E;KAC5F,CAAC;2CACsB;AAGhB;IADP,KAAK,CAAC,MAAM,CAAC,EAAE,WAAW,EAAE,yBAAyB,EAAE,CAAC;6CAC/B;AAGlB;IADP,KAAK,CAAC,MAAM,CAAC,EAAE,WAAW,EAAE,kBAAkB,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC;0CACzC;AAQd;IANP,KAAK,CAAC,MAAM,CAAC;QACZ,WAAW,EACT,2FAA2F;YAC3F,6FAA6F;YAC7F,iDAAiD;KACpD,CAAC;0CACqB"}
@@ -37,15 +37,7 @@ import type { ApplicationService } from '@adonisjs/core/types';
37
37
  export default class DashboardProvider {
38
38
  protected app: ApplicationService;
39
39
  constructor(app: ApplicationService);
40
- /** Warn once so a throwing `login`/`session` hook doesn't spam the logs on every failed attempt. */
41
- private warnedOnHookThrow;
42
40
  private warnedOnAuthorizeThrow;
43
- /** Login-attempt timestamps per client address — a simple sliding window (10/min) so a brute
44
- * force can't hammer the host's `login` hook at line rate. In-memory per instance: a cluster
45
- * multiplies the budget by pod count, which still reduces the attack by orders of magnitude. */
46
- private readonly loginAttempts;
47
- /** True (and reply 429) when this address exhausted its login window. */
48
- private loginRateLimited;
49
41
  /** SSE connections currently open, per client address (and in total) — see streamRun. */
50
42
  private readonly sseByAddress;
51
43
  private sseTotal;
@@ -76,43 +68,23 @@ export default class DashboardProvider {
76
68
  private streamRun;
77
69
  private compatSource;
78
70
  private fleetTransport;
79
- /**
80
- * Mount the built-in `dashboardAuth` endpoints under `basePath`. All are public (no guard): they
81
- * create/destroy the session the {@link enforce} guard checks for.
82
- *
83
- * - `GET <base>/login` -> the server-rendered login page (Mode B only).
84
- * - `POST <base>/login` -> verifies credentials via the host `login` hook and mints the cookie.
85
- * - `POST <base>/session` -> Mode A: verifies the HOST APP's own auth (off the raw request) via the
86
- * `session` hook and mints the cookie. No body — the hook reads whatever it needs off `request`.
87
- * `204` on success, uniform `401` on denial. Mirrors `@dudousxd/nestjs-durable-dashboard`'s
88
- * `DurableAuthController.session` and `client/console-session.ts`'s `mintDurableConsoleSession`.
89
- * - `GET <base>/logout` -> clears the cookie and redirects to the login page (Mode B) or the
90
- * dashboard root (Mode A only). A plain `GET` (idempotent, only ever destroys the caller's own
91
- * session) so a simple `<a href>` works.
92
- */
93
- private registerAuthRoutes;
94
71
  /** Record one mutating console action (see DashboardAuditEntry). Never throws into the request. */
95
72
  private audit;
96
73
  /** Route a handler 500's detail to the app logger (the client only ever sees a generic body). */
97
74
  private logServerError;
98
75
  private warnAuthorizeThrow;
99
- private warnHookThrow;
76
+ /** The once-per-boot warning for a console with no `authorize` (open in dev, 403 in production). */
77
+ private warnNoAuthorize;
100
78
  /**
101
- * Run the guards for a dashboard resource. Composes the existing `authorize` hook (bearer
102
- * token/custom) with the optional `dashboardAuth` session guard — BOTH must pass:
79
+ * Run `authorize` for a dashboard resource. On a denial:
103
80
  *
104
- * 1. `authorize` denies -> the built-in (or host-customised) access-denied PAGE for a `page`
105
- * request, JSON for an `api` request. `reason: 'unauthenticated'` is a `401`
106
- * (`{ error: 'unauthorized' }`); `false`, `{ allowed: false }`, `reason: 'forbidden'` or a
107
- * throwing hook is a `403` (`{ error: 'forbidden' }`). A hook that already answered the request
108
- * (a redirect, a body) keeps its answer.
109
- * 2. `dashboardAuth` configured AND no valid session -> for a `page` request, redirect `302` to
110
- * the login page (Mode B) carrying a sanitized `returnTo`, or the `401` "open this console
111
- * from your app" page (Mode A only, no login page to redirect to); for an `api` request, a
112
- * plain `401 { error: 'unauthorized', auth: { modes } }` — the `modes` list lets the SPA's
113
- * client (`durable-client.ts`) decide whether to offer the login page.
81
+ * - `{ redirectTo }`: a `page` request is redirected there (`302`); an `api` request gets
82
+ * `401 { error: 'unauthenticated', redirectTo }`;
83
+ * - `reason: 'unauthenticated'`: `401` (the access-denied page, or `{ error: 'unauthenticated' }`);
84
+ * - anything else (`false`, `{ allowed: false }`, a throw): `403` (the access-denied page, or
85
+ * `{ error: 'forbidden' }`).
114
86
  *
115
- * When `dashboardAuth` is unconfigured only step 1 runs.
87
+ * A hook that already answered the request (a redirect, a body) keeps its answer.
116
88
  * Returns `false` (and has already written the response) when the request must short-circuit.
117
89
  */
118
90
  private enforce;
@@ -123,16 +95,8 @@ export default class DashboardProvider {
123
95
  * redirect keeps it; the provider never overwrites a `location` header.
124
96
  */
125
97
  private denyPage;
126
- private readSessionCookie;
127
- private writeSessionCookie;
128
98
  shutdown(): Promise<void>;
129
99
  }
130
- /**
131
- * Whether something already answered this request: a redirect (`location` header — the signal the
132
- * `authorize` contract has always honoured) or a body queued on the response. The body check reads
133
- * AdonisJS's `response.hasLazyBody` structurally so a plain-object `ctx` double in a unit test
134
- * (which has neither) still works.
135
- */
136
100
  /** Whether this request can change state (everything but GET/HEAD/OPTIONS). Exported for tests. */
137
101
  export declare function isMutatingMethod(ctx: HttpContext): boolean;
138
102
  /**
@@ -143,8 +107,10 @@ export declare function isMutatingMethod(ctx: HttpContext): boolean;
143
107
  * cross-subdomain POST caller, and strict beats sorry here.
144
108
  */
145
109
  export declare function crossSiteRequest(ctx: HttpContext): boolean;
146
- /** Role convention (see enforce): no roles = full access; with roles, mutating needs operator/admin. */
147
- export declare function sessionCanMutate(session: {
148
- roles: string[];
149
- }): boolean;
110
+ /**
111
+ * Who a mutating console action is attributed to: the signed-in user (`ctx.auth.user`'s `email`,
112
+ * `name` or `id`, read structurally — this package does not depend on `@adonisjs/auth`), `'bearer'`
113
+ * for a caller with an `Authorization` header and no user, else `'anonymous'`.
114
+ */
115
+ export declare function auditActor(ctx: HttpContext): string;
150
116
  //# sourceMappingURL=dashboard_provider.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dashboard_provider.d.ts","sourceRoot":"","sources":["../../providers/dashboard_provider.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,KAAK,EAAE,kBAAkB,EAAqB,MAAM,sBAAsB,CAAC;AAiFlF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,MAAM,CAAC,OAAO,OAAO,iBAAiB;IACxB,SAAS,CAAC,GAAG,EAAE,kBAAkB;IAA7C,YAAsB,GAAG,EAAE,kBAAkB,EAAI;IAEjD,oGAAoG;IACpG,OAAO,CAAC,iBAAiB,CAAS;IAClC,OAAO,CAAC,sBAAsB,CAAS;IAEvC;;qGAEiG;IACjG,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA+B;IAE7D,yEAAyE;IACzE,OAAO,CAAC,gBAAgB;IAkBxB,yFAAyF;IACzF,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA6B;IAC1D,OAAO,CAAC,QAAQ,CAAK;IAErB;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAoC;IAChE,oFAAoF;IACpF,OAAO,CAAC,iBAAiB,CAA6B;IACtD,kGAAkG;IAClG,OAAO,CAAC,gBAAgB,CAAiC;IAEzD,oGAAoG;IACpG,OAAO,CAAC,WAAW;IAIb,IAAI,kBAgBT;YAEa,iBAAiB;IAiB/B,OAAO,CAAC,cAAc;IA+KtB,yEAAyE;IACzE,OAAO,CAAC,iBAAiB;IAgDzB;;;;;;OAMG;YACW,SAAS;YA6CT,YAAY;YAWZ,cAAc;IAQ5B;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,kBAAkB;IAyF1B,mGAAmG;IACnG,OAAO,CAAC,KAAK;IAiCb,iGAAiG;YACnF,cAAc;YAWd,kBAAkB;YAQlB,aAAa;IAQ3B;;;;;;;;;;;;;;;;;OAiBG;YACW,OAAO;IA0ErB;;;;;OAKG;YACW,QAAQ;IAuBtB,OAAO,CAAC,iBAAiB;IAKzB,OAAO,CAAC,kBAAkB;IAapB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAK9B;CACF;AAED;;;;;GAKG;AACH,mGAAmG;AACnG,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAG1D;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAU1D;AAED,wGAAwG;AACxG,wBAAgB,gBAAgB,CAAC,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,OAAO,CAMtE"}
1
+ {"version":3,"file":"dashboard_provider.d.ts","sourceRoot":"","sources":["../../providers/dashboard_provider.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,KAAK,EAAE,kBAAkB,EAAqB,MAAM,sBAAsB,CAAC;AAuElF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,MAAM,CAAC,OAAO,OAAO,iBAAiB;IACxB,SAAS,CAAC,GAAG,EAAE,kBAAkB;IAA7C,YAAsB,GAAG,EAAE,kBAAkB,EAAI;IAEjD,OAAO,CAAC,sBAAsB,CAAS;IAEvC,yFAAyF;IACzF,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA6B;IAC1D,OAAO,CAAC,QAAQ,CAAK;IAErB;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAoC;IAChE,oFAAoF;IACpF,OAAO,CAAC,iBAAiB,CAA6B;IACtD,kGAAkG;IAClG,OAAO,CAAC,gBAAgB,CAAiC;IAEzD,oGAAoG;IACpG,OAAO,CAAC,WAAW;IAIb,IAAI,kBAkBT;YAEa,iBAAiB;IAiB/B,OAAO,CAAC,cAAc;IAwKtB,yEAAyE;IACzE,OAAO,CAAC,iBAAiB;IAgDzB;;;;;;OAMG;YACW,SAAS;YA6CT,YAAY;YAWZ,cAAc;IAQ5B,mGAAmG;IACnG,OAAO,CAAC,KAAK;IA2Bb,iGAAiG;YACnF,cAAc;YAWd,kBAAkB;IAQhC,oGAAoG;YACtF,eAAe;IAS7B;;;;;;;;;;;OAWG;YACW,OAAO;IA6CrB;;;;;OAKG;YACW,QAAQ;IAsBhB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAK9B;CACF;AAED,mGAAmG;AACnG,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAG1D;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAU1D;AAwBD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,WAAW,GAAG,MAAM,CAUnD"}
@@ -3,13 +3,11 @@ import { basename, resolve as resolvePath } from 'node:path';
3
3
  import { PassThrough } from 'node:stream';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { resolveAccessDeniedPage, } from '../src/dashboard/access_denied_page.js';
6
- import { performLogin, performSession, readSession, SESSION_COOKIE_NAME, sanitizeReturnTo, } from '../src/dashboard/auth.js';
7
6
  import { compat, enumerateLiveFleet, mergeFleets, } from '../src/dashboard/compat.js';
8
- import { isProduction, normalizeAuthorizeResult, resolveConfig, } from '../src/dashboard/define_config.js';
7
+ import { NO_AUTHORIZE_WARNING, resolveConfig, } from '../src/dashboard/define_config.js';
9
8
  import { BlockedDiagnosticsRecorder } from '../src/dashboard/diagnostics-recorder.js';
10
9
  import { dashboardEngineForRole } from '../src/dashboard/gateway-adapter.js';
11
10
  import { bulkAction, cancelRun, completeTaskRun, continueRun, deliverWebhook, failTaskRun, getFanMembers, getRun, getRunEvent, health, listRuns, listSchedules, metricsText, redispatchPendingRun, republishLifecycleRun, retryRun, retryWithInputRun, runValues, scheduleAction, signalRun, topology, updateRun, workers, workflowLifecycle, } from '../src/dashboard/handlers.js';
12
- import { renderLoginPage } from '../src/dashboard/login_page.js';
13
11
  import { openApiDocument } from '../src/dashboard/openapi.js';
14
12
  import { contentTypeFor, renderIndexHtml } from '../src/dashboard/spa.js';
15
13
  import { WorkflowEngine } from '../src/index.js';
@@ -66,32 +64,7 @@ export default class DashboardProvider {
66
64
  constructor(app) {
67
65
  this.app = app;
68
66
  }
69
- /** Warn once so a throwing `login`/`session` hook doesn't spam the logs on every failed attempt. */
70
- warnedOnHookThrow = false;
71
67
  warnedOnAuthorizeThrow = false;
72
- /** Login-attempt timestamps per client address — a simple sliding window (10/min) so a brute
73
- * force can't hammer the host's `login` hook at line rate. In-memory per instance: a cluster
74
- * multiplies the budget by pod count, which still reduces the attack by orders of magnitude. */
75
- loginAttempts = new Map();
76
- /** True (and reply 429) when this address exhausted its login window. */
77
- loginRateLimited(ctx) {
78
- const WINDOW_MS = 60_000;
79
- const MAX_ATTEMPTS = 10;
80
- const key = ctx.request.ip() || 'unknown';
81
- const now = Date.now();
82
- const recent = (this.loginAttempts.get(key) ?? []).filter((t) => now - t < WINDOW_MS);
83
- if (recent.length >= MAX_ATTEMPTS) {
84
- this.loginAttempts.set(key, recent);
85
- ctx.response.status(429).json({ error: 'too many login attempts — retry in a minute' });
86
- return true;
87
- }
88
- recent.push(now);
89
- this.loginAttempts.set(key, recent);
90
- // Bounded: prune other addresses opportunistically so the map can't grow without limit.
91
- if (this.loginAttempts.size > 10_000)
92
- this.loginAttempts.clear();
93
- return false;
94
- }
95
68
  /** SSE connections currently open, per client address (and in total) — see streamRun. */
96
69
  sseByAddress = new Map();
97
70
  sseTotal = 0;
@@ -110,9 +83,11 @@ export default class DashboardProvider {
110
83
  return this.app.config.get('durable', {}).role ?? 'standalone';
111
84
  }
112
85
  async boot() {
113
- const config = resolveConfig(this.app.config.get('durable_dashboard', {}));
86
+ const config = resolveConfig(this.app.config.get('durable_dashboard', {}), { inProduction: this.app.inProduction });
114
87
  if (!config.enabled)
115
88
  return;
89
+ if (!config.authorizeConfigured)
90
+ await this.warnNoAuthorize();
116
91
  const role = this.durableRole();
117
92
  // Route registration can't happen synchronously in `boot()` — see the long-standing note this
118
93
  // provider always carried: the router singleton isn't committed until the app's "booted" hooks
@@ -153,12 +128,6 @@ export default class DashboardProvider {
153
128
  ...(config.redact ? { redact: config.redact } : {}),
154
129
  ...(this.metricsCollector ? { metrics: this.metricsCollector } : {}),
155
130
  });
156
- // Built-in `dashboardAuth` (Mode A `session` and/or Mode B `login`, opt-in). Registered ONLY when
157
- // configured, so omitting `dashboardAuth` leaves route registration byte-for-byte as it was. These
158
- // endpoints are public (behind NEITHER guard): they MINT the session the guard checks for.
159
- if (config.dashboardAuth) {
160
- this.registerAuthRoutes(router, config, config.dashboardAuth);
161
- }
162
131
  this.registerSpaRoutes(router, config, apiBase);
163
132
  // JSON API.
164
133
  const json = (handler) => {
@@ -424,110 +393,12 @@ export default class DashboardProvider {
424
393
  return null;
425
394
  }
426
395
  }
427
- /**
428
- * Mount the built-in `dashboardAuth` endpoints under `basePath`. All are public (no guard): they
429
- * create/destroy the session the {@link enforce} guard checks for.
430
- *
431
- * - `GET <base>/login` -> the server-rendered login page (Mode B only).
432
- * - `POST <base>/login` -> verifies credentials via the host `login` hook and mints the cookie.
433
- * - `POST <base>/session` -> Mode A: verifies the HOST APP's own auth (off the raw request) via the
434
- * `session` hook and mints the cookie. No body — the hook reads whatever it needs off `request`.
435
- * `204` on success, uniform `401` on denial. Mirrors `@dudousxd/nestjs-durable-dashboard`'s
436
- * `DurableAuthController.session` and `client/console-session.ts`'s `mintDurableConsoleSession`.
437
- * - `GET <base>/logout` -> clears the cookie and redirects to the login page (Mode B) or the
438
- * dashboard root (Mode A only). A plain `GET` (idempotent, only ever destroys the caller's own
439
- * session) so a simple `<a href>` works.
440
- */
441
- registerAuthRoutes(router, config, auth) {
442
- const loginPath = `${config.path}/login`;
443
- const sessionPath = `${config.path}/session`;
444
- const logoutPath = `${config.path}/logout`;
445
- if (auth.login) {
446
- router
447
- .get(loginPath, async (ctx) => {
448
- ctx.response.header('content-type', 'text/html; charset=utf-8');
449
- ctx.response.header('cache-control', 'no-store, must-revalidate');
450
- const qs = ctx.request.qs();
451
- const nonce = cspNonce(ctx);
452
- return ctx.response.send(renderLoginPage(config.path, {
453
- ...(nonce !== undefined ? { nonce } : {}),
454
- error: qs.error !== undefined,
455
- returnTo: qs.returnTo,
456
- }));
457
- })
458
- .as('durable_dashboard.login.page');
459
- // Two callers: the page's own `fetch` (JSON in, JSON out — `{ redirectTo }` / `{ error }`)
460
- // and, with JavaScript off or its inline script dropped by a CSP, the same page as a classic
461
- // form post (form-encoded in, a redirect out: to `returnTo` on success, back to the login
462
- // page with `?error` on failure). Same hook, same cookie, same uniform failure either way.
463
- router
464
- .post(loginPath, async (ctx) => {
465
- if (this.loginRateLimited(ctx))
466
- return;
467
- const body = ctx.request.body();
468
- const outcome = await performLogin(auth, body, config.path);
469
- const form = isFormPost(ctx);
470
- const backToLogin = () => ctx.response
471
- .redirect()
472
- .withQs({
473
- error: '1',
474
- returnTo: sanitizeReturnTo(body?.returnTo, config.path || '/'),
475
- })
476
- .toPath(loginPath);
477
- if (outcome.kind === 'bad-request') {
478
- if (form)
479
- return backToLogin();
480
- return ctx.response.status(400).json({ error: outcome.message });
481
- }
482
- if (outcome.kind === 'unauthorized') {
483
- void this.warnHookThrow(outcome.hookError).catch(() => undefined);
484
- if (form)
485
- return backToLogin();
486
- return ctx.response.status(401).json({ error: outcome.message });
487
- }
488
- this.writeSessionCookie(ctx, auth, outcome.cookieValue);
489
- if (form)
490
- return ctx.response.redirect().toPath(outcome.redirectTo);
491
- return ctx.response.status(200).json({ redirectTo: outcome.redirectTo });
492
- })
493
- .as('durable_dashboard.login.submit');
494
- }
495
- if (auth.session) {
496
- router
497
- .post(sessionPath, async (ctx) => {
498
- if (this.loginRateLimited(ctx))
499
- return;
500
- const outcome = await performSession(auth, ctx.request.request);
501
- if (outcome.kind === 'unauthorized') {
502
- void this.warnHookThrow(outcome.hookError).catch(() => undefined);
503
- return ctx.response.status(401).json({ error: 'unauthorized' });
504
- }
505
- this.writeSessionCookie(ctx, auth, outcome.cookieValue);
506
- return ctx.response.status(204).send('');
507
- })
508
- .as('durable_dashboard.session.mint');
509
- }
510
- const logout = async (ctx) => {
511
- ctx.response.clearCookie(SESSION_COOKIE_NAME, { path: '/' });
512
- return ctx.response.redirect().toPath(auth.login ? loginPath : config.path || '/');
513
- };
514
- router.get(logoutPath, logout).as('durable_dashboard.logout');
515
- // POST too: the CSRF-proof spelling for clients that prefer it (the GET stays for plain <a>
516
- // links — it only ever destroys the caller's own session, so a forced logout is an annoyance,
517
- // not a compromise).
518
- router.post(logoutPath, logout).as('durable_dashboard.logout.post');
519
- }
520
396
  /** Record one mutating console action (see DashboardAuditEntry). Never throws into the request. */
521
397
  audit(config, ctx) {
522
398
  try {
523
- const auth = config.dashboardAuth;
524
- const session = auth ? readSession(auth, this.readSessionCookie(ctx)) : null;
525
- const fromToken = ctx.request.header('authorization') !== undefined ||
526
- ctx.request.header('x-durable-token') !== undefined;
527
- const actor = session?.name ?? session?.sub ?? (fromToken ? 'token' : 'anonymous');
528
399
  const entry = {
529
400
  at: new Date(),
530
- actor,
401
+ actor: auditActor(ctx),
531
402
  method: ctx.request.method().toUpperCase(),
532
403
  path: ctx.request.url(false),
533
404
  };
@@ -567,95 +438,66 @@ export default class DashboardProvider {
567
438
  const logger = await this.app.container.make('logger');
568
439
  logger.warn(`[durable_dashboard] authorize hook threw; treating as denial (403). ${message}`);
569
440
  }
570
- async warnHookThrow(hookError) {
571
- if (hookError === undefined || this.warnedOnHookThrow)
572
- return;
573
- this.warnedOnHookThrow = true;
574
- const message = hookError instanceof Error ? hookError.message : String(hookError);
575
- const logger = await this.app.container.make('logger');
576
- logger.warn(`dashboardAuth login/session hook threw; treating as denial. ${message}`);
441
+ /** The once-per-boot warning for a console with no `authorize` (open in dev, 403 in production). */
442
+ async warnNoAuthorize() {
443
+ try {
444
+ const logger = await this.app.container.make('logger');
445
+ logger.warn(NO_AUTHORIZE_WARNING);
446
+ }
447
+ catch {
448
+ console.warn(NO_AUTHORIZE_WARNING);
449
+ }
577
450
  }
578
451
  /**
579
- * Run the guards for a dashboard resource. Composes the existing `authorize` hook (bearer
580
- * token/custom) with the optional `dashboardAuth` session guard — BOTH must pass:
452
+ * Run `authorize` for a dashboard resource. On a denial:
581
453
  *
582
- * 1. `authorize` denies -> the built-in (or host-customised) access-denied PAGE for a `page`
583
- * request, JSON for an `api` request. `reason: 'unauthenticated'` is a `401`
584
- * (`{ error: 'unauthorized' }`); `false`, `{ allowed: false }`, `reason: 'forbidden'` or a
585
- * throwing hook is a `403` (`{ error: 'forbidden' }`). A hook that already answered the request
586
- * (a redirect, a body) keeps its answer.
587
- * 2. `dashboardAuth` configured AND no valid session -> for a `page` request, redirect `302` to
588
- * the login page (Mode B) carrying a sanitized `returnTo`, or the `401` "open this console
589
- * from your app" page (Mode A only, no login page to redirect to); for an `api` request, a
590
- * plain `401 { error: 'unauthorized', auth: { modes } }` — the `modes` list lets the SPA's
591
- * client (`durable-client.ts`) decide whether to offer the login page.
454
+ * - `{ redirectTo }`: a `page` request is redirected there (`302`); an `api` request gets
455
+ * `401 { error: 'unauthenticated', redirectTo }`;
456
+ * - `reason: 'unauthenticated'`: `401` (the access-denied page, or `{ error: 'unauthenticated' }`);
457
+ * - anything else (`false`, `{ allowed: false }`, a throw): `403` (the access-denied page, or
458
+ * `{ error: 'forbidden' }`).
592
459
  *
593
- * When `dashboardAuth` is unconfigured only step 1 runs.
460
+ * A hook that already answered the request (a redirect, a body) keeps its answer.
594
461
  * Returns `false` (and has already written the response) when the request must short-circuit.
595
462
  */
596
463
  async enforce(config, ctx, mode) {
597
- // CSRF defense-in-depth over the Lax session cookie: a browser-originated CROSS-SITE mutating
598
- // request is rejected outright, whatever guard would have passed it. The mutating endpoints
599
- // need no body and read flags from the query string, so without this a malicious page could
600
- // fire them as SIMPLE cross-origin POSTs (no preflight) against a console the operator's
601
- // browser is authenticated to. Non-browser clients send neither header and pass.
464
+ // CSRF defense-in-depth: a browser-originated CROSS-SITE mutating request is rejected outright,
465
+ // whatever `authorize` would say. The mutating endpoints need no body and read flags from the
466
+ // query string, so without this a malicious page could fire them as SIMPLE cross-origin POSTs
467
+ // (no preflight) against a console the operator's browser is signed in to. Non-browser clients
468
+ // send neither header and pass.
602
469
  if (mode === 'api' && isMutatingMethod(ctx) && crossSiteRequest(ctx)) {
603
470
  ctx.response.status(403).json({ error: 'cross-site request rejected' });
604
471
  return false;
605
472
  }
606
- // Never test the raw result: a decision OBJECT is truthy, so `{ allowed: false }` would pass a
607
- // `!result` check. A throwing hook is a denial (403), never a 500 and never a pass.
608
- let decision;
609
- try {
610
- decision = normalizeAuthorizeResult(await config.authorize(ctx));
611
- }
612
- catch (error) {
613
- void this.warnAuthorizeThrow(error).catch(() => undefined);
614
- decision = { allowed: false, reason: 'forbidden' };
615
- }
616
- if (!decision.allowed) {
617
- const unauthenticated = decision.reason === 'unauthenticated';
618
- if (mode === 'page') {
619
- await this.denyPage(ctx, config, unauthenticated
620
- ? { status: 401, reason: 'unauthenticated' }
621
- : { status: 403, reason: 'forbidden' });
622
- }
623
- else if (!responseAnswered(ctx)) {
624
- ctx.response
625
- .status(unauthenticated ? 401 : 403)
626
- .json({ error: unauthenticated ? 'unauthorized' : 'forbidden' });
627
- }
628
- return false;
629
- }
630
- const auth = config.dashboardAuth;
631
- if (!auth)
632
- return true;
633
- const session = readSession(auth, this.readSessionCookie(ctx));
634
- if (session) {
635
- // Role convention: a session WITHOUT roles keeps full access (back-compat — most host hooks
636
- // never set them); a session WITH roles must carry `operator` (or `admin`) to mutate.
637
- // Previously every authenticated principal — including a read-only support login — could
638
- // bulk-cancel with compensation or fix-and-replay.
639
- if (mode === 'api' && isMutatingMethod(ctx) && !sessionCanMutate(session)) {
640
- ctx.response
641
- .status(403)
642
- .json({ error: "this session's roles do not include 'operator' (or 'admin')" });
643
- return false;
644
- }
473
+ // The resolved gate never throws: a throwing `authorize` comes back as a 403 carrying the error.
474
+ const decision = await config.authorize(ctx);
475
+ if (decision.allowed)
645
476
  return true;
646
- }
477
+ if (decision.error !== undefined)
478
+ void this.warnAuthorizeThrow(decision.error).catch(() => undefined);
479
+ const unauthenticated = decision.reason === 'unauthenticated';
647
480
  if (mode === 'page') {
648
- if (auth.login) {
649
- const returnTo = ctx.request.url(true);
650
- ctx.response.redirect().withQs('returnTo', returnTo).toPath(`${config.path}/login`);
481
+ if (decision.redirectTo !== undefined && !responseAnswered(ctx)) {
482
+ ctx.response.redirect().toPath(decision.redirectTo);
651
483
  return false;
652
484
  }
653
- // Mode A only: there's no login page to send the browser to — this deployment expects the
654
- // host app to mint a session via `POST <path>/session` before ever navigating here.
655
- await this.denyPage(ctx, config, { status: 401, reason: 'session-required' });
485
+ await this.denyPage(ctx, config, unauthenticated
486
+ ? { status: 401, reason: 'unauthenticated' }
487
+ : { status: 403, reason: 'forbidden' });
656
488
  return false;
657
489
  }
658
- ctx.response.status(401).json({ error: 'unauthorized', auth: { modes: auth.modes } });
490
+ if (responseAnswered(ctx))
491
+ return false;
492
+ if (!unauthenticated) {
493
+ ctx.response.status(403).json({ error: 'forbidden' });
494
+ }
495
+ else if (decision.redirectTo !== undefined) {
496
+ ctx.response.status(401).json({ error: 'unauthenticated', redirectTo: decision.redirectTo });
497
+ }
498
+ else {
499
+ ctx.response.status(401).json({ error: 'unauthenticated' });
500
+ }
659
501
  return false;
660
502
  }
661
503
  /**
@@ -672,7 +514,6 @@ export default class DashboardProvider {
672
514
  const info = {
673
515
  ...denial,
674
516
  basePath: config.path,
675
- ...(config.dashboardAuth?.login ? { loginHref: `${config.path}/login` } : {}),
676
517
  ...(nonce !== undefined ? { nonce } : {}),
677
518
  };
678
519
  const html = await resolveAccessDeniedPage(info, config.accessDenied, ctx, answered);
@@ -684,22 +525,6 @@ export default class DashboardProvider {
684
525
  .header('cache-control', 'no-store, must-revalidate')
685
526
  .send(html);
686
527
  }
687
- readSessionCookie(ctx) {
688
- const value = ctx.request.plainCookie(SESSION_COOKIE_NAME, undefined, false);
689
- return typeof value === 'string' && value !== '' ? value : undefined;
690
- }
691
- writeSessionCookie(ctx, auth, value) {
692
- ctx.response.plainCookie(SESSION_COOKIE_NAME, value, {
693
- httpOnly: true,
694
- sameSite: 'lax',
695
- // Forced on in production even when `request.secure()` reads false (a TLS-terminating proxy
696
- // without AdonisJS trustProxy) — exactly the deployments that most need the Secure flag.
697
- secure: isProduction() || ctx.request.secure(),
698
- path: '/',
699
- maxAge: Math.floor(auth.ttlMs / 1000),
700
- encode: false,
701
- });
702
- }
703
528
  async shutdown() {
704
529
  this.detachDiagnostics?.();
705
530
  this.detachDiagnostics = null;
@@ -707,12 +532,6 @@ export default class DashboardProvider {
707
532
  this.metricsCollector = null;
708
533
  }
709
534
  }
710
- /**
711
- * Whether something already answered this request: a redirect (`location` header — the signal the
712
- * `authorize` contract has always honoured) or a body queued on the response. The body check reads
713
- * AdonisJS's `response.hasLazyBody` structurally so a plain-object `ctx` double in a unit test
714
- * (which has neither) still works.
715
- */
716
535
  /** Whether this request can change state (everything but GET/HEAD/OPTIONS). Exported for tests. */
717
536
  export function isMutatingMethod(ctx) {
718
537
  const method = ctx.request.method().toUpperCase();
@@ -739,15 +558,12 @@ export function crossSiteRequest(ctx) {
739
558
  return true; // an unparseable Origin is not a browser being honest — reject
740
559
  }
741
560
  }
742
- /** Role convention (see enforce): no roles = full access; with roles, mutating needs operator/admin. */
743
- export function sessionCanMutate(session) {
744
- if (session.roles.length === 0)
745
- return true;
746
- return session.roles.some((r) => {
747
- const role = r.toLowerCase();
748
- return role === 'operator' || role === 'admin';
749
- });
750
- }
561
+ /**
562
+ * Whether something already answered this request: a redirect (`location` header — the signal the
563
+ * `authorize` contract has always honoured) or a body queued on the response. The body check reads
564
+ * AdonisJS's `response.hasLazyBody` structurally so a plain-object `ctx` double in a unit test
565
+ * (which has neither) still works.
566
+ */
751
567
  function responseAnswered(ctx) {
752
568
  if (ctx.response.getHeader('location'))
753
569
  return true;
@@ -764,13 +580,22 @@ function cspNonce(ctx) {
764
580
  return typeof nonce === 'string' && nonce !== '' ? nonce : undefined;
765
581
  }
766
582
  /**
767
- * Whether a login `POST` came from a classic HTML form submit (form-encoded — JavaScript off, or
768
- * the page's inline script dropped by a CSP) rather than the page's own JSON `fetch`. Decides
769
- * whether the reply is a redirect (a browser navigating) or JSON (a script awaiting it).
583
+ * Who a mutating console action is attributed to: the signed-in user (`ctx.auth.user`'s `email`,
584
+ * `name` or `id`, read structurally — this package does not depend on `@adonisjs/auth`), `'bearer'`
585
+ * for a caller with an `Authorization` header and no user, else `'anonymous'`.
770
586
  */
771
- function isFormPost(ctx) {
772
- const type = ctx.request.header('content-type') ?? '';
773
- return type.includes('application/x-www-form-urlencoded') || type.includes('multipart/form-data');
587
+ export function auditActor(ctx) {
588
+ const user = ctx.auth?.user;
589
+ if (user) {
590
+ for (const key of ['email', 'name', 'id']) {
591
+ const value = user[key];
592
+ if (typeof value === 'string' && value !== '')
593
+ return value;
594
+ if (typeof value === 'number')
595
+ return String(value);
596
+ }
597
+ }
598
+ return ctx.request.header('authorization') !== undefined ? 'bearer' : 'anonymous';
774
599
  }
775
600
  /** Adapt an AdonisJS `HttpContext` to the framework-light {@link ApiRequest}. */
776
601
  function toApiRequest(ctx) {