@stigg/agent-sdk-js 0.3.2 → 0.5.0

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.
@@ -58,15 +58,31 @@ function agentDimension(context) {
58
58
  }
59
59
  return { [context.attributionKey]: context.agentId };
60
60
  }
61
+ /**
62
+ * A plan id as given, trimmed - or a refusal when it was given blank. Absent stays absent: no
63
+ * plan is a legal choice, while an empty one is a typo that would provision nobody onto nothing.
64
+ */
65
+ function planId(value) {
66
+ if (value === undefined)
67
+ return undefined;
68
+ const named = typeof value === 'string' ? value.trim() : '';
69
+ if (!named) {
70
+ throw new Error('stiggContext.defaultPlanId is empty - leave the field out to provision no plan, or name the ' +
71
+ 'plan new customers should start on.');
72
+ }
73
+ return named;
74
+ }
75
+ /**
76
+ * The one field a call cannot proceed without, refused the same way from every binding. Only where
77
+ * to put the customer differs, so the remedy is the caller's to word.
78
+ */
79
+ const noCustomer = (remedy = 'Give `wrap(client, { stiggContext: { customerId } })` a default for every call through ' +
80
+ 'this client, or name one per call in `stiggContext`.') => new Error(`No customerId - every governance call is scoped to a tenant. ${remedy}`);
81
+ exports.noCustomer = noCustomer;
61
82
  /**
62
83
  * Validates and derives, once. Everything expensive about a context happens here rather than on
63
84
  * the call path - see the note at the top of this file for why that split exists.
64
85
  */
