Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/adr/0018-unified-node-action-registry.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -195,3 +195,42 @@ No fourth engine. Workflow Rules stays a **simplified authoring view** for busin
1. Does `connector_action` (the one verb already present in all three paradigms) become the *general* extension action, with `http`/`notify` as well-known specializations — or stay peer-level? Leaning: keep peer-level; `connector_action` targets a registered connector, `http` is raw.
2. Should `screen` / `user_task` (human-input nodes) carry their own descriptor category (`human`) that the runtime treats as always-`isAsync`? Likely yes.
3. Where does the action registry live for **cross-environment** consistency — is it per-environment (a plugin enabled in env A but not B yields different palettes)? Tie to the package/environment model (ADR-0006).

---

## Addendum (2026-05-31): `connector_action` is baseline generic dispatch

> Status of this addendum: **implemented.** This re-scopes the §Migration M2 note and resolves §Open-questions #1. The baseline registry + `connector_action` executor ship in `service-automation`, with `@objectstack/connector-rest` as the first concrete connector plugin.

### Decision

`connector_action` is promoted to a **built-in (`source: 'builtin'`) baseline node**, the generic-dispatch counterpart to `http_request`:

- where `http_request` calls **any raw URL**, `connector_action` invokes **any registered connector's declared action**;
- the engine ships the dispatch node **plus an initially-empty connector registry** (`registerConnector` / `resolveConnectorAction` / `getRegisteredConnectors`);
- **concrete** connectors (`@objectstack/connector-rest`, `connector-slack`, `connector-salesforce`, …) remain **plugins** that populate the registry at runtime.

This is the **mechanism/policy split**: the *mechanism* (registry + dispatch node) is baseline; the *concrete integrations* (and their credentials/lifecycle) are not. It mirrors the ADR-0015 datasource split — federation contract is in the open framework, managed connection lifecycle lives outside it.

### Why this reverses M2's "connector_action dropped from baseline"

M2 dropped `connector_action` because it would need "a connector registry the platform doesn't ship." That is circular: the registry is the missing piece, and an **empty** registry is zero-dependency and zero-cost. The protocol already commits to the node — `connector_action` is in `FLOW_BUILTIN_NODE_TYPES` and `connectorConfig {connectorId, actionId, input}` is already a `FlowNode` field — but ships **no executor**, so any flow referencing it fails at execution. Shipping the empty registry + dispatch executor closes that spec/runtime gap without pulling any concrete integration into the core.

### Resolves Open-question #1

The leaning ("keep peer-level") is **overturned for the dispatch direction, kept for the verbs**: `connector_action` *does* become the general connector-extension action, while `http`/`notify` stay **peer-level raw verbs** (not specializations of it). `http_request` calls a URL with no registration; `connector_action` calls a registered, named capability. Both are baseline; neither is implemented in terms of the other.

### Graceful degradation

Because the registry starts empty, a flow that references a connector no plugin has registered **fails that step with a clear error** (`no handler for '<id>.<action>' — is the connector plugin registered?`) rather than failing to register the flow — the same fail-soft posture `http_request` takes on a bad URL.

### Out of scope (deliberately not baseline)

Managed credentials/secret vault, OAuth2 token refresh, multi-tenant connection lifecycle, and a connector marketplace are **not** part of this mechanism — they are the enterprise tier, on the ADR-0015 precedent. The open framework ships the contract + dispatch + an in-process registry only.

### Implementation checklist

- [x] `AutomationEngine`: connector registry (`registerConnector` / `unregisterConnector` / `resolveConnectorAction` / `getRegisteredConnectors`) + `ConnectorActionHandler` / `ConnectorActionContext` types.
- [x] `builtin/connector-nodes.ts`: `connector_action` executor + descriptor (`category: 'io'`, `source: 'builtin'`, `paradigms: ['flow','workflow_rule','approval']`), wired into `installBuiltinNodes()`. The core plugin now seeds 11 baseline node types (was 10).
- [x] First concrete plugin `@objectstack/connector-rest` (the reference connector) validating the registry — `request` action, static auth (`none`/`api-key`/`basic`/`bearer`), no OAuth2 refresh.
- [x] Tests: baseline dispatch (fake connector) + REST plugin auth-header injection + end-to-end kernel boot (both plugins → `connector_action` flow → REST handler).
93 changes: 93 additions & 0 deletions packages/connectors/connector-rest/LICENSE
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved.
"Business Source License" is a trademark of MariaDB Corporation Ab.

Parameters

