@substrat-run/connector-planima 0.2.0 → 0.2.1

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/dist/index.js ADDED
@@ -0,0 +1,646 @@
1
+ import { z } from 'zod';
2
+ import { connectionActivity, connectionId as connectionIdSchema, instant, scopeId as scopeIdSchema, tenantId as tenantIdSchema, } from '@substrat-run/contracts';
3
+ import { PlanimaApi, PlanimaApiError, PLANIMA_API_BASE, planimaSecret } from './api.js';
4
+ import { actionFactIn, buildingFact, componentFact, facilityFact, planimaActionFact, planimaBuildingFact, planimaComponentFact, planimaFacilityFact, } from './plan.js';
5
+ export { PlanimaApi, PlanimaApiError, PLANIMA_API_BASE, PLANIMA_ACCEPT, PLANIMA_MAX_PAGE, PLANIMA_ACTION_STATUSES, planimaSecret, } from './api.js';
6
+ export { decimalOf, planimaActionFact, planimaBuildingFact, planimaComponentFact, planimaDecimal, planimaFacilityFact, } from './plan.js';
7
+ export { PlanimaMock } from './mock.js';
8
+ /**
9
+ * The Planima connector — the INBOUND half of maintenance-plan integration.
10
+ *
11
+ * [Planima](https://planima.se/) is a Swedish web application for planned facility
12
+ * maintenance (sv. *underhållsplan*). A plan is a tree — organization → facility →
13
+ * building → component — with **actions** hanging off it: dated, priced, categorized
14
+ * work a property owner intends to do in a given year.
15
+ *
16
+ * ## Poll-only, and that is a design fact rather than an omission
17
+ *
18
+ * Like `connector-fortnox` and unlike `connector-scrive`, this connector registers no
19
+ * event handler at all, because nothing inside a scope initiates the work. A vertical
20
+ * does not *ask* for next year's maintenance plan the way it asks for a signature; the
21
+ * plan changes in Planima — a surveyor walks a roof and moves an action from 2031 to
22
+ * 2027 — and the platform finds out by looking.
23
+ *
24
+ * So there is no `registerPlanimaConnector`. {@link sweepPlanimaPlan} is the whole
25
+ * trigger surface, and a deployment binds it into the platform sweeper exactly as it
26
+ * binds Fortnox's.
27
+ *
28
+ * ## Read-only, and the credential is why
29
+ *
30
+ * Planima's API can create and update organizations, facilities, buildings and
31
+ * components. This connector uses none of it. A Planima token carries the full access
32
+ * of the user who minted it — there are no scopes to narrow — so the narrowing that
33
+ * matters is operational: hand this connector a **read-only user's token** and the
34
+ * blast radius of a leaked credential is a maintenance plan someone could already see.
35
+ * A write path would forfeit that, and no vertical has asked for one; when one does,
36
+ * it belongs behind an event and a dispatch, in the shape `connector-scrive` already
37
+ * has.
38
+ *
39
+ * ## Where the data lands, and why the connector does not decide
40
+ *
41
+ * A sweep has no delivered event, so it has neither a scope to write to nor authority
42
+ * to write with. That is declared **once**, explicitly, by {@link bindPlanimaScope}:
43
+ * which scope, which operation to land the plan through, and which permission that
44
+ * operation checks.
45
+ *
46
+ * The operation is the *consumer's*, and that is deliberate. What comes out of Planima
47
+ * is neutral fact — a component, a year, a price, a status string. What a business
48
+ * *means* by them (which status counts as committed spend, which category rolls into
49
+ * which budget line, whether a deferred action still books) is vocabulary, and
50
+ * vocabulary is the vertical's layer.
51
+ */
52
+ /**
53
+ * The standing grants this connector requires — deliberately EMPTY, with a mechanism
54
+ * in place of a declaration.
55
+ *
56
+ * The reasoning is `FORTNOX_CONNECTION_GRANTS`'s, and it applies here for the same
57
+ * reason: the permission this connector needs is whatever the *consumer's* landing
58
+ * operation checks, which differs per vertical and is unknown at this package's build
59
+ * time. So the check moves from build time to bind time — {@link bindPlanimaScope}
60
+ * verifies the connection actually holds the named permission in the named scope and
61
+ * **refuses the binding otherwise**, naming what is missing. A sweep can therefore
62
+ * never be configured into a state where it fetches a whole maintenance plan and
63
+ * cannot write it down.
64
+ */
65
+ export const PLANIMA_CONNECTION_GRANTS = [];
66
+ /** The connector-state key prefix every binding lives under — what the sweep enumerates. */
67
+ const BINDING_PREFIX = 'planima:binding:';
68
+ const bindingKey = (scope) => `${BINDING_PREFIX}${scope}`;
69
+ /**
70
+ * The currency a Planima plan's prices are in — a DECLARED fact, because the API does
71
+ * not carry one.
72
+ *
73
+ * Planima sends `unit_price: 1200` and nothing else: no currency field on the action,
74
+ * the facility, the organization or the account. The product is Swedish and its prices
75
+ * are kronor, so `SEK` is the right default — but it is a default this connector chose,
76
+ * not a value it read, and Substrat money is a `{ amount, currency }` pair that cannot
77
+ * be built without one. Naming it on the binding is what keeps that choice visible and
78
+ * overridable instead of hard-coded three files down.
79
+ */
80
+ export const PLANIMA_DEFAULT_CURRENCY = 'SEK';
81
+ /**
82
+ * How many years past the current one a sweep reads when a binding names no window.
83
+ *
84
+ * A Swedish maintenance plan is conventionally drawn 30 years out, and almost nothing
85
+ * consumes all of it: the far years are placeholders that move every time a surveyor
86
+ * revisits. Ten years is the horizon a budget actually uses, and — because the window
87
+ * is part of the sync's identity — a shorter one also means the far-future churn does
88
+ * not make every sweep look like a change.
89
+ */
90
+ export const PLANIMA_DEFAULT_HORIZON_YEARS = 10;
91
+ /** How many action rows ride one `invoke`. */
92
+ const PAGE_SIZE = 500;
93
+ /**
94
+ * One page of a maintenance plan, as it crosses into a scope.
95
+ *
96
+ * Parsed with this schema on the way OUT, before every `invoke`. The engine-seam rule
97
+ * (`returns()`) exists because a value crossing a version boundary must be pinned to a
98
+ * published shape rather than to whatever the code currently produces, and a connector
99
+ * seam is the same boundary with a network in the middle: a vertical compiled against
100
+ * one version of this package and running against another must get a throw, never a
101
+ * silently-reshaped plan on a screen.
102
+ *
103
+ * ## The paging shape, and how a consumer reads it
104
+ *
105
+ * Pages are global across one sync and each page names exactly one facility. A
106
+ * facility's buildings and components ride its **first** page only (`facilityHead`) —
107
+ * they are the same on every page of that facility, and repeating a component list
108
+ * across ten pages of actions is bytes through a clone pipe for nothing. So a consumer
109
+ * upserts on `facilityHead`, appends actions on every page, and commits or swaps when
110
+ * `final` arrives.
111
+ */
112
+ export const planimaPlanPage = z.object({
113
+ /**
114
+ * Identifies this sync RUN, and it is the content hash rather than a ULID on purpose:
115
+ * two syncs of an unchanged plan produce the same `syncId`, so a consumer's upsert is
116
+ * naturally idempotent and a redelivered page cannot double a cost.
117
+ */
118
+ syncId: z.string().min(1),
119
+ connectionId: z.string().min(1),
120
+ /** The organization the facility belongs to, when Planima nested one on it. */
121
+ organization: z.object({ id: z.number().int(), name: z.string() }).nullable(),
122
+ /**
123
+ * The facility this page carries — or `null` on a CLEAR page.
124
+ *
125
+ * A sync that finds no facilities at all still has something to say, and saying
126
+ * nothing is the one answer that corrupts a consumer: it commits or swaps on `final`,
127
+ * so a pass that lands zero pages leaves last month's facilities and actions in place
128
+ * for ever, while the cursor records the empty plan as synced and no later sweep
129
+ * repairs it. So an empty plan lands exactly one page — `facility: null`,
130
+ * `actions: []`, `final: true` — which reads as "this plan is now empty" rather than
131
+ * as silence.
132
+ */
133
+ facility: planimaFacilityFact.nullable(),
134
+ /** The year range the actions on this page were read through — inclusive both ends. */
135
+ window: z.object({ fromYear: z.number().int(), toYear: z.number().int() }),
136
+ /** The currency every `Money` on this page is denominated in; declared, not read — see the binding. */
137
+ currency: z.string(),
138
+ page: z.number().int().nonnegative(),
139
+ pageCount: z.number().int().positive(),
140
+ /** True on the last page of the whole sync — the signal a consumer commits or swaps on. */
141
+ final: z.boolean(),
142
+ /** True on this facility's FIRST page, where its buildings and components ride. */
143
+ facilityHead: z.boolean(),
144
+ buildings: z.array(planimaBuildingFact).default([]),
145
+ components: z.array(planimaComponentFact).default([]),
146
+ actions: z.array(planimaActionFact),
147
+ });
148
+ /**
149
+ * Declare that a connection should sync one scope — the one-time setup a poll-only
150
+ * connector needs in place of a dispatch.
151
+ *
152
+ * **Refuses a binding whose grant is missing**, which is the whole reason this is a
153
+ * function rather than a config object. The alternative — write the binding, discover
154
+ * at sweep time that the connection cannot invoke the operation — fails in the worst
155
+ * possible place: after a whole maintenance plan has been fetched against a
156
+ * 10-request-per-10-seconds budget, in a background timer nobody is watching. Here it
157
+ * fails in the operator's hands, naming the permission to grant.
158
+ */
159
+ export async function bindPlanimaScope(host, input) {
160
+ const tenant = tenantIdSchema.parse(input.tenantId);
161
+ const scope = scopeIdSchema.parse(input.scopeId);
162
+ if (input.window && input.window.toYear < input.window.fromYear) {
163
+ throw new Error(`window ${input.window.fromYear}..${input.window.toYear} ends before it starts — ` +
164
+ `Planima would return nothing and the sync would look like an empty plan`);
165
+ }
166
+ const horizonYears = input.horizonYears ?? PLANIMA_DEFAULT_HORIZON_YEARS;
167
+ if (!Number.isInteger(horizonYears) || horizonYears < 0) {
168
+ throw new Error(`horizonYears must be a non-negative integer, got ${String(input.horizonYears)}`);
169
+ }
170
+ // On the host, not `admin`: this is the same projection the permission checker reads,
171
+ // so a binding is verified against the tuples that will actually gate the invoke —
172
+ // not against a directory row that may not have reached the scope yet.
173
+ const granted = await host.connectionGrantsInScope(tenant, scope);
174
+ const held = granted.some((g) => g.connectionId === input.connectionId && g.permission === input.permission);
175
+ if (!held) {
176
+ throw new Error(`connection ${input.connectionId} does not hold '${input.permission}' on scope ${input.scopeId} — ` +
177
+ `a sweep would fetch the maintenance plan and then fail to land it. Grant it first ` +
178
+ `(grantToConnection), then bind.`);
179
+ }
180
+ const binding = {
181
+ scopeId: input.scopeId,
182
+ tenantId: input.tenantId,
183
+ vertical: input.vertical,
184
+ operation: input.operation,
185
+ permission: input.permission,
186
+ organizationId: input.organizationId ?? null,
187
+ currency: input.currency ?? PLANIMA_DEFAULT_CURRENCY,
188
+ window: input.window ?? null,
189
+ horizonYears,
190
+ boundAt: new Date(input.now?.() ?? Date.now()).toISOString(),
191
+ };
192
+ await host.admin.putConnectorState(input.connectionId, bindingKey(input.scopeId), binding);
193
+ return binding;
194
+ }
195
+ /** Every scope this connection syncs into. */
196
+ export async function listPlanimaBindings(host, connectionId) {
197
+ const rows = await host.admin.listConnectorState(connectionId, BINDING_PREFIX);
198
+ // Tombstones filtered, exactly as the sweep and the activity projection do.
199
+ // `unbindPlanimaScope` writes `null` under the same key, and casting that to
200
+ // `PlanimaBinding` hands a caller a typed value that throws on the first property
201
+ // read — a lie the type system cannot catch.
202
+ return rows
203
+ .filter((r) => r.value !== null && typeof r.value === 'object')
204
+ .map((r) => r.value);
205
+ }
206
+ /**
207
+ * Stop syncing one scope. The binding row is replaced with a tombstone rather than
208
+ * removed, because `putConnectorState` is the only verb this surface has — and an
209
+ * unbound scope that a later sweep silently re-adopts would be worse than a visible
210
+ * dead row.
211
+ */
212
+ export async function unbindPlanimaScope(host, connectionId, scopeId) {
213
+ await host.admin.putConnectorState(connectionId, bindingKey(scopeId), null);
214
+ }
215
+ const sha256Hex = async (text) => {
216
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
217
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
218
+ };
219
+ /** The window a binding reads through, resolved against the clock for a rolling one. */
220
+ export function windowFor(binding, nowMs) {
221
+ if (binding.window)
222
+ return binding.window;
223
+ const fromYear = new Date(nowMs).getUTCFullYear();
224
+ return { fromYear, toYear: fromYear + binding.horizonYears };
225
+ }
226
+ /**
227
+ * Sync ONE bound scope: read the plan, hash it, and land it through the binding's
228
+ * operation as the connection itself (#97).
229
+ *
230
+ * Idempotent and cheap to re-land on a no-op. The assembled plan is hashed before
231
+ * anything is landed, and an unchanged hash returns `changed: false` without a single
232
+ * `invoke` — which matters because a sweep runs on a timer and most passes find a plan
233
+ * nobody has touched.
234
+ *
235
+ * Note what that does NOT save: the provider reads still happen, because Planima offers
236
+ * no collection-level `updated_at` or ETag to ask "has anything changed" cheaply. The
237
+ * skip saves the writes and the events, not the round trips. Being straight about that
238
+ * is what makes the sweep interval a real decision — every pass costs
239
+ * `1 + 3 × facilities` requests against a 10-per-10-second budget.
240
+ */
241
+ export async function syncPlanimaScope(host, connectionId, binding, options) {
242
+ const admin = host.admin;
243
+ const conn = await openPlanimaConnection(admin, options.fetch, binding.tenantId, binding.vertical, options.timeoutMs ?? 30_000,
244
+ // The binding's own connection, or nothing. See `openPlanimaConnection`.
245
+ connectionId);
246
+ const api = new PlanimaApi(conn, {
247
+ apiBase: options.apiBase,
248
+ now: options.now,
249
+ sleep: options.sleep,
250
+ rateWindow: options.rateWindow,
251
+ });
252
+ const nowMs = options.now?.() ?? Date.now();
253
+ const window = options.window ?? windowFor(binding, nowMs);
254
+ const currency = binding.currency || PLANIMA_DEFAULT_CURRENCY;
255
+ const facilities = await api.facilities(binding.organizationId ?? undefined);
256
+ // Sorted by id before anything else touches them. Planima documents no ordering, so
257
+ // two sweeps could legitimately return the same facilities in a different order — and
258
+ // an unsorted hash would then read as a changed plan and re-land the whole thing.
259
+ // The same reasoning applies to every list below.
260
+ facilities.sort((a, b) => a.id - b.id);
261
+ const plans = [];
262
+ for (const facility of facilities) {
263
+ // Sequential, not `Promise.all`: the client's rate-limit window is a per-instance
264
+ // list, so three concurrent requests would each see an empty window and fire at
265
+ // once. Against a 10-per-10-second budget that is how a sweep of a handful of
266
+ // facilities starts eating 429s.
267
+ const buildings = await api.buildings(facility.id);
268
+ const components = await api.components(facility.id);
269
+ const actions = await api.actions(facility.id, window);
270
+ buildings.sort((a, b) => a.id - b.id);
271
+ components.sort((a, b) => a.id - b.id);
272
+ actions.sort((a, b) => a.id - b.id);
273
+ plans.push({
274
+ facility: facilityFact(facility),
275
+ organization: facility.organization
276
+ ? { id: facility.organization.id, name: facility.organization.name }
277
+ : null,
278
+ buildings: buildings.map(buildingFact),
279
+ components: components.map(componentFact),
280
+ actions: actions.map((a) => actionFactIn(a, facility.id, currency)),
281
+ });
282
+ }
283
+ // The WINDOW and the CURRENCY are part of the sync's identity, not just the rows.
284
+ //
285
+ // The window because what lands is the plan filtered to it (Fortnox's lesson, and
286
+ // sharper here because a rolling window moves by itself every January). The currency
287
+ // because it is a declared value rather than a read one: re-binding a scope from SEK
288
+ // to EUR changes every `Money` that lands while leaving every provider row identical,
289
+ // and a hash over the rows alone would call that no change at all.
290
+ const contentHash = await sha256Hex(JSON.stringify({ window, currency, organizationId: binding.organizationId, plans }));
291
+ const actionCount = plans.reduce((n, p) => n + p.actions.length, 0);
292
+ if (binding.lastSync?.contentHash === contentHash) {
293
+ return {
294
+ scopeId: binding.scopeId,
295
+ changed: false,
296
+ syncId: contentHash,
297
+ window,
298
+ facilities: plans.length,
299
+ actions: actionCount,
300
+ pages: 0,
301
+ };
302
+ }
303
+ // Every page of every facility, counted BEFORE the first invoke, because `pageCount`
304
+ // and `final` are on page 0 and a consumer swapping a plan atomically needs to know
305
+ // from the first page how many are coming.
306
+ //
307
+ // Floored at one: a plan with no facilities still lands a single CLEAR page. Landing
308
+ // nothing would leave a consumer holding the previous sync's rows for ever, because
309
+ // it swaps on `final` and no `final` would ever arrive — while the cursor below
310
+ // recorded the empty plan as synced, so no later sweep would repair it either.
311
+ const pageCount = Math.max(1, plans.reduce((n, p) => n + Math.max(1, Math.ceil(p.actions.length / PAGE_SIZE)), 0));
312
+ // The connection acting as itself (#97). Refuses a scope in another tenant or running
313
+ // another vertical by construction, and the invoke below is gated on the connection's
314
+ // own grant — the one `bindPlanimaScope` verified.
315
+ const scope = await host.getConnectorScope(connectionId, scopeIdSchema.parse(binding.scopeId));
316
+ if (plans.length === 0) {
317
+ // "This plan is now empty", said explicitly. Every facility deleted, an
318
+ // organization filter that matches nothing, a token whose access was narrowed —
319
+ // all of them arrive here, and all of them are a fact the consumer needs.
320
+ await scope.invoke(binding.operation, planimaPlanPage.parse({
321
+ syncId: contentHash,
322
+ connectionId,
323
+ organization: null,
324
+ facility: null,
325
+ window,
326
+ currency,
327
+ page: 0,
328
+ pageCount: 1,
329
+ final: true,
330
+ facilityHead: false,
331
+ buildings: [],
332
+ components: [],
333
+ actions: [],
334
+ }));
335
+ }
336
+ let page = 0;
337
+ for (const plan of plans) {
338
+ const facilityPages = Math.max(1, Math.ceil(plan.actions.length / PAGE_SIZE));
339
+ for (let i = 0; i < facilityPages; i += 1) {
340
+ const payload = planimaPlanPage.parse({
341
+ syncId: contentHash,
342
+ connectionId,
343
+ organization: plan.organization,
344
+ facility: plan.facility,
345
+ window,
346
+ currency,
347
+ page,
348
+ pageCount,
349
+ final: page === pageCount - 1,
350
+ facilityHead: i === 0,
351
+ buildings: i === 0 ? plan.buildings : [],
352
+ components: i === 0 ? plan.components : [],
353
+ actions: plan.actions.slice(i * PAGE_SIZE, (i + 1) * PAGE_SIZE),
354
+ });
355
+ await scope.invoke(binding.operation, payload);
356
+ page += 1;
357
+ }
358
+ }
359
+ const synced = {
360
+ ...binding,
361
+ lastSync: {
362
+ syncedAt: new Date(nowMs).toISOString(),
363
+ contentHash,
364
+ facilities: plans.length,
365
+ actions: actionCount,
366
+ },
367
+ };
368
+ // Written only AFTER every page landed, so a failure mid-way leaves the cursor at the
369
+ // previous hash and the next sweep retries the whole plan rather than resuming into a
370
+ // half-written one.
371
+ await admin.putConnectorState(connectionId, bindingKey(binding.scopeId), synced);
372
+ return {
373
+ scopeId: binding.scopeId,
374
+ changed: true,
375
+ syncId: contentHash,
376
+ window,
377
+ facilities: plans.length,
378
+ actions: actionCount,
379
+ pages: pageCount,
380
+ };
381
+ }
382
+ /**
383
+ * Poll Planima for every scope this connection is bound to — the sweeper a deployment
384
+ * schedules.
385
+ *
386
+ * A timer calls this; it holds no timer itself. That keeps the trigger a deployment
387
+ * concern (`startPlatformSweeper` on node, `definePlatformSweeperDO`'s alarm on
388
+ * Cloudflare) and this a plain, testable function.
389
+ *
390
+ * Robust the way a poller must be: an unchanged plan is skipped without landing
391
+ * anything, and a failure on one scope is recorded and stepped over rather than sinking
392
+ * the pass — one vertical's missing grant must not stop another tenant's plan syncing.
393
+ */
394
+ export async function sweepPlanimaPlan(host, connectionId, options) {
395
+ const rows = await host.admin.listConnectorState(connectionId, BINDING_PREFIX);
396
+ const result = { found: 0, synced: [], unchanged: 0, failed: [] };
397
+ // ONE rate-limit window for the whole pass. Every binding under this connection
398
+ // shares its token, and Planima meters per token — so a per-scope window would let
399
+ // the second scope's first ten requests land on top of the first scope's ten.
400
+ const rateWindow = options.rateWindow ?? [];
401
+ for (const { value } of rows) {
402
+ // A tombstoned binding (`unbindPlanimaScope`) — present as a row, not a target.
403
+ if (value === null || typeof value !== 'object')
404
+ continue;
405
+ const binding = value;
406
+ result.found += 1;
407
+ try {
408
+ const r = await syncPlanimaScope(host, connectionId, binding, { ...options, rateWindow });
409
+ if (r.changed)
410
+ result.synced.push(r);
411
+ else
412
+ result.unchanged += 1;
413
+ }
414
+ catch (err) {
415
+ result.failed.push({
416
+ scopeId: binding.scopeId,
417
+ error: err instanceof Error ? err.message : String(err),
418
+ });
419
+ }
420
+ }
421
+ return result;
422
+ }
423
+ /**
424
+ * Probe a credential that is not stored yet — the connect-time check (#605).
425
+ *
426
+ * Takes the candidate secret directly, touches no connection and no store, and records
427
+ * no health: there may be no connection to record against, and a candidate's failure is
428
+ * not a fact about a live one.
429
+ *
430
+ * The probe reads `/organizations`, which is both the cheapest authenticated read
431
+ * Planima offers and the one that answers the question an operator actually has: not
432
+ * "is this token valid" but "does this token see the customer account I meant". A token
433
+ * from the wrong Planima login is perfectly valid and syncs somebody else's buildings.
434
+ */
435
+ export async function probePlanimaSecret(secret, options) {
436
+ const parsed = planimaSecret.safeParse(secret);
437
+ if (!parsed.success) {
438
+ // A malformed credential IS a refusal — Planima would reject it, and there is no
439
+ // point spending a round trip to hear so.
440
+ return {
441
+ ok: false,
442
+ refused: true,
443
+ accountRef: null,
444
+ accountLabel: null,
445
+ facts: [],
446
+ error: `incomplete Planima credential: ${parsed.error.issues
447
+ .map((i) => i.path.join('.'))
448
+ .join(', ')}`,
449
+ };
450
+ }
451
+ const timeoutMs = options.timeoutMs ?? 15_000;
452
+ const conn = {
453
+ id: connectionIdSchema.parse('00000000000000000000000000'), // no row yet; never read
454
+ tenantId: '',
455
+ vertical: '',
456
+ provider: 'planima',
457
+ secret: parsed.data,
458
+ expiresAt: null,
459
+ fetch: (input, init) => options.fetch(input, { ...init, signal: AbortSignal.timeout(timeoutMs) }),
460
+ };
461
+ return probeWith(conn, options);
462
+ }
463
+ /** Probe the credential a live connection already holds. Verifying is itself a use. */
464
+ export async function probePlanimaConnection(host, connection, options) {
465
+ const conn = await openPlanimaConnection(host.admin, options.fetch, connection.tenantId, connection.vertical, options.timeoutMs ?? 15_000);
466
+ return probeWith(conn, options);
467
+ }
468
+ async function probeWith(conn, options) {
469
+ const api = new PlanimaApi(conn, {
470
+ apiBase: options.apiBase,
471
+ now: options.now,
472
+ sleep: options.sleep,
473
+ // A probe answers a person waiting on a form. Sitting out a rate limit for tens of
474
+ // seconds to answer them is worse than saying "busy, try again" — and a 429 is not
475
+ // a fact about the credential, which is what a probe is for.
476
+ maxRateLimitRetries: 0,
477
+ });
478
+ try {
479
+ const organizations = await api.organizations();
480
+ return {
481
+ ok: true,
482
+ refused: false,
483
+ // Left null on purpose: a Planima token is one customer ACCOUNT's, and an account
484
+ // may hold several organizations, so no single id names what this credential
485
+ // reads. The dashboard catalog therefore declares no `accountRefField` and a
486
+ // re-paste rotates the one connection in place, which is the right behaviour when
487
+ // there is only ever one.
488
+ accountRef: null,
489
+ accountLabel: organizations.length === 1
490
+ ? (organizations[0]?.name ?? null)
491
+ : organizations.length === 0
492
+ ? null
493
+ : `${organizations.length} organizations`,
494
+ facts: [
495
+ { label: 'Organizations', value: String(organizations.length) },
496
+ {
497
+ label: 'Names',
498
+ value: organizations.length === 0
499
+ ? '— (the token is valid but sees nothing)'
500
+ : organizations
501
+ .slice(0, 5)
502
+ .map((o) => o.name)
503
+ .join(', ') + (organizations.length > 5 ? `, +${organizations.length - 5} more` : ''),
504
+ },
505
+ ],
506
+ error: null,
507
+ };
508
+ }
509
+ catch (err) {
510
+ return {
511
+ ok: false,
512
+ // Only "not with this token" counts. A timeout, a 429 or a 5xx says nothing about
513
+ // the credential, and treating it as a refusal would make a Planima outage look
514
+ // like every tenant's token going bad at once.
515
+ refused: err instanceof PlanimaApiError && err.refused,
516
+ accountRef: null,
517
+ accountLabel: null,
518
+ facts: [],
519
+ error: err instanceof Error ? err.message : String(err),
520
+ };
521
+ }
522
+ }
523
+ /**
524
+ * What this connection has been doing, for a console — one entry per bound scope.
525
+ *
526
+ * Reads the binding ledger rather than the provider: this answers "what has the platform
527
+ * synced", which is the question an operator asks when a plan looks stale, and it
528
+ * answers it without spending a provider round trip against a 10-per-10-second budget.
529
+ */
530
+ export async function planimaConnectionActivity(host, connectionId) {
531
+ const rows = await host.admin.listConnectorState(connectionId, BINDING_PREFIX);
532
+ const entries = rows
533
+ .filter(({ value }) => value !== null && typeof value === 'object')
534
+ .map(({ key, value }) => {
535
+ const b = value;
536
+ return {
537
+ key,
538
+ title: `${b.vertical} — ${b.scopeId}`,
539
+ reference: b.organizationId === null ? null : String(b.organizationId),
540
+ status: b.lastSync ? 'synced' : 'bound — not yet synced',
541
+ at: instant.parse(b.lastSync?.syncedAt ?? b.boundAt),
542
+ facts: [
543
+ { label: 'Lands through', value: b.operation },
544
+ { label: 'Permission', value: b.permission },
545
+ {
546
+ label: 'Organization',
547
+ value: b.organizationId === null ? 'all the token can see' : String(b.organizationId),
548
+ },
549
+ {
550
+ label: 'Window',
551
+ value: b.window
552
+ ? `${b.window.fromYear}–${b.window.toYear} (fixed)`
553
+ : `rolling, +${b.horizonYears} years`,
554
+ },
555
+ ...(b.lastSync
556
+ ? [
557
+ { label: 'Facilities', value: String(b.lastSync.facilities) },
558
+ { label: 'Actions', value: String(b.lastSync.actions) },
559
+ // The content hash IS the sync identity, so an operator comparing two
560
+ // scopes can tell "same plan" from "same moment" at a glance.
561
+ { label: 'Content hash', value: b.lastSync.contentHash.slice(0, 12) },
562
+ ]
563
+ : []),
564
+ ],
565
+ };
566
+ });
567
+ return connectionActivity.parse({
568
+ source: 'ledger',
569
+ entries,
570
+ // Never live: this reads the binding ledger, never the provider. The ledger knows
571
+ // what the platform synced, not what Planima has since changed, and a console that
572
+ // blurs the two invents facts.
573
+ live: false,
574
+ });
575
+ }
576
+ /**
577
+ * The stored credential, REDUCED (#605) — and for this provider that is one masked
578
+ * field and nothing else.
579
+ *
580
+ * There is no identifier half to show. Fortnox can display its client id and
581
+ * DatabaseNumber unmasked because they name the integration and the company; a Planima
582
+ * credential is a single opaque token and every character of it is secret. So this
583
+ * surface is honest rather than useful, and the useful answer — *which* Planima account
584
+ * this is — comes from {@link probePlanimaConnection}, which reads the organization
585
+ * names back from the provider.
586
+ */
587
+ export async function planimaCredentialSummary(host, connection) {
588
+ const open = await host.admin.openConnection(tenantIdSchema.parse(connection.tenantId), connection.vertical, 'planima');
589
+ if (!open) {
590
+ throw new Error(`no live 'planima' connection for tenant ${connection.tenantId} / vertical '${connection.vertical}'`);
591
+ }
592
+ const secret = planimaSecret.parse(open.secret);
593
+ return {
594
+ fields: [{ key: 'token', label: 'API token', value: maskSecret(secret.token), masked: true }],
595
+ };
596
+ }
597
+ /** A bullet run plus the last four — or nothing at all when there is too little to hide behind. */
598
+ const maskSecret = (value) => value.length < 8 ? '••••••••' : `••••••••${value.slice(-4)}`;
599
+ /** Open the live Planima connection for a (tenant, vertical), with health recorded. */
600
+ async function openPlanimaConnection(admin, fetchImpl, tenant, vertical, timeoutMs, expected) {
601
+ const parsedTenant = tenantIdSchema.parse(tenant);
602
+ const open = await admin.openConnection(parsedTenant, vertical, 'planima');
603
+ if (!open) {
604
+ throw new Error(`no live 'planima' connection for tenant ${tenant} / vertical '${vertical}'`);
605
+ }
606
+ // The credential that READS and the identity that WRITES must be the same connection.
607
+ //
608
+ // A binding names a `connectionId`, and that id is what opens the scope, stamps the
609
+ // spine and is checked for the grant. The token, though, comes from
610
+ // `openConnection(tenant, vertical, provider)` — whichever live row exists. Nothing in
611
+ // THIS file makes those the same row; two layers below it do, and both were checked
612
+ // rather than assumed: the directory holds a UNIQUE constraint that refuses a second
613
+ // live connection for one (tenant, vertical, provider, account), and revoking one
614
+ // takes its bindings with it. So a mismatch is currently unreachable.
615
+ //
616
+ // This is a backstop for the day that stops being true, not a fix for a live bug. It
617
+ // is here because the invariant is load-bearing and invisible: if Planima ever gains
618
+ // an `accountRefField` — one token per client company, the shape Fortnox already has —
619
+ // a (tenant, vertical) grows several live connections, `openConnection` starts
620
+ // choosing between them, and the sweep would silently read one company's plan with
621
+ // another's credential while the audit trail named a connection that fetched nothing.
622
+ // One comparison buys a loud refusal instead of that.
623
+ if (expected !== undefined && open.id !== expected) {
624
+ throw new Error(`binding names connection ${expected}, but the live 'planima' connection for tenant ` +
625
+ `${tenant} / vertical '${vertical}' is ${open.id} — the credential that reads and the ` +
626
+ `identity that writes must be the same connection. Re-bind the scope against ${open.id}.`);
627
+ }
628
+ return {
629
+ ...open,
630
+ fetch: async (input, init) => {
631
+ try {
632
+ const res = await fetchImpl(input, { ...init, signal: AbortSignal.timeout(timeoutMs) });
633
+ await admin.recordConnectionUse(open.id, res.ok ? { ok: true } : { ok: false, error: `HTTP ${res.status} from planima` });
634
+ return res;
635
+ }
636
+ catch (err) {
637
+ await admin.recordConnectionUse(open.id, {
638
+ ok: false,
639
+ error: err instanceof Error ? err.message : String(err),
640
+ });
641
+ throw err;
642
+ }
643
+ },
644
+ };
645
+ }
646
+ //# sourceMappingURL=index.js.map