65
- /** The one field a call cannot proceed without, said the same way wherever it is missing. */
66
- const noCustomer = () => new Error('No customerId - every governance call is scoped to a tenant. Give `wrap(client, ' +
67
- '{ stiggContext: { customerId } })` a default for every call through this client, or name ' +
68
- 'one per call in `stiggContext`.');
69
- exports.noCustomer = noCustomer;
70
86
  function resolveContext(context, correlationId,
71
87
  // `wrap` passes false: a client whose calls each name their own customer is legal, and its
72
88
  // types already say which calls still owe one. Every path that reaches the engine resolves it.
@@ -97,6 +113,7 @@ function resolveContext(context, correlationId,
97
113
  const dimensions = agent ? { ...context.dimensions, ...agent } : (context.dimensions ?? {});
98
114
  return {
99
115
  customerId,
116
+ defaultPlanId: planId(context.defaultPlanId),
100
117
  // Normalized, not passed through: `isEnforced` asks `!== undefined` while every other reader
101
118
  // asks for truthiness, so a JS caller's `entitlement: null` would be enforced by one and
102
119
  // absent to the rest - a call that runs the gate and then reports itself ungated.
@@ -120,6 +137,7 @@ function resolveContext(context, correlationId,
120
137
  */
121
138
  const CONTEXT_KEYS = {
122
139
  customerId: true,
140
+ defaultPlanId: true,
123
141
  agentId: true,
124
142
  attributionKey: true,
125
143
  dimensions: true,
@@ -223,6 +241,9 @@ function overrideContext(base, override) {
223
241
  }
224
242
  return {
225
243
  customerId,
244
+ // Independent of the customer: a call naming its own customer still provisions onto the
245
+ // client's plan unless it names a plan too.
246
+ defaultPlanId: override.defaultPlanId !== undefined ? planId(override.defaultPlanId) : base.defaultPlanId,
226
247
  entitlement,
227
248
  dimensions,
228
249
  checkedId,
@@ -27,6 +27,13 @@ export type HookContext = {
27
27
  correlationId: string;
28
28
  /** What the call gated on, when it gated on anything. Undefined on a metering-only run. */
29
29
  entitlementId?: string;
30
+ /**
31
+ * The agent that took the step, by the orchestrator's own name for it, when a middleware
32
+ * governs the run. Present at the customer level too, where nothing is attributed to an agent.
33
+ */
34
+ agent?: string;
35
+ /** The tool a step ran, when it was a paid tool a middleware checked. */
36
+ tool?: string;
30
37
  };
31
38
  export type SessionHooks = {
32
39
  /** Every check this client makes. The whole decision, already formatted as `.summary`. */
@@ -45,9 +52,20 @@ export type SessionDeps = {
45
52
  * than disappearing.
46
53
  */
47
54
  engine: () => Promise<GovernanceEngine>;
55
+ /**
56
+ * Creates the customer on their default plan if Stigg does not know them yet. Shared across
57
+ * every session of one SDK and remembered per customer, so only a customer's first call pays
58
+ * the round trip. Absent, nothing is ever provisioned.
59
+ */
60
+ provisionCustomer?: (customerId: string, planId: string) => Promise<void>;
48
61
  logger: Logger;
49
62
  onError: (error: EngineError, context: ErrorContext) => void;
50
63
  hooks: SessionHooks;
64
+ /**
65
+ * Whether each check and report is an OpenTelemetry span. On for an orchestrator's middleware,
66
+ * where an observability tool's trace is already open around the graph; off for `wrap`.
67
+ */
68
+ tracing?: boolean;
51
69
  };
52
70
  /** What a client did, for whoever wants to print it. Spans every context it served. */
53
71
  export type SessionSummary = {
@@ -175,12 +193,21 @@ export declare class GovernanceSession {
175
193
  private warnRounded;
176
194
  private warnIfNothingDrains;
177
195
  private record;
196
+ /** Every ingest result, however it ended: to its span, then to the application's hook. */
197
+ private emitIngest;
178
198
  /**
179
199
  * Hooks are instrumentation, so a throwing one must not reach the caller: it cannot turn an
180
200
  * allow into a denial, flip a delivered ingest to `ok: false`, or reject a promise the contract
181
201
  * says never rejects. The failure is reported like any other.
182
202
  */
183
203
  private hook;
204
+ /**
205
+ * Makes sure a customer with a default plan exists. Never throws, and answers whether Stigg was
206
+ * reached - false only for an outage, which is reported here and retried by the next call. A
207
+ * refusal is an answer: the SDK reports it once, and the call goes on to meet whatever the check
208
+ * says about a customer that does not exist.
209
+ */
210
+ private provisionCustomer;
184
211
  /**
185
212
  * The last stop for a failure. `onError` is the application's own code and can throw too, and
186
213
  * there is nowhere left to report that to, so it falls back to the logger.
@@ -29,6 +29,7 @@ const errors_1 = require("../errors");
29
29
  const headers_1 = require("../headers");
30
30
  const dimensions_1 = require("./dimensions");
31
31
  const hooks_1 = require("./hooks");
32
+ const telemetry_1 = require("./telemetry");
32
33
  const context_1 = require("./context");
33
34
  const types_1 = require("../types");
34
35
  /** What a context looks like to a hook: the attribution, and nothing a hook could change. */
@@ -37,6 +38,8 @@ const hookContext = (context) => ({
37
38
  dimensions: context.dimensions,
38
39
  correlationId: context.correlationId,
39
40
  entitlementId: context.checkedId,
41
+ ...(context.agent !== undefined ? { agent: context.agent } : {}),
42
+ ...(context.tool !== undefined ? { tool: context.tool } : {}),
40
43
  });
41
44
  /** A metric nobody can bill: refused where the mapper that invented it can still be found. */
42
45
  const emptyMetricNameError = () => new errors_1.EngineError('measure', 'a metric needs a name - an empty one reports to no feature at all.');
@@ -49,6 +52,8 @@ const emptyMetricNameError = () => new errors_1.EngineError('measure', 'a metric
49
52
  * `'async'` report would reach the event API with no name at all.
50
53
  */
51
54
  const DEFAULT_EVENT_NAME = 'agent.usage';
55
+ /** How both engines say a check found no such customer - the reason, verbatim, from the API. */
56
+ const CUSTOMER_NOT_FOUND = 'CustomerNotFound';
52
57
  class GovernanceSession {
53
58
  deps;
54
59
  pending = new Set();
@@ -93,6 +98,8 @@ class GovernanceSession {
93
98
  * applies the error policy.
94
99
  */
95
100
  async warmUp(context) {
101
+ // First, so the cache below loads a customer that exists rather than remembering one that did not.
102
+ await this.provisionCustomer(context);
96
103
  if (!(0, context_1.isEnforced)(context))
97
104
  return;
98
105
  try {
@@ -107,28 +114,40 @@ class GovernanceSession {
107
114
  * application routes on, not an exception it recovers from.
108
115
  */
109
116
  async check(context, requestedUsage = 1) {
117
+ const startedAt = Date.now();
110
118
  this.checks += 1;
111
119
  const { entitlement } = context;
112
120
  // Asked and answered, with nobody to ask: this context named no entitlement, so there is no
113
121
  // budget to consult and no refusal that could come back. Still counted and recorded, because
114
122
  // the caller did ask and `lastDecision` is where they read what they got.
115
123
  if (!entitlement)
116
- return this.record((0, decision_1.unenforcedDecision)(), context);
124
+ return this.record((0, decision_1.unenforcedDecision)(), context, startedAt);
117
125
  try {
118
126
  const engine = await this.deps.engine();
127
+ // Before the check, not after: a customer Stigg has never seen holds no entitlement, so their
128
+ // first call would be refused for not existing yet.
129
+ const reached = await this.provisionCustomer(context);
119
130
  const result = await engine.check(context.customerId, {
120
131
  entitlement,
121
132
  requestedUsage,
122
133
  dimensions: context.dimensions,
123
134
  });
124
- return this.record((0, decision_1.toDecision)(result, { customerId: context.customerId, entitlement: context.entitlement }), context);
135
+ // The policy answers only when both halves say so: Stigg could not be reached to create the
136
+ // customer, and the check then found no customer. Either alone is not that. An existing
137
+ // customer gets the real answer whatever happened to the create, and a customer Stigg
138
+ // refused - an unknown plan, an archived customer - is refused here too, rather than
139
+ // waved through by an `allow` that exists for outages, not for configuration mistakes.
140
+ if (!reached && result.accessDeniedReason === CUSTOMER_NOT_FOUND) {
141
+ return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context, startedAt);
142
+ }
143
+ return this.record((0, decision_1.toDecision)(result, { customerId: context.customerId, entitlement: context.entitlement }), context, startedAt);
125
144
  }
126
145
  catch (err) {
127
146
  const error = asEngineError('check', err);
128
147
  this.reportError(error, 'check', context);
129
148
  // Governance is fail-closed server-side; `'deny'` mirrors that, and `'allow'` - the default -
130
149
  // is the soft limit that keeps the application up while Stigg is unreachable.
131
- return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context);
150
+ return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context, startedAt);
132
151
  }
133
152
  }
134
153
  /** Applies the context's mapper to a token measurement. The path every binding uses. */
@@ -201,6 +220,7 @@ class GovernanceSession {
201
220
  send(context, metrics, sendOptions) {
202
221
  const { dimensions, idempotencyKey, raw, event } = sendOptions;
203
222
  const mode = context.ingestMode;
223
+ const startedAt = Date.now();
204
224
  // A broken value is one broken metric, not a broken call: the others measured fine and the
205
225
  // spend they describe already happened. Dropping only the bad one reports what is knowable.
206
226
  //
@@ -245,25 +265,29 @@ class GovernanceSession {
245
265
  // distinguishable from one that never tried, and reported to `onIngest` like every result.
246
266
  if (!Object.keys(sendable).length) {
247
267
  if (rejected)
248
- return Promise.resolve(this.failedIngest(context, idempotencyKey, rejected));
268
+ return Promise.resolve(this.failedIngest(context, idempotencyKey, rejected, startedAt));
249
269
  this.ingested += 1;
250
270
  const noop = { ok: true, reported: {}, idempotencyKey, mode, replayed: false };
251
- this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(noop, hookContext(context)));
271
+ this.emitIngest(context, noop, startedAt);
252
272
  return Promise.resolve(noop);
253
273
  }
254
274
  const promise = this.deps
255
275
  .engine()
256
- .then((engine) => engine.report(context.customerId, {
257
- metrics: sendable,
258
- entitlement: context.entitlement,
259
- mode,
260
- // Flattened here rather than in the engine, so both transports send the same event and a
261
- // sync report pays nothing for a field it will not use.
262
- raw: mode === 'async' ? (0, dimensions_1.flattenRaw)(raw) : undefined,
263
- dimensions,
264
- idempotencyKey,
265
- event: event ?? { name: DEFAULT_EVENT_NAME },
266
- }))
276
+ .then(async (engine) => {
277
+ // A metering-only client never checks, so this is the first place its customer is seen.
278
+ await this.provisionCustomer(context);
279
+ return engine.report(context.customerId, {
280
+ metrics: sendable,
281
+ entitlement: context.entitlement,
282
+ mode,
283
+ // Flattened here rather than in the engine, so both transports send the same event and a
284
+ // sync report pays nothing for a field it will not use.
285
+ raw: mode === 'async' ? (0, dimensions_1.flattenRaw)(raw) : undefined,
286
+ dimensions,
287
+ idempotencyKey,
288
+ event: event ?? { name: DEFAULT_EVENT_NAME },
289
+ });
290
+ })
267
291
  .then((result) => {
268
292
  this.ingested += 1;
269
293
  // A replay is a delivery that consumed nothing - the server had already counted these
@@ -292,10 +316,10 @@ class GovernanceSession {
292
316
  this.ingestFailures += 1;
293
317
  this.reportError(rejected, rejected.operation, context, idempotencyKey);
294
318
  }
295
- this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(landed, hookContext(context)));
319
+ this.emitIngest(context, landed, startedAt);
296
320
  return landed;
297
321
  })
298
- .catch((err) => this.failedIngest(context, idempotencyKey, asEngineError('ingest', err)))
322
+ .catch((err) => this.failedIngest(context, idempotencyKey, asEngineError('ingest', err), startedAt))
299
323
  .finally(() => {
300
324
  this.pending.delete(promise);
301
325
  });
@@ -384,11 +408,22 @@ class GovernanceSession {
384
408
  this.deps.logger.warn(`[stigg] reported ${names} but the response carried no credit balance - ${names.includes(',') ? 'these features are' : 'this feature is'} not mapped to ${checked}, so the budget this call checks ` +
385
409
  'against will never drain. Map it in Stigg, or report to the currency directly.');
386
410
  }
387
- record(decision, context) {
411
+ record(decision, context, startedAt) {
388
412
  this.decision = decision;
413
+ // Telemetry is instrumentation exactly as a hook is: it never reaches the caller.
414
+ if (this.deps.tracing) {
415
+ (0, hooks_1.safely)(() => (0, telemetry_1.traceCheck)(context, decision, startedAt), (err) => this.deps.logger.debug(`[stigg] check span failed: ${String(err)}`));
416
+ }
389
417
  this.hook('onDecision', context, () => this.deps.hooks.onDecision?.(decision, hookContext(context)));
390
418
  return decision;
391
419
  }
420
+ /** Every ingest result, however it ended: to its span, then to the application's hook. */
421
+ emitIngest(context, result, startedAt) {
422
+ if (this.deps.tracing) {
423
+ (0, hooks_1.safely)(() => (0, telemetry_1.traceIngest)(context, result, startedAt), (err) => this.deps.logger.debug(`[stigg] ingest span failed: ${String(err)}`));
424
+ }
425
+ this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(result, hookContext(context)));
426
+ }
392
427
  /**
393
428
  * Hooks are instrumentation, so a throwing one must not reach the caller: it cannot turn an
394
429
  * allow into a denial, flip a delivered ingest to `ok: false`, or reject a promise the contract
@@ -397,6 +432,24 @@ class GovernanceSession {
397
432
  hook(name, context, invoke) {
398
433
  (0, hooks_1.safely)(invoke, (err) => this.reportError(asEngineError(name, err), name, context));
399
434
  }
435
+ /**
436
+ * Makes sure a customer with a default plan exists. Never throws, and answers whether Stigg was
437
+ * reached - false only for an outage, which is reported here and retried by the next call. A
438
+ * refusal is an answer: the SDK reports it once, and the call goes on to meet whatever the check
439
+ * says about a customer that does not exist.
440
+ */
441
+ async provisionCustomer(context) {
442
+ if (!context.defaultPlanId || !context.customerId || !this.deps.provisionCustomer)
443
+ return true;
444
+ try {
445
+ await this.deps.provisionCustomer(context.customerId, context.defaultPlanId);
446
+ return true;
447
+ }
448
+ catch (err) {
449
+ this.reportError(asEngineError('provisionCustomer', err), 'provisionCustomer', context);
450
+ return false;
451
+ }
452
+ }
400
453
  /**
401
454
  * The last stop for a failure. `onError` is the application's own code and can throw too, and
402
455
  * there is nowhere left to report that to, so it falls back to the logger.
@@ -409,7 +462,7 @@ class GovernanceSession {
409
462
  customerId: context?.customerId,
410
463
  }), (err) => this.deps.logger.warn(`[stigg] onError threw while reporting ${error.message}: ${String(err)}`));
411
464
  }
412
- failedIngest(context, idempotencyKey, error) {
465
+ failedIngest(context, idempotencyKey, error, startedAt = Date.now()) {
413
466
  this.ingestFailures += 1;
414
467
  // The error knows what failed - reading a vendor's usage is not the same event as the report
415
468
  // that would have followed it, and a context that says otherwise makes the two
@@ -435,7 +488,7 @@ class GovernanceSession {
435
488
  replayed: error.replayed ?? false,
436
489
  error,
437
490
  };
438
- this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(failure, hookContext(context)));
491
+ this.emitIngest(context, failure, startedAt);
439
492
  return failure;
440
493
  }
441
494
  }
@@ -0,0 +1,8 @@
1
+ import type { AccessDecision, IngestResult } from '../types';
2
+ import type { ResolvedContext } from './context';
3
+ /** The instrumentation scope every Stigg span is reported under - what a span filter matches on. */
4
+ export declare const TRACER_NAME = "@stigg/agent-sdk-js";
5
+ /** One check, from when it was asked to when it was answered. */
6
+ export declare function traceCheck(context: ResolvedContext, decision: AccessDecision, startedAt: number): void;
7
+ /** One report, whether it landed, failed, or had nothing to send. */
8
+ export declare function traceIngest(context: ResolvedContext, result: IngestResult, startedAt: number): void;
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TRACER_NAME = void 0;
4
+ exports.traceCheck = traceCheck;
5
+ exports.traceIngest = traceIngest;
6
+ const sdk_identity_1 = require("../sdk-identity");
7
+ /** The instrumentation scope every Stigg span is reported under - what a span filter matches on. */
8
+ exports.TRACER_NAME = '@stigg/agent-sdk-js';
9
+ let api;
10
+ /** The application's copy of the API, loaded once - or `null` when it has none installed. */
11
+ function openTelemetry() {
12
+ if (api === undefined) {
13
+ try {
14
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
15
+ api = require('@opentelemetry/api');
16
+ }
17
+ catch {
18
+ api = null;
19
+ }
20
+ }
21
+ return api;
22
+ }
23
+ /** Asked for per span, so a provider registered after this package was loaded is still found. */
24
+ const tracer = (otel) => otel.trace.getTracer(exports.TRACER_NAME, sdk_identity_1.AGENT_SDK_VERSION);
25
+ /** Who the call was for: the attribution, as the check and the report both carry it. */
26
+ function attribution(context) {
27
+ const attributes = {
28
+ 'stigg.customer.id': context.customerId,
29
+ 'stigg.correlation_id': context.correlationId,
30
+ };
31
+ if (context.agent !== undefined)
32
+ attributes['gen_ai.agent.name'] = context.agent;
33
+ if (context.tool !== undefined)
34
+ attributes['gen_ai.tool.name'] = context.tool;
35
+ if (context.attributionKey && context.dimensions[context.attributionKey] !== undefined) {
36
+ attributes['stigg.agent.id'] = context.dimensions[context.attributionKey];
37
+ }
38
+ for (const [key, value] of Object.entries(context.dimensions))
39
+ attributes[`stigg.dimension.${key}`] = value;
40
+ if (context.eventName)
41
+ attributes['stigg.event.name'] = context.eventName.name;
42
+ return attributes;
43
+ }
44
+ /** One check, from when it was asked to when it was answered. */
45
+ function traceCheck(context, decision, startedAt) {
46
+ const otel = openTelemetry();
47
+ if (!otel)
48
+ return;
49
+ const attributes = {
50
+ ...attribution(context),
51
+ 'stigg.decision.has_access': decision.hasAccess,
52
+ 'stigg.decision.source': decision.source,
53
+ };
54
+ if (context.checkedId)
55
+ attributes['stigg.entitlement.id'] = context.checkedId;
56
+ if (decision.reason)
57
+ attributes['stigg.decision.reason'] = decision.reason;
58
+ if (decision.deniedBy)
59
+ attributes['stigg.decision.denied_by'] = decision.deniedBy;
60
+ if (decision.remaining !== undefined)
61
+ attributes['stigg.decision.remaining'] = decision.remaining;
62
+ if (decision.remedy)
63
+ attributes['stigg.decision.remedy'] = decision.remedy;
64
+ if (decision.isFallback)
65
+ attributes['stigg.decision.is_fallback'] = true;
66
+ const span = tracer(otel).startSpan('stigg.check', { startTime: startedAt, attributes });
67
+ if (!decision.hasAccess) {
68
+ span.recordException({ name: 'AccessDeniedError', message: decision.message });
69
+ span.setStatus({ code: otel.SpanStatusCode.ERROR, message: decision.summary });
70
+ }
71
+ span.end();
72
+ }
73
+ /** One report, whether it landed, failed, or had nothing to send. */
74
+ function traceIngest(context, result, startedAt) {
75
+ const otel = openTelemetry();
76
+ if (!otel)
77
+ return;
78
+ const attributes = {
79
+ ...attribution(context),
80
+ 'stigg.ingest.ok': result.ok,
81
+ 'stigg.ingest.mode': result.mode,
82
+ 'stigg.ingest.idempotency_key': result.idempotencyKey,
83
+ };
84
+ for (const [metric, value] of Object.entries(result.reported))
85
+ attributes[`stigg.metric.${metric}`] = value;
86
+ if (result.creditsConsumed !== undefined)
87
+ attributes['stigg.ingest.credits_consumed'] = result.creditsConsumed;
88
+ if (result.replayed !== undefined)
89
+ attributes['stigg.ingest.replayed'] = result.replayed;
90
+ const span = tracer(otel).startSpan('stigg.ingest', { startTime: startedAt, attributes });
91
+ if (!result.ok) {
92
+ const message = result.error instanceof Error ? result.error.message : String(result.error ?? 'ingest failed');
93
+ span.recordException({ name: 'EngineError', message });
94
+ span.setStatus({ code: otel.SpanStatusCode.ERROR, message });
95
+ }
96
+ span.end();
97
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Logger } from '../logger';
2
- import type { EngineCapabilities, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, EngineResolution, EntityInput, EntityPlacement, GovernanceEngine } from './engine';
2
+ import type { EngineCapabilities, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, EngineResolution, EntityInput, EntityPlacement, GovernanceEngine, ProvisionOutcome } from './engine';
3
3
  export type CompositeDelegates = {
4
4
  upsertEntity?: GovernanceEngine;
5
5
  check: GovernanceEngine;
@@ -11,6 +11,11 @@ export type CompositeDelegates = {
11
11
  unattributedCheck?: GovernanceEngine;
12
12
  report: GovernanceEngine;
13
13
  archiveEntity?: GovernanceEngine;
14
+ /**
15
+ * Creates customers on their default plan. The Node SDK, so the plan it answers with lands in
16
+ * the cache the next check reads. A customer API, so it needs no governance.
17
+ */
18
+ provisionCustomer?: GovernanceEngine;
14
19
  };
15
20
  /**
16
21
  * Whether this check is about an entity at all.
@@ -47,6 +52,7 @@ export declare class CompositeEngine implements GovernanceEngine {
47
52
  constructor(deps: CompositeDeps);
48
53
  upsertEntity(customerId: string, entity: EntityInput, placement?: EntityPlacement): Promise<void>;
49
54
  archiveEntity(customerId: string, entityId: string): Promise<void>;
55
+ provisionCustomer(customerId: string, planId: string): Promise<ProvisionOutcome>;
50
56
  check(customerId: string, params: EngineCheckParams): Promise<EngineCheckResult>;
51
57
  report(customerId: string, params: EngineReportParams): Promise<EngineReportResult>;
52
58
  /**
@@ -70,6 +70,15 @@ class CompositeEngine {
70
70
  async archiveEntity(customerId, entityId) {
71
71
  await this.run('archiveEntity', this.provisioner(), (engine) => engine.archiveEntity(customerId, entityId));
72
72
  }
73
+ async provisionCustomer(customerId, planId) {
74
+ const delegate = this.delegates.provisionCustomer;
75
+ if (!delegate?.provisionCustomer) {
76
+ throw new errors_2.EngineError('provisionCustomer', 'no client here can create customers.');
77
+ }
78
+ // Not through `run()`: its fallback is REST, which does not provision customers, so there is
79
+ // nothing to re-route to - an unreachable Node SDK fails here and the call's own check decides.
80
+ return delegate.provisionCustomer(customerId, planId);
81
+ }
73
82
  async check(customerId, params) {
74
83
  const delegate = (0, exports.isAttributed)(params)
75
84
  ? this.delegates.check
@@ -61,7 +61,7 @@ async function createGovernanceEngine(config) {
61
61
  const resolution = customerLevel('governance not enabled for this account');
62
62
  return {
63
63
  engine: new composite_engine_1.CompositeEngine({
64
- delegates: { check: nodeSdk, report: nodeSdk },
64
+ delegates: { check: nodeSdk, report: nodeSdk, provisionCustomer: nodeSdk },
65
65
  fallback: rest,
66
66
  resolution,
67
67
  logger: config.logger,
@@ -78,7 +78,14 @@ async function createGovernanceEngine(config) {
78
78
  engine: new composite_engine_1.CompositeEngine({
79
79
  // An attributed check goes to REST because only it returns chains; an unattributed one has
80
80
  // no chain to lose, and ingest prefers the cached SDK either way.
81
- delegates: { upsertEntity: rest, check: rest, unattributedCheck: nodeSdk, report: nodeSdk, archiveEntity: rest },
81
+ delegates: {
82
+ upsertEntity: rest,
83
+ check: rest,
84
+ unattributedCheck: nodeSdk,
85
+ report: nodeSdk,
86
+ archiveEntity: rest,
87
+ provisionCustomer: nodeSdk,
88
+ },
82
89
  fallback: rest,
83
90
  resolution,
84
91
  logger: config.logger,
@@ -133,12 +133,40 @@ export type EntityInput = {
133
133
  displayName?: string;
134
134
  metadata?: Record<string, string>;
135
135
  };
136
+ /** What provisioning a customer came to. Every variant is an answer from Stigg. */
137
+ export type ProvisionOutcome = {
138
+ status: 'created';
139
+ /** How the server chose the first subscription - `REQUESTED_PLAN` when it took ours. */
140
+ decision: string;
141
+ /** The plan the customer actually landed on, if they landed on one. */
142
+ planId?: string;
143
+ } | {
144
+ status: 'existing';
145
+ }
146
+ /** Refused, for a reason a retry would not change: an unknown plan, a key that cannot write. */
147
+ | {
148
+ status: 'rejected';
149
+ reason: string;
150
+ code?: string;
151
+ };
136
152
  export interface GovernanceEngine {
137
153
  readonly capabilities: EngineCapabilities;
138
154
  upsertEntity(customerId: string, entity: EntityInput, placement?: EntityPlacement): Promise<void>;
139
155
  check(customerId: string, params: EngineCheckParams): Promise<EngineCheckResult>;
140
156
  report(customerId: string, params: EngineReportParams): Promise<EngineReportResult>;
141
157
  archiveEntity(customerId: string, entityId: string): Promise<void>;
158
+ /**
159
+ * Creates the customer on `planId` when Stigg does not know them yet, and does nothing when it
160
+ * does. `existing` is the common answer, and it leaves the customer's subscriptions alone: a
161
+ * default plan is where a new customer starts, never something an existing one is moved onto.
162
+ *
163
+ * Resolves for every answer Stigg gave - including `rejected`, a request it refused - and
164
+ * throws only when Stigg could not be reached, which is the one failure worth trying again.
165
+ *
166
+ * Optional: an engine with no customer API cannot provision, and the caller treats its absence
167
+ * as having nothing to do.
168
+ */
169
+ provisionCustomer?(customerId: string, planId: string): Promise<ProvisionOutcome>;
142
170
  /**
143
171
  * Loads whatever state this engine caches for a customer, so the checks that follow are local.
144
172
  *
@@ -13,7 +13,7 @@
13
13
  * configuration passed straight through rather than re-described here.
14
14
  */
15
15
  import Stigg, { type ClientConfiguration } from '@stigg/node-server-sdk';
16
- import type { EngineCapabilities, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, GovernanceEngine } from './engine';
16
+ import type { EngineCapabilities, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, GovernanceEngine, ProvisionOutcome } from './engine';
17
17
  /**
18
18
  * Caching - all of it, and none of it implemented here.
19
19
  *
@@ -84,6 +84,21 @@ export declare class NodeSdkEngine implements GovernanceEngine {
84
84
  * amount happened to warm it.
85
85
  */
86
86
  preload(customerId: string): Promise<void>;
87
+ /**
88
+ * One mutation, no lookup first. Stigg refuses to provision a customer it already has - the
89
+ * whole mutation, subscription included - so an existing customer comes back as a duplicate and
90
+ * is left exactly as they were. Reading first would only add a round trip to every customer's
91
+ * first call in each process, to learn what the create already says.
92
+ *
93
+ * Here rather than on REST because the Node SDK creates the customer and the subscription in one
94
+ * mutation, and writes the entitlements it answers with straight into the cache - so the check
95
+ * that follows reads the new plan locally instead of fetching it.
96
+ *
97
+ * Throws only when Stigg did not answer. Any answer - created, duplicate, or refused - resolves,
98
+ * because the caller treats them differently: a refusal will be the same next time, and an
99
+ * unreachable server may not.
100
+ */
101
+ provisionCustomer(customerId: string, planId: string): Promise<ProvisionOutcome>;
87
102
  upsertEntity(): Promise<void>;
88
103
  archiveEntity(): Promise<void>;
89
104
  /**
@@ -52,6 +52,11 @@ const node_server_sdk_1 = __importStar(require("@stigg/node-server-sdk"));
52
52
  const errors_1 = require("../errors");
53
53
  const sdk_identity_1 = require("../sdk-identity");
54
54
  const report_1 = require("./report");
55
+ /**
56
+ * The API's own code for an error it answered with. Absent when nothing answered - a refused
57
+ * connection, a dropped socket - which is what tells a refusal apart from an outage.
58
+ */
59
+ const errorCode = (err) => err?.errorCode;
55
60
  const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
56
61
  /**
57
62
  * Drops keys that were never set, so a spread cannot un-set a default.
@@ -133,6 +138,39 @@ class NodeSdkEngine {
133
138
  async preload(customerId) {
134
139
  await this.client.reloadEntitlements(customerId);
135
140
  }
141
+ /**
142
+ * One mutation, no lookup first. Stigg refuses to provision a customer it already has - the
143
+ * whole mutation, subscription included - so an existing customer comes back as a duplicate and
144
+ * is left exactly as they were. Reading first would only add a round trip to every customer's
145
+ * first call in each process, to learn what the create already says.
146
+ *
147
+ * Here rather than on REST because the Node SDK creates the customer and the subscription in one
148
+ * mutation, and writes the entitlements it answers with straight into the cache - so the check
149
+ * that follows reads the new plan locally instead of fetching it.
150
+ *
151
+ * Throws only when Stigg did not answer. Any answer - created, duplicate, or refused - resolves,
152
+ * because the caller treats them differently: a refusal will be the same next time, and an
153
+ * unreachable server may not.
154
+ */
155
+ async provisionCustomer(customerId, planId) {
156
+ try {
157
+ const provisioned = await this.client.provisionCustomer({ customerId, subscriptionParams: { planId } });
158
+ return {
159
+ status: 'created',
160
+ decision: provisioned.subscriptionDecisionStrategy,
161
+ planId: provisioned.subscription?.planId,
162
+ };
163
+ }
164
+ catch (err) {
165
+ const code = errorCode(err);
166
+ if (code === 'DuplicatedEntityNotAllowed')
167
+ return { status: 'existing' };
168
+ if (code && code !== 'RequestTimeout') {
169
+ return { status: 'rejected', code, reason: err instanceof Error ? err.message : String(err) };
170
+ }
171
+ throw (0, errors_1.asEngineError)('provisionCustomer', err);
172
+ }
173
+ }
136
174
  async upsertEntity() {
137
175
  throw new errors_1.GovernanceNotEnabledError('The Node server SDK has no governance surface; entities need the REST client.');
138
176
  }
package/dist/errors.d.ts CHANGED
@@ -50,7 +50,14 @@ export declare class EngineError extends Error {
50
50
  export declare class GovernanceNotEnabledError extends Error {
51
51
  constructor(detail?: string);
52
52
  }
53
- /** Returns the decision when `err` is a governance denial - the one-liner for a binding catch. */
53
+ /**
54
+ * Returns the decision when `err` is a governance denial - the one-liner for a binding catch.
55
+ *
56
+ * Frameworks rarely let an error through untouched. Vendor SDKs wrap callback errors, LangChain
57
+ * wraps a model call's error once per middleware, and LangGraph reports two branches that failed
58
+ * in one step as an `AggregateError` - so the denial is looked for down the whole `cause` chain and
59
+ * inside every aggregated error, not only at the top.
60
+ */
54
61
  export declare function isAccessDenied(err: unknown): AccessDecision | undefined;
55
62
  export declare const isEngineError: (err: unknown) => err is EngineError;
56
63
  export declare const isGovernanceNotEnabled: (err: unknown) => err is GovernanceNotEnabledError;