Licensor: ObjectStack AI LLC
Licensed Work: ObjectStack Runtime: the BSL-licensed packages
of the ObjectStack monorepo as listed in LICENSING.md.
Copyright (c) 2026 ObjectStack AI LLC.
Additional Use Grant: You may make production use of the Licensed Work, provided
Your use does not include offering the Licensed Work to third
parties on a hosted or embedded basis in order to compete with
ObjectStack AI LLC's paid version(s) of the Licensed Work. For purposes
of this license:

A "competitive offering" is a Product that is offered to third
parties on a paid basis, including through paid support
arrangements, that significantly overlaps with the capabilities
of ObjectStack AI LLC's paid version(s) of the Licensed Work. If Your
Product is not a competitive offering when You first make it
generally available, it will not become a competitive offering
later due to ObjectStack AI LLC releasing a new version of the Licensed
Work with additional capabilities. In addition, Products that
are not provided on a paid basis are not competitive.

"Product" means software that is offered to end users to manage
in their own environments or offered as a service on a hosted
basis.

"Embedded" means including the source code or executable code
from the Licensed Work in a competitive offering. "Embedded"
also means packaging the competitive offering in such a way
that the Licensed Work must be accessed or downloaded for the
competitive offering to operate.

Hosting or using the Licensed Work(s) for internal purposes
within an organization is not considered a competitive
offering. ObjectStack AI LLC considers your organization to include all
of your affiliates under common control.

For binding interpretive guidance on using ObjectStack AI LLC products
under the Business Source License, please visit our FAQ.
(see LICENSING.md in this repository)
Change Date: Four years from the date the Licensed Work is published.
Change License: Apache License, Version 2.0

For information about alternative licensing arrangements for the Licensed Work,
please contact licensing@objectstack.dev.

Notice

Business Source License 1.1

Terms

The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited production use.

Effective on the Change Date, or the fourth anniversary of the first publicly
available distribution of a specific version of the Licensed Work under this
License, whichever comes first, the Licensor hereby grants you rights under
the terms of the Change License, and the rights granted in the paragraph
above terminate.

If your use of the Licensed Work does not comply with the requirements
currently in effect as described in this License, you must purchase a
commercial license from the Licensor, its affiliated entities, or authorized
resellers, or you must refrain from using the Licensed Work.

All copies of the original and modified Licensed Work, and derivative works
of the Licensed Work, are subject to this License. This License applies
separately for each version of the Licensed Work and the Change Date may vary
for each version of the Licensed Work released by Licensor.

You must conspicuously display this License on each original or modified copy
of the Licensed Work. If you receive the Licensed Work in original or
modified form from a third party, the terms and conditions set forth in this
License apply to your use of that work.

Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other
versions of the Licensed Work.

This License does not grant you any right in any trademark or logo of
Licensor or its affiliates (provided that you may use a trademark or logo of
Licensor as expressly required by this License).

TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
TITLE.
36 changes: 36 additions & 0 deletions packages/connectors/connector-rest/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
{
"name": "@objectstack/connector-rest",
"version": "7.3.0",
"license": "Apache-2.0",
"description": "Generic REST connector for ObjectStack — the reference concrete connector that registers a `request` action on the automation engine's connector registry (ADR-0018 §Addendum).",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"build": "tsup --config ../../../tsup.config.ts",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/spec": "workspace:*"
},
"devDependencies": {
"@objectstack/service-automation": "workspace:*",
"@types/node": "^25.9.1",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
},
"keywords": [
"objectstack",
"connector",
"rest",
"integration",
"http"
]
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
import { ConnectorRestPlugin } from './connector-rest-plugin.js';

/** A fetch stub recording calls, returning a fixed JSON response. */
function stubFetch() {
const calls: Array<{ url: string; init: RequestInit }> = [];
const impl = (async (url: string, init: RequestInit) => {
calls.push({ url, init });
return {
status: 201,
ok: true,
headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? 'application/json' : null) },
json: async () => ({ id: 'created-1' }),
text: async () => '{"id":"created-1"}',
};
}) as unknown as typeof fetch;
return { impl, calls };
}

