@adonis-agora/durable 1.0.0-rc.0 → 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.
- package/CHANGELOG.md +122 -0
- package/dist/assets/spa/assets/index-B2kIfzsc.css +1 -0
- package/dist/assets/spa/assets/index-Bgf_8IJ0.js +16 -0
- package/dist/assets/spa/assets/index-Bgf_8IJ0.js.map +1 -0
- package/dist/assets/spa/index.html +2 -2
- package/dist/commands/runs.d.ts.map +1 -1
- package/dist/commands/runs.js +4 -2
- package/dist/commands/runs.js.map +1 -1
- package/dist/providers/dashboard_provider.d.ts +15 -49
- package/dist/providers/dashboard_provider.d.ts.map +1 -1
- package/dist/providers/dashboard_provider.js +69 -244
- package/dist/providers/dashboard_provider.js.map +1 -1
- package/dist/providers/durable_provider.d.ts.map +1 -1
- package/dist/providers/durable_provider.js +11 -3
- package/dist/providers/durable_provider.js.map +1 -1
- package/dist/src/commands/runs.js +1 -1
- package/dist/src/commands/runs.js.map +1 -1
- package/dist/src/concurrency.d.ts.map +1 -1
- package/dist/src/concurrency.js +1 -0
- package/dist/src/concurrency.js.map +1 -1
- package/dist/src/control-flow-signal.d.ts +5 -4
- package/dist/src/control-flow-signal.d.ts.map +1 -1
- package/dist/src/control-flow-signal.js +5 -4
- package/dist/src/control-flow-signal.js.map +1 -1
- package/dist/src/dashboard/access_denied_page.d.ts +5 -7
- package/dist/src/dashboard/access_denied_page.d.ts.map +1 -1
- package/dist/src/dashboard/access_denied_page.js +2 -6
- package/dist/src/dashboard/access_denied_page.js.map +1 -1
- package/dist/src/dashboard/console_authorize.d.ts +76 -0
- package/dist/src/dashboard/console_authorize.d.ts.map +1 -0
- package/dist/src/dashboard/console_authorize.js +101 -0
- package/dist/src/dashboard/console_authorize.js.map +1 -0
- package/dist/src/dashboard/define_config.d.ts +44 -81
- package/dist/src/dashboard/define_config.d.ts.map +1 -1
- package/dist/src/dashboard/define_config.js +16 -112
- package/dist/src/dashboard/define_config.js.map +1 -1
- package/dist/src/dashboard/handlers.d.ts.map +1 -1
- package/dist/src/dashboard/handlers.js +6 -3
- package/dist/src/dashboard/handlers.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +5 -8
- package/dist/src/dashboard/index.d.ts.map +1 -1
- package/dist/src/dashboard/index.js +4 -6
- package/dist/src/dashboard/index.js.map +1 -1
- package/dist/src/dashboard/openapi.d.ts.map +1 -1
- package/dist/src/dashboard/openapi.js +11 -1
- package/dist/src/dashboard/openapi.js.map +1 -1
- package/dist/src/dashboard/run-filter.d.ts.map +1 -1
- package/dist/src/dashboard/run-filter.js +1 -0
- package/dist/src/dashboard/run-filter.js.map +1 -1
- package/dist/src/define_config.d.ts +6 -0
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/diagnostics/index.d.ts +11 -0
- package/dist/src/diagnostics/index.d.ts.map +1 -0
- package/dist/src/diagnostics/index.js +6 -0
- package/dist/src/diagnostics/index.js.map +1 -0
- package/dist/src/diagnostics/transport.d.ts +71 -0
- package/dist/src/diagnostics/transport.d.ts.map +1 -0
- package/dist/src/diagnostics/transport.js +188 -0
- package/dist/src/diagnostics/transport.js.map +1 -0
- package/dist/src/diagnostics-bridge.d.ts +2 -1
- package/dist/src/diagnostics-bridge.d.ts.map +1 -1
- package/dist/src/diagnostics-bridge.js +7 -1
- package/dist/src/diagnostics-bridge.js.map +1 -1
- package/dist/src/engine.d.ts +30 -8
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +198 -72
- package/dist/src/engine.js.map +1 -1
- package/dist/src/errors.d.ts +9 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +13 -0
- package/dist/src/errors.js.map +1 -1
- package/dist/src/event-trigger-bridge.d.ts +5 -14
- package/dist/src/event-trigger-bridge.d.ts.map +1 -1
- package/dist/src/event-trigger-bridge.js +11 -99
- package/dist/src/event-trigger-bridge.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/interfaces.d.ts +22 -6
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/interfaces.js +8 -0
- package/dist/src/interfaces.js.map +1 -1
- package/dist/src/origin.js +3 -3
- package/dist/src/scheduler.d.ts.map +1 -1
- package/dist/src/scheduler.js +2 -1
- package/dist/src/scheduler.js.map +1 -1
- package/dist/src/singleton-admission.d.ts.map +1 -1
- package/dist/src/singleton-admission.js +2 -0
- package/dist/src/singleton-admission.js.map +1 -1
- package/dist/src/singleton-gate.d.ts +1 -1
- package/dist/src/singleton-gate.d.ts.map +1 -1
- package/dist/src/singleton-gate.js +4 -3
- package/dist/src/singleton-gate.js.map +1 -1
- package/dist/src/stores/lucid-pg-hot-path.d.ts +6 -2
- package/dist/src/stores/lucid-pg-hot-path.d.ts.map +1 -1
- package/dist/src/stores/lucid-pg-hot-path.js +7 -3
- package/dist/src/stores/lucid-pg-hot-path.js.map +1 -1
- package/dist/src/stores/lucid.d.ts +15 -2
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +191 -49
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/testing/in-memory-state-store.js +3 -3
- package/dist/src/testing/in-memory-state-store.js.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.js +13 -0
- package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
- package/dist/src/testing-kit/transport-conformance.d.ts.map +1 -1
- package/dist/src/testing-kit/transport-conformance.js +5 -1
- package/dist/src/testing-kit/transport-conformance.js.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.js +13 -2
- package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
- package/dist/src/transports/db.d.ts.map +1 -1
- package/dist/src/transports/db.js +42 -3
- package/dist/src/transports/db.js.map +1 -1
- package/dist/src/transports/queue.d.ts.map +1 -1
- package/dist/src/transports/queue.js +44 -3
- package/dist/src/transports/queue.js.map +1 -1
- package/dist/src/workflow-discovery.d.ts +1 -1
- package/dist/src/workflow-discovery.d.ts.map +1 -1
- package/dist/src/workflow-discovery.js +4 -7
- package/dist/src/workflow-discovery.js.map +1 -1
- package/dist/src/workflow-events.d.ts +20 -51
- package/dist/src/workflow-events.d.ts.map +1 -1
- package/dist/src/workflow-events.js +12 -30
- package/dist/src/workflow-events.js.map +1 -1
- package/dist/src/workflow-ref.d.ts +1 -1
- package/dist/src/workflow-turn.d.ts.map +1 -1
- package/dist/src/workflow-turn.js +18 -1
- package/dist/src/workflow-turn.js.map +1 -1
- package/dist/stubs/config/durable_dashboard.stub +9 -29
- package/package.json +12 -2
- package/skills/durable-cluster/SKILL.md +8 -4
- package/skills/durable-reliability/SKILL.md +2 -2
- package/skills/durable-setup/SKILL.md +3 -1
- package/dist/assets/spa/assets/index-70hGzOnT.css +0 -1
- package/dist/assets/spa/assets/index-CJjMAUau.js +0 -16
- package/dist/assets/spa/assets/index-CJjMAUau.js.map +0 -1
- package/dist/src/dashboard/auth.d.ts +0 -134
- package/dist/src/dashboard/auth.d.ts.map +0 -1
- package/dist/src/dashboard/auth.js +0 -156
- package/dist/src/dashboard/auth.js.map +0 -1
- package/dist/src/dashboard/login_page.d.ts +0 -36
- package/dist/src/dashboard/login_page.d.ts.map +0 -1
- package/dist/src/dashboard/login_page.js +0 -158
- package/dist/src/dashboard/login_page.js.map +0 -1
- package/dist/src/dashboard/session_cookie.d.ts +0 -45
- package/dist/src/dashboard/session_cookie.d.ts.map +0 -1
- package/dist/src/dashboard/session_cookie.js +0 -97
- 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-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/__DURABLE_DASHBOARD__/assets/index-
|
|
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,
|
|
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"}
|
package/dist/commands/runs.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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;
|
|
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 {
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
|
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
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
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
|
-
*
|
|
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
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
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
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
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 (
|
|
649
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
485
|
+
await this.denyPage(ctx, config, unauthenticated
|
|
486
|
+
? { status: 401, reason: 'unauthenticated' }
|
|
487
|
+
: { status: 403, reason: 'forbidden' });
|
|
656
488
|
return false;
|
|
657
489
|
}
|
|
658
|
-
|
|
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
|
-
/**
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
*
|
|
768
|
-
*
|
|
769
|
-
*
|
|
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
|
|
772
|
-
const
|
|
773
|
-
|
|
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) {
|