-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathapprovals-plugin.ts
More file actions
316 lines (300 loc) · 14.3 KB
/
Copy pathapprovals-plugin.ts
File metadata and controls
316 lines (300 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import type { Plugin, PluginContext } from '@objectstack/core';
import { SysApprovalRequest } from './sys-approval-request.object.js';
import { SysApprovalAction } from './sys-approval-action.object.js';
import { SysApprovalApprover } from './sys-approval-approver.object.js';
import { SysApprovalToken } from './sys-approval-token.object.js';
import { SysApprovalDelegation } from './sys-approval-delegation.object.js';
import { renderConfirmPage, renderResultPage } from './action-link-pages.js';
import {
ApprovalService,
ESCALATION_JOB_NAME,
ESCALATION_SCAN_INTERVAL_MS,
type ApprovalEngine,
} from './approval-service.js';
import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
import { registerApprovalNode, type ApprovalAutomationSurface } from './approval-node.js';
export interface ApprovalsPluginOptions {
/** Disable runtime registration (schemas still register). */
disableService?: boolean;
/**
* Interval between SLA escalation scans (ADR-0042). Defaults to
* {@link ESCALATION_SCAN_INTERVAL_MS} (5 min). Only takes effect when a
* `job` service is installed; without one, SLA stays display-only.
*/
escalationScanIntervalMs?: number;
/**
* Absolute origin for actionable links in outbound notifications
* (ADR-0043), e.g. `https://app.example.com`. Relative by default.
*/
publicBaseUrl?: string;
/**
* Disable the record-lock hook. Schema + service stay intact; only the
* engine-level lock wiring is suppressed. Useful when a caller wants the
* manual API only (e.g. tests).
*/
disableAutoHooks?: boolean;
}
/**
* ApprovalsServicePlugin — registers sys_approval_{request,action}, the
* `approvals` service, the `approval` flow node executor (ADR-0019), and the
* record-lock hook.
*
* ADR-0019: approval is no longer a standalone process engine. A flow's
* Approval node opens a request and suspends the run; a decision via the
* service resumes it down the matching branch.
*/
export class ApprovalsServicePlugin implements Plugin {
name = 'com.objectstack.service.approvals';
version = '1.0.0';
type = 'standard';
dependencies = ['com.objectstack.engine.objectql'];
private readonly options: ApprovalsPluginOptions;
private service?: ApprovalService;
private engine?: any;
private escalationJobScheduled = false;
constructor(options: ApprovalsPluginOptions = {}) {
this.options = options;
}
async init(ctx: PluginContext): Promise<void> {
ctx.getService<{ register(m: any): void }>('manifest').register({
id: 'com.objectstack.service.approvals',
name: 'Approvals Service',
version: '1.0.0',
type: 'plugin',
scope: 'system',
defaultDatasource: 'cloud',
namespace: 'sys',
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken, SysApprovalDelegation],
// ADR-0029 D7 — contribute the Approvals entries into the Setup app's
// `group_approvals` slot. This plugin owns these objects (K2.b), so it
// ships their menu too; when the plugin isn't installed the slot is empty.
navigationContributions: [
{
app: 'setup',
group: 'group_approvals',
priority: 100,
items: [
{ id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' },
{ id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' },
{ id: 'nav_approval_delegations', type: 'object', label: 'Delegations (OOO)', objectName: 'sys_approval_delegation', icon: 'user-clock', requiresObject: 'sys_approval_delegation' },
],
},
],
});
// ADR-0029 D8 — contribute this plugin's object translations to the i18n
// service on kernel:ready (the i18n plugin may register after this one).
if (typeof (ctx as any).hook === 'function') {
(ctx as any).hook('kernel:ready', async () => {
try {
const i18n = ctx.getService<any>('i18n');
if (i18n && typeof i18n.loadTranslations === 'function') {
const { ApprovalsTranslations } = await import('./translations/index.js');
for (const [locale, data] of Object.entries(ApprovalsTranslations)) {
i18n.loadTranslations(locale, data as Record<string, unknown>);
}
}
} catch { /* i18n optional */ }
});
}
ctx.logger.info('ApprovalsServicePlugin: schemas registered');
}
async start(ctx: PluginContext): Promise<void> {
if (this.options.disableService) return;
let engine: any = null;
try { engine = ctx.getService<any>('objectql'); }
catch { try { engine = ctx.getService<any>('data'); } catch { /* ignore */ } }
if (!engine) {
ctx.logger.warn('ApprovalsServicePlugin: no ObjectQL engine — service NOT registered');
return;
}
this.engine = engine;
this.service = new ApprovalService({
engine: engine as ApprovalEngine,
logger: ctx.logger,
publicBaseUrl: this.options.publicBaseUrl,
// [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
// capability. Read LAZILY (not captured at start) because the tenancy
// service resolves its posture during its own start, which may not have
// run yet; an unresolvable posture reads as "unknown" and the guard
// stands down rather than refusing a legitimate flow on a minimal stack.
tenancyPosture: () => {
try {
const tenancy = ctx.getService<{ posture?: string }>('tenancy');
const posture = tenancy?.posture;
return typeof posture === 'string' && posture ? posture : undefined;
} catch {
return undefined;
}
},
});
// Record lock: block edits to a record while it has a pending request.
// Delegation write-guard: a self-service OOO delegation may only name the
// acting user as delegator (#1322 follow-up). Both bind under the same
// package id, so unbindAllHooks clears them together.
if (!this.options.disableAutoHooks) {
try {
unbindAllHooks(engine);
bindApprovalLockHook(engine, ctx.logger);
bindDelegationWriteGuard(engine, ctx.logger);
} catch (err: any) {
ctx.logger.warn?.('[approvals] failed to bind approval hooks', { error: err?.message });
}
}
ctx.registerService('approvals', this.service);
ctx.logger.info('ApprovalsServicePlugin: service registered');
// Optional messaging service (ADR-0012): thread interactions (reassign /
// remind / request-info / comment) notify users when present; without it
// they degrade to audit-only.
try {
const messaging = ctx.getService<any>('messaging');
if (messaging && typeof messaging.emit === 'function') {
this.service.attachMessaging(messaging);
}
} catch { /* messaging not installed */ }
// SLA escalation clock (ADR-0042): a plugin-internal job, deliberately
// NOT a flow trigger (ADR-0041 §1). Interval sweep + one catch-up scan at
// boot so a restart doesn't extend a breach by a scan period. Wired on
// kernel:ready — the job service may start after this plugin. No `job`
// service → SLA stays display-only.
const wireEscalationClock = async () => {
try {
const jobs = ctx.getService<any>('job');
if (!jobs || typeof jobs.schedule !== 'function' || !this.service) return;
const svc = this.service;
const intervalMs = this.options.escalationScanIntervalMs ?? ESCALATION_SCAN_INTERVAL_MS;
// Both sweeps ride this one clock: they walk the same `pending` set, and
// the dead-run release (#3456) is reconciliation with the same "catch up
// after a restart" requirement — a run killed BY the restart is exactly
// the shape no in-band handler can clean up.
// Genuinely independent — an escalation failure must not strand locked
// records, and vice versa, so neither can short-circuit the other.
const sweep = async () => {
const results = await Promise.allSettled([
svc.runEscalations(),
svc.releaseDeadRunRequests(),
// #4469 — the other half of the dead-run picture, and the one no
// sweeper could see: a request already TERMINAL whose run is gone.
// Read-only by design (it reports; it never rewrites a decision
// that really happened), so it rides the same clock purely to make
// the finding surface without an operator knowing to go looking.
svc.inspectStrandedRequests(),
]);
for (const r of results) {
if (r.status === 'rejected') {
ctx.logger.warn?.('[approvals] periodic sweep leg failed', {
error: (r.reason as any)?.message ?? String(r.reason),
});
}
}
};
await jobs.schedule(ESCALATION_JOB_NAME, { type: 'interval', intervalMs }, sweep);
this.escalationJobScheduled = true;
void sweep().catch((err: any) => {
ctx.logger.warn?.('[approvals] boot sweep failed', { error: err?.message });
});
ctx.logger.info('ApprovalsServicePlugin: SLA escalation scan scheduled', { intervalMs });
} catch { /* job service not installed */ }
};
// Actionable-link pages (ADR-0043): session-less confirm + redemption,
// mounted straight on the host Hono app. GET only renders; the decision
// happens exclusively on the POST (mail-gateway prefetch safe).
const mountActionPages = async () => {
try {
const http = ctx.getService<any>('http-server');
const rawApp = http && typeof http.getRawApp === 'function' ? http.getRawApp() : null;
if (!rawApp || !this.service) return;
const svc = this.service;
const ACT_PATH = '/api/v1/approvals/act';
const html = (c: any, body: string, status = 200) =>
c.body(body, status, { 'Content-Type': 'text/html; charset=utf-8' });
rawApp.get(ACT_PATH, async (c: any) => {
const token = String(c.req.query('token') ?? '');
const peek = await svc.peekActionToken(token);
if (!peek.ok) return html(c, renderResultPage(peek.reason, peek.request), 200);
return html(c, renderConfirmPage({
request: peek.request, action: peek.action, approverId: peek.approverId,
token, actPath: ACT_PATH,
}));
});
rawApp.post(ACT_PATH, async (c: any) => {
let token = '';
try {
const body = await c.req.parseBody();
token = String(body?.token ?? '');
} catch { /* fall through to invalid */ }
const out = await svc.redeemActionToken(token);
if (!out.ok) return html(c, renderResultPage(out.reason, out.request), 200);
return html(c, renderResultPage(out.action === 'approve' ? 'approved' : 'rejected', out.request));
});
ctx.logger.info(`ApprovalsServicePlugin: actionable-link pages mounted at ${ACT_PATH}`);
} catch { /* http server not installed */ }
};
// Pending-approver index backfill (issue #1745): rebuild the normalized
// sys_approval_approver rows from the pending_approvers CSV so requests
// written before the index existed (or drifted past a crashed sync) are
// queryable. Idempotent; cost tracks the live pending queue.
const backfillApproverIndex = async () => {
try {
const svc = this.service;
if (!svc) return;
const out = await svc.rebuildApproverIndex();
if (out.inserted > 0 || out.deleted > 0) {
ctx.logger.info('ApprovalsServicePlugin: approver index rebuilt', out);
}
} catch (err: any) {
ctx.logger.warn?.('[approvals] approver index backfill failed', { error: err?.message });
}
};
if (typeof (ctx as any).hook === 'function') {
(ctx as any).hook('kernel:ready', wireEscalationClock);
(ctx as any).hook('kernel:ready', mountActionPages);
(ctx as any).hook('kernel:ready', backfillApproverIndex);
} else {
await wireEscalationClock();
await mountActionPages();
await backfillApproverIndex();
}
// ADR-0019: contribute the `approval` node to the flow engine when one is
// present. The node lets a flow suspend on an approval and resume on
// decision; the service is wired to the same engine so `decide()` can
// resume the suspended run.
//
// #4771 — the degradation must be LOUD (#4632). This used to be one
// try/catch logging at `info`, and dev's default log level is `warn`: the
// one line that says "every `approval` node in this deployment is dead"
// was invisible in exactly the deployment where it is true, while the flow
// registration was warning about `approval` in the deployments where it is
// false. The catch is also narrowed to the service *lookup*, so a genuine
// failure inside registerApprovalNode surfaces as itself instead of being
// relabelled "no automation engine".
let automation: ApprovalAutomationSurface | undefined;
try {
automation = ctx.getService<ApprovalAutomationSurface>('automation');
} catch {
automation = undefined; // no automation service registered in this stack
}
if (automation && typeof automation.registerNodeExecutor === 'function') {
this.service.attachAutomation(automation);
registerApprovalNode(automation, this.service, ctx.logger);
} else {
ctx.logger.warn(
'ApprovalsServicePlugin: no automation engine — the `approval` flow node is NOT registered. '
+ 'Every ADR-0019 approval flow in this deployment fails at execution time with NO_EXECUTOR. '
+ 'Add @objectstack/service-automation to the stack to enable them.',
);
}
}
async stop(ctx: PluginContext): Promise<void> {
if (this.escalationJobScheduled) {
try {
const jobs = ctx.getService<any>('job');
await jobs?.cancel?.(ESCALATION_JOB_NAME);
} catch { /* ignore */ }
this.escalationJobScheduled = false;
}
if (this.engine) {
try { unbindAllHooks(this.engine); } catch { /* ignore */ }
}
}
}