describe('ConnectorRestPlugin — end to end with the automation engine', () => {
it('registers the REST connector so a connector_action flow dispatches to it', async () => {
const { impl, calls } = stubFetch();

const kernel = new LiteKernel();
kernel.use(new AutomationServicePlugin());
kernel.use(
new ConnectorRestPlugin({
baseUrl: 'https://api.example.com',
auth: { type: 'bearer', token: 'secret-token' },
fetchImpl: impl,
}),
);
await kernel.bootstrap();

const engine = kernel.getService<AutomationEngine>('automation');

// The baseline node and the plugin-contributed connector are both present.
expect(engine.getRegisteredNodeTypes()).toContain('connector_action');
expect(engine.getRegisteredConnectors()).toContain('rest');

engine.registerFlow('create_via_rest', {
name: 'create_via_rest',
label: 'Create via REST',
type: 'autolaunched',
variables: [{ name: 'call.body', type: 'json', isOutput: true }],
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{
id: 'call',
type: 'connector_action',
label: 'POST /items',
connectorConfig: {
connectorId: 'rest',
actionId: 'request',
input: { method: 'POST', path: '/items', body: { name: 'Widget' } },
},
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'call' },
{ id: 'e2', source: 'call', target: 'end' },
],
});

const result = await engine.execute('create_via_rest');

expect(result.success).toBe(true);
// The REST connector handled the dispatch: one fetch with auth + body.
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('https://api.example.com/items');
expect(calls[0].init.method).toBe('POST');
expect((calls[0].init.headers as Record<string, string>)['Authorization']).toBe('Bearer secret-token');
// The action output propagated back into the flow.
expect(result.output).toEqual({ 'call.body': { id: 'created-1' } });

await kernel.shutdown();
});
});
79 changes: 79 additions & 0 deletions packages/connectors/connector-rest/src/connector-rest-plugin.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Plugin, PluginContext } from '@objectstack/core';
import type { Connector } from '@objectstack/spec/integration';
import { createRestConnector, type RestConnectorOptions } from './rest-connector.js';

/**
* Minimal surface of the automation engine this plugin depends on — the
* connector registry from ADR-0018 §Addendum. Kept structural so the plugin
* needs no runtime dependency on `@objectstack/service-automation`.
*/
export interface ConnectorRegistrySurface {
registerConnector(
def: Connector,
handlers: Record<
string,
(input: Record<string, unknown>, ctx: unknown) => Promise<Record<string, unknown>>
>,
): void;
unregisterConnector(name: string): void;
}

export interface ConnectorRestPluginOptions extends RestConnectorOptions {}

/**
* ConnectorRestPlugin — registers a generic REST connector on the automation
* engine. This is the **reference concrete connector** (ADR-0018 §Addendum):
* the dispatch node + registry are baseline; a connector like this one is a
* plugin that populates the registry.
*
* If no automation engine is present the plugin logs and skips — the connector
* has nowhere to register, which is not an error.
*/
export class ConnectorRestPlugin implements Plugin {
name = 'com.objectstack.connector.rest';
version = '1.0.0';
type = 'standard' as const;
// Ensure the automation engine (and its connector registry) is started first.
dependencies = ['com.objectstack.service-automation'];

private readonly options: ConnectorRestPluginOptions;
private connectorName?: string;
private automation?: ConnectorRegistrySurface;

constructor(options: ConnectorRestPluginOptions) {
this.options = options;
}

async init(_ctx: PluginContext): Promise<void> {
// No services to register; the connector is registered in start() once
// the automation engine is available.
}

async start(ctx: PluginContext): Promise<void> {
let automation: ConnectorRegistrySurface | undefined;
try {
automation = ctx.getService<ConnectorRegistrySurface>('automation');
} catch {
automation = undefined;
}

if (!automation || typeof automation.registerConnector !== 'function') {
ctx.logger.info('ConnectorRestPlugin: no automation engine — REST connector not registered');
return;
}

const { def, handlers } = createRestConnector(this.options);
automation.registerConnector(def, handlers);
this.automation = automation;
this.connectorName = def.name;
ctx.logger.info(`ConnectorRestPlugin: REST connector '${def.name}' registered`);
}

async stop(_ctx: PluginContext): Promise<void> {
if (this.automation && this.connectorName) {
try { this.automation.unregisterConnector(this.connectorName); } catch { /* ignore */ }
}
}
}
26 changes: 26 additions & 0 deletions packages/connectors/connector-rest/src/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* @objectstack/connector-rest
*
* Generic REST connector — the reference *concrete* connector (ADR-0018
* §Addendum). The baseline automation engine ships the `connector_action`
* dispatch node + an empty connector registry; this plugin populates the
* registry with a `rest` connector exposing a `request` action.
*
* Static auth only (`none` / `api-key` / `basic` / `bearer`); OAuth2 refresh,
* credential vaulting, and multi-tenant lifecycle are the enterprise tier.
*/

export {
createRestConnector,
type RestConnectorOptions,
type RestConnectorBundle,
type RestRequestInput,
type RestAuth,
} from './rest-connector.js';
export {
ConnectorRestPlugin,
type ConnectorRestPluginOptions,
type ConnectorRegistrySurface,
} from './connector-rest-plugin.js';
Loading
Loading