@appport/services
v0.4.5
Published
API keys, durable webhooks, background jobs, and provider-neutral secrets for AppPort applications
Readme
AppPort Services
AppPort Services provides durable operational application capabilities that sit beside AuthPort.
The service set includes API Keys, Jobs, Schedules, Secrets, Webhooks, Files, and Notifications. Notifications are stored and delivered by AppPort Services; they are not an attention-management layer. Attn may consume them to derive attention separately.
Create and run an application
For a new application:
npx create-appport my-app
cd my-app
npm run devThe generated source contains business handlers only. It does not create an HTTP server, configure persistence, implement CORS/SSE, run job workers, deliver webhooks, or install signal handlers.
For an existing application:
Install the package in your application:
npm install @appport/runtimeInitialize AppPort with every capability, or select only what the application uses:
npx @appport/runtime init
npx @appport/runtime init --use api,webhooks,jobsWith no flags, init asks which capabilities to enable and defaults each one to yes. The --use form is available for scripts and CI.
This creates two files that should be committed:
appport.tomldeclares the AppPort capabilities your application uses.feltdb.flowis your application's authoritative FeltDB contract. AppPort's internal template remains inside the npm package; application developers do not edit or import AppPort storage internals.
Bootstrap AppPort from the contract:
import { appport } from '@appport/runtime';
const app = await appport();
// Only capabilities declared in appport.toml are initialized.
await app.api.keys.createApiKey(/* ... */);appport() reads ./appport.toml by default and owns service construction, persistence, audit infrastructure, and lifecycle. Call await app.close() during graceful shutdown. Accessing an undeclared capability throws a CapabilityNotDeclaredError with the declaration needed to enable it.
appport.toml is the authoritative application contract. It is parsed, validated, normalized, and frozen once at startup. It declares application identity, deployment and state authority, tenancy, HTTP/CORS, API keys, webhook delivery, job types, events, authorization, observability, lifecycle, and development defaults. The sibling feltdb.flow is deployed into FeltDB as the authoritative state contract.
Legacy files containing only use api, use webhooks, and use jobs remain supported. Expand one to the canonical contract with a recoverable backup using:
npx @appport/runtime config migrateApplication code supplies behavior:
import { appport } from '@appport/runtime';
const application = await appport({
routes: {
'POST /invoices': async ({ body, tenantId }) => createInvoice(body, tenantId),
},
jobs: {
'invoice.process': async (job) => processInvoice(job.payload),
},
});
await application.publish('invoice.created', { id: 'inv-123' });The public application.state and application.events APIs provide provider-neutral state and subscriptions. Application code never reaches through a capability to access its private database.
The runtime owns the stable management contract:
/_appport/health
/_appport/overview
/_appport/events
/_appport/api/keys
/_appport/webhooks
/_appport/jobsHealth is public. Other endpoints follow the contract's authorization and tenant rules. Events uses SSE for GET and publishes domain events with POST; API keys, webhooks, and jobs support runtime-owned create/list operations.
@appport/services supplies the CLI and capability implementation, but application source imports only @appport/runtime. Existing applications may continue using createServices() from @appport/services as a compatibility API.
Run your application with its usual command, such as npm run dev. Operational CLI commands are available through the installed binary:
npx @appport/runtime api-key list --tenant acme
npx @appport/runtime webhook list --tenant acme
npx @appport/runtime job list --tenant acmeAppPort manages its FeltDB runtime dependency; consumers do not import @feltdb/core or AppPort's internal stores.
The repository defines AppPort capabilities for tenant-scoped API keys, durable webhooks, durable job execution (including schedules), provider-neutral Secrets metadata/lifecycle and scoped-resolution contracts, durable notifications, and files. Legacy runtime state uses @feltdb/[email protected]. Secret material remains with an authorized provider and is never durable AppPort state.
Application
│
┌───────────┴───────────┐
│ │
AuthPort AppPort Services
│ │
identity/authz API Keys, Webhooks, Jobs, Secrets
│ │
└───────────┬───────────┘
│
FeltDBConsumer API
Existing authenticated Express applications can mount the supported management runtime against their existing service instance:
import { createManagementRouter, createServices } from '@appport/services';
const services = createServices({ path: '.appport', application: 'invoices', authorizer: authBoundry, credentials: authBoundryCustody });
app.use(createManagementRouter({
services,
authority: services.gateway,
authenticate: (request) => hostAuthentication(request), // identity only
}));The host owns authentication, AuthBoundry owns authorization, and AppPort Services enforces AuthBoundry's decisions before any effect. @appport/services executes effects. It does not decide who is allowed to cause them. See the authority model, webhook security, and job security. API-key management requires apikeys.read, apikeys.create, and apikeys.revoke, always uses the authenticated tenant, and returns a plaintext credential only in the creation response. See Composable management runtime for the full contract and standalone/embedded behavior.
Outbound credentials use a server-only scoped protocol. AppPort defines the reference, context, lifecycle, audit, errors, and callback contract; AuthBoundry authorizes and AppBoundry resolves provider-held material. AppPort ships no resolver, secret store, provider adapter, or policy engine, and exposes no browser-facing credential-value route.
await secrets.withSecret({
reference: { secretId, tenantId: 'acme', provider: 'pipedrive' },
context: { tenantId: 'acme', principalId: 'integration:pipedrive', purpose: 'person.lookup', authorizationRef },
}, async ({ value }) => callProvider(value));See the outbound credential guide for ownership, authorization handoff, lifecycle, and failure semantics.
The contract determines which runtime APIs are available:
import { appport } from '@appport/runtime';
const app = await appport({ authorizer: authBoundry, credentials: authBoundryCustody });
// Identity comes from authentication; AuthBoundry authorizes each capability.
const principal = await app.authenticate(request);
// Machine identity (an API key identifies a caller; it carries no scopes)
await app.invoke('apikeys.create', { name: 'server-key' }, { principal });
// Durable outbound notifications, bound to a signing credential in AuthBoundry custody
await app.invoke('webhooks.register', {
url: 'https://acme.example.com/webhooks',
events: ['invoice.created'],
signingCredentialRef: 'credential-ref:whsec_acme',
}, { principal });
// Durable deferred execution; the job runs as this principal and is re-authorized on every run
await app.invoke('jobs.create', {
type: 'invoice.process',
payload: { invoiceId: 'inv-123' },
maxAttempts: 3,
}, { principal });The consumer does not need to know that FeltDB exists underneath. Only declared capabilities are constructed, and they share one durable runtime.
What is AppPort Services?
AppPort Services answers:
What operational capabilities does this application expose?
AuthPort still answers identity, authentication, and authorization questions.
See It In Action
A complete example application is available under examples/services-demo/. It demonstrates:
- API key authentication with tenant-scoped principals
- Durable invoice creation with automatic webhook and job intent
- Webhook delivery with retry and replay
- Background job processing with lease-based concurrency control
- Tenant isolation and durability across process restart
See examples/services-demo/README.md for a five-minute quickstart.
Architecture
AppPort Services uses Flow (the @feltdb/core contract language) as the authoritative durable schema. The package's internal appport.flow is the template from which appport init generates the application's authoritative feltdb.flow:
API Keys vertical:
ApiKeys— API key credentials (secret stored as scrypt hash only)ApiKeyPrefixes— Prefix lookup for efficient secret validationApiKeyAuditEvents— Access audit trail
Webhooks vertical:
WebhookEndpoints— Registered webhook receiversWebhookDeliveries— Outbound delivery records with retry stateWebhookAuditEvents— Webhook lifecycle audit trail
Jobs vertical:
Jobs— Individual jobs with execution status and lease trackingJobSchedules— Recurring job definitionsJobAuditEvents— Job execution audit trail
Secrets vertical:
Secrets— Tenant-scoped logical secret identity and lifecycle metadataSecretVersions— Provider references and explicit rotation versionsSecretAuditEvents— Creation, resolution, rotation, revocation, retirement, and failure audit records
Secrets operations distinguish describeSecret (metadata only) from resolveSecret (authorized provider resolution). Secret values, plaintext, decrypted material, and provider credentials are not fields in appport.flow or audit events.
All collections are tenant-scoped via tenant_id field with tenant_idx for efficient queries.
Dependency direction
feltdb.flow (generated authoritative application contract)
↓
TypeScript implementation
↓
FeltDB (@feltdb/[email protected])The Flow contract is parsed and validated at test time. The TypeScript stores (FeltDbApiKeyStore, FeltDbWebhookEndpointStore, etc.) implement the contract semantics directly against FeltDB collections.
Why is API Keys separate from AuthPort?
API keys authenticate machine principals. They do not replace AuthPort authorization.
API key
↓
machine principal
↓
AuthPort authorization
↓
resource/action decisionInstallation
npm install
npm run buildDependencies are pinned, including:
{
"dependencies": {
"@feltdb/core": "0.11.6"
}
}How do I enable API keys?
Create the service with FeltDB’s real deployment model:
import { createApiKeyService } from '@appport/services';
const service = createApiKeyService({
mode: 'local',
namespace: 'appport-services',
path: './.feltdb/appport-services'
});Remote FeltDB deployments can use the same exported deployment fields that resolveFeltDBDeployment() understands.
How do I create one?
// `operator` is a verified principal (host authentication or an existing API key).
const created = await service.createApiKey({ name: 'production' }, operator);
console.log(created.secret); // only returned onceAn API key identifies a caller and its application. It carries no scopes:
AuthBoundry decides what the caller may do. Passing scopes is rejected with
a migration error (docs/AUTHORITY.md).
CLI (the operator is identified through APPPORT_AUTHORITY or APPPORT_API_KEY):
APPPORT_AUTHORITY=./authority.mjs appport api-key create --tenant tenant-123 --name productionHow does an application authenticate?
Option 1: Framework-neutral HTTP adapter
For applications using ordinary Node HTTP request/response semantics:
import { createApiKeyAuth } from '@appport/services';
const auth = createApiKeyAuth({ service });
// Optional authentication (returns null if missing/invalid)
const principal = await auth.authenticate(request);
// Required authentication (throws if missing/invalid)
const principal = await auth.require(request);The adapter extracts the Authorization: Bearer <api-key> header and returns an AuthenticatedPrincipal:
interface AuthenticatedPrincipal {
principalId: string;
principalType: 'api_key';
tenantId: string;
applicationId: string;
credentialId: string;
verifiedBy: 'api_key';
}Principals are branded: services accept only principals minted by an authentication path, never object literals or actor strings.
Option 2: Express middleware
For Express applications:
import { apiKeyAuth, requireApiKeyAuth } from '@appport/services';
const app = express();
// Optional authentication
app.use(apiKeyAuth(service));
app.get('/invoices', async (req, res) => {
const principal = req.auth; // null if missing/invalid
if (!principal) {
return res.status(401).json({ error: 'Unauthenticated' });
}
// Handle request with principal
});
// Or, require authentication
app.use(requireApiKeyAuth(service));
app.get('/protected', async (req, res) => {
const principal = req.auth; // guaranteed, or middleware rejects
// Handle request with principal
});The middleware attaches the principal to req.auth and provides request-scoped context via req.authContext.
Tenant safety
If your application accepts tenant context independently, validate it against the principal:
import { assertTenant } from '@appport/services';
assertTenant(principal, tenantIdFromRequest); // throws if mismatchLow-level Bearer token extraction
For custom frameworks:
import { authenticateBearerToken } from '@appport/services';
const principal = await authenticateBearerToken(
authorizationHeader,
service,
);How does authorization happen?
Authentication returns an identity. Authorization belongs to AuthBoundry: every
service effect is authorized by the configured ServiceAuthorizer before it
runs, credentials are resolved only after that decision, and evidence is
written to FeltDB. See docs/AUTHORITY.md.
How do I use webhooks?
Webhooks provide durable signed delivery to external HTTP endpoints.
Setup
import { createServices } from '@appport/services';
const services = createServices({ path: './.appport', application: 'invoices', authorizer: authBoundry, credentials: authBoundryCustody });
const service = services.webhooks;Register endpoint
const endpoint = await service.createWebhookEndpoint({
url: 'https://customer.example.com/webhooks',
events: ['invoice.created', 'invoice.paid'],
// The signing secret lives in AuthBoundry custody; the endpoint stores only the reference.
signingCredentialRef: 'credential-ref:whsec_customer',
}, principal);
// Private, loopback, link-local, and metadata destinations are rejected; redirects are never followed.
// See docs/WEBHOOK-SECURITY.md.Emit event
await service.emitWebhookEvent({
type: 'invoice.created',
payload: { id: 'inv-456', amount: 100 }
}, principal);
// Delivery records created automatically for matching endpointsWorker: deliver webhooks
const delivery = await service.getWebhookDelivery(tenantId, deliveryId);
const result = await service.deliverWebhook(tenantId, deliveryId);
if (result.success) {
// Delivered
} else {
// Failed or queued for retry
}The delivery is signed with HMAC-SHA256. Each delivery includes:
{
"id": "delivery-uuid",
"eventId": "event-uuid",
"type": "invoice.created",
"data": { "id": "inv-456", "amount": 100 },
"timestamp": "2026-09-10T22:30:00Z"
}Header: X-AppPort-Signature: <hex-encoded-hmac-sha256>
Consumer verification:
import crypto from 'node:crypto';
function verify(payload, signature, secret) {
const expected = Buffer.from(crypto.createHmac('sha256', secret).update(payload).digest('hex'));
const actual = Buffer.from(signature);
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
}Retry policy
- 2xx → delivered
- 408 / 429 / 5xx / network failure → retry with bounded exponential backoff
- 3xx → failed (terminal): redirects are never followed
- other 4xx → failed (terminal)
- AuthBoundry denial → failed; the provider is never called
- AuthBoundry unavailable / timeout → retried later; the provider is never called
- Max attempts → 5 (configurable)
Failed deliveries can be manually replayed:
await service.replayWebhookDelivery(tenantId, deliveryId, principal); // webhooks.replayDisable endpoint
await service.disableWebhookEndpoint({ id: endpoint.id }, principal); // webhooks.remove
// No new deliveries created; existing pending deliveries blockedWebhooks are at-least-once delivery. Consumers should treat eventId / deliveryId as idempotency identifiers.
How do I use durable jobs?
Jobs provide reliable background task execution with retry support, concurrency control via leases, and automatic recovery from worker failure.
Setup
import { createServices } from '@appport/services';
const services = createServices({ path: './.appport', application: 'invoices', authorizer: authBoundry });
const service = services.jobs;Register handler
service.register('invoice.process', async (job, execution) => {
const { invoiceId } = job.payload as { invoiceId: string };
// The job runs as its durable principal; every effect is authorized again.
await services.invoke('notifications.send', { recipient: 'finance', type: 'invoice.processed', title: invoiceId }, { principal: execution.principal });
});Enqueue job
const job = await service.enqueue({
type: 'invoice.process',
payload: { invoiceId: 'inv-456' },
maxAttempts: 3,
delegationId: 'del_quote_agent', // optional AuthBoundry delegation, checked on every run
}, principal);A job without a durable principal is never executed, and a revoked delegation stops the next run. See docs/JOB-SECURITY.md.
Execute job (worker)
const result = await service.executeJob(tenantId, jobId, 'worker-1');
if (result) {
// Job completed successfully
}Schedule recurring job
const schedule = await service.scheduleRecurring({
type: 'invoice.reconcile',
payload: { batchSize: 100 },
interval: '1h',
}, principal);Retry policy
- Success → job marked completed
- Failure → retry with exponential backoff (2^attemptCount)
- Max attempts exceeded → job marked failed
- Worker lease expired → job eligible for recovery by another worker
- Manual retry → reset failed job to pending state
Jobs are durable and idempotent. Job state survives process restarts; workers claim jobs via version-based optimistic locking.
How do I send notifications?
Notifications are durable application events routed through one or more delivery channels. Applications decide what happened. AppPort Services decides how a durable notification is delivered.
Application = meaning
AppPort Services = delivery infrastructure
FeltDB = durable state and evidence
Attn = attention and judgmentconst { notification, deliveries } = await app.notifications.notify({
tenantId: 'tenant-a',
recipient: 'user-1',
type: 'monitor.triggered',
title: 'Status changed',
source: { type: 'monitor', id: 'monitor-7', eventId: 'observation-123' },
channels: ['browser', 'in-app'],
}, principal);
await app.notifications.markRead('tenant-a', notification.id, recipientPrincipal);
await app.notifications.acknowledge('tenant-a', notification.id, recipientPrincipal);- Each channel has its own durable delivery record (
pending,delivered,failed,retrying). - Repeating the same source event returns the same notification.
- Retries run on the existing job infrastructure.
expiresAtstops obsolete deliveries but keeps the record as evidence.- Credentials (passwords, cookies, authorization headers, tokens, API keys, private keys) are rejected.
- Closing a browser does not destroy notifications. The browser is one delivery channel, and a new
session catches up from
GET /notifications?unread=true.
Email, mobile push, SMS, and webhook channels are adapters registered with registerChannel().
See docs/notifications.md for the resource model, lifecycle, HTTP API,
authorization, and the sensitive-data boundary.
Where does durable state live?
AppPort Services stores all state directly in FeltDB collections through @feltdb/[email protected].
feltdb.flow (generated Flow contract)
│
▼
AppPort Services
│
├─→ FeltDbApiKeyStore (ApiKeys, ApiKeyPrefixes, ApiKeyAuditEvents)
├─→ FeltDbWebhookEndpointStore (WebhookEndpoints, WebhookAuditEvents)
├─→ FeltDbWebhookDeliveryStore (WebhookDeliveries)
├─→ FeltDbJobStore (Jobs, JobSchedules, JobAuditEvents)
└─→ Secrets protocol (Secrets, SecretVersions, SecretAuditEvents)
│
▼
@feltdb/[email protected]
│
▼
real FeltDBWhat happens to the secret?
For AppPort Secrets, the boundary is:
application .flow → capability contract → AuthBoundry authorization → secret providerApplications declare the secrets capability, but never put secret values in .flow. AppPort publishes identity, lifecycle, tenant, audit, and provider-reference metadata only; AppBoundry supplies provider execution after AuthBoundry authorization.
AppPort is declarative: it defines what the Secrets capability means. AppBoundry decides how it runs, and AuthBoundry decides who may use it. The Secrets contract contains no provider, storage, caching, injection, or authorization implementation.
- generated with cryptographic randomness
- returned exactly once at creation time
- hashed with scrypt before persistence
- never stored in the durable API-key record
- excluded from audit records
Repository layout
appport.flow— Internal package template used to generate consumerfeltdb.flowcontracts/src/api-keys— API-key models and semantic service/src/authority— Policy Enforcement Point: capability manifest, verified principals, execution contexts, AuthBoundry protocol, destination policy, effect evidence/src/webhooks— Webhook models, service, and signing helpersmodels.ts— Endpoint, delivery, event contractsservice.ts— Webhook lifecycle and delivery orchestrationsecrets.ts— Secret generation, encryption, HMAC signing
/src/jobs— Job models, service, and executionmodels.ts— Job and schedule contractsservice.ts— Job lifecycle, retry, and schedulingstore.ts— FeltDB-backed storage with optimistic lockingworker.ts— Concurrent job execution with polling
/src/storage— FeltDB-backed store and audit sinkapi-keys.ts— API key storagewebhooks.ts— Webhook endpoint/delivery storage
/src/runtime— HTTP/Express authentication adapters and Bearer-token extractionapi-keys.ts— Bearer token extractionhttp-adapter.ts— Framework-neutral HTTP adapterexpress-middleware.ts— Express middleware
/src/contract— AuthPort-facing principal contract/tests— Node/TypeScript testscontract.test.ts— Flow DSL validation and collection verificationapi-keys.test.ts— Core service testshttp-adapter.test.ts— HTTP adapter tests (security, isolation, tenant safety)integration-app.test.ts— Real HTTP application fixtureexpress-integration.test.ts— Express middleware integration testswebhooks.test.ts— Webhook lifecycle and durability testswebhooks-delivery.test.ts— HTTP delivery, signing, retry, and concurrency testsjobs.test.ts— Job lifecycle, scheduling, and durability testsjobs-execution.test.ts— Concurrent execution, leasing, and recovery tests
/docs— architecture notes
