Skip to main content
0.0.x

Call internal procedures from a service or worker

Goal: expose a procedure to backend processes in the same installation while keeping the service's public procedures public. This installation service identity is separate from enterprise machine identity. Workers, sagas and triggers call it without a browser, phone app or user session.

Use the provisioned file reference

Generate the project's Aspire helpers with netscript generate aspire, then start the AppHost. The generated carrier creates .data/aspire-secrets/installation.secret once under the project root, preserves it across starts, and registers a secret parameter. It delivers NETSCRIPT_INSTALLATION_SECRET_FILE to services, plugin APIs and background processors, with the Deno read/environment permissions they need. The variable contains the file path, never the secret. For packaged backends, the installation carrier likewise delivers the reference. Keep the file out of source control, browser configuration, command arguments and logs. Do not copy its value into appsettings or an SDK option.

Load it once when composing the service. Each SDK contribution loads the same file reference once and derives a bearer for the target service name. Use HTTPS outside loopback; the contribution refuses non-loopback cleartext HTTP by default. Service discovery must resolve the target from the caller's backend environment. A worker needs a service reference to orders, as does a calling service; configure ServiceReferences: ['orders'] on that resource in appsettings.

Declare the audience in the contract

Save this as internal-contract.ts in a backend workspace shared by the service and its callers. The marker covers both projections, including a procedure with no explicit OpenAPI route.

import { baseContract, SuccessSchema } from '@netscript/contracts';

export const ordersContract = {
  public: {
    ping: baseContract.route({ method: 'POST', path: '/ping' }).output(SuccessSchema),
  },
  internal: {
    sync: baseContract.meta({ access: { audience: 'internal' } }).output(SuccessSchema),
    reindex: baseContract.route({ method: 'POST', path: '/internal/reindex' })
      .meta({ access: { audience: 'internal' } }).output(SuccessSchema),
  },
};

Never combine audience: 'internal' with authentication: 'none'; construction rejects that contradiction. An admin role or a user session does not satisfy the internal audience.

Compose the explicit service policy

Save this as internal-service.ts. The overlay governs access-marked procedures. Unmarked procedures keep the path policy, so /api/v1/ping stays public in this example. For a guarded service, retain its existing protect/allowAnonymous policy instead of adding the public /api exception. Pass its session authenticator to the composition below so ordinary session calls still work.

import { implement } from '@orpc/server';
import { createService } from '@netscript/service';
import {
  type AuthenticatorPort,
  createCompositeAuthenticator,
  createContractOverlayAuthorizer,
  createInternalCredentialAuthenticator,
  loadInstallationSecret,
  type ServiceAuthPolicy,
} from '@netscript/service/auth';
import { ordersContract } from './internal-contract.ts';

export async function createOrdersApp(sessionAuthenticator?: AuthenticatorPort) {
  const secret = await loadInstallationSecret();
  const internalAuthenticator = createInternalCredentialAuthenticator({
    secret,
    service: 'orders',
  });
  const auth = {
    authn: {
      authenticator: createCompositeAuthenticator([
        internalAuthenticator,
        ...(sessionAuthenticator ? [sessionAuthenticator] : []),
      ]),
      allowAnonymous: ['/api', '/health'],
    },
    authz: { authorizer: createContractOverlayAuthorizer(ordersContract) },
  } satisfies ServiceAuthPolicy;
  const implemented = implement(ordersContract);
  const router = implemented.router({
    public: { ping: implemented.public.ping.handler(() => ({ success: true })) },
    internal: {
      sync: implemented.internal.sync.handler(() => ({ success: true })),
      reindex: implemented.internal.reindex.handler(() => ({ success: true })),
    },
  });
  return createService(router, { name: 'orders' })
    .withRPC({ rpcPath: '/api/rpc/v1/orders', apiPath: '/api/v1' })
    .withAuthn(auth.authn)
    .withAuthz(auth.authz);
}

At the service entrypoint, call await (await createOrdersApp()).serve({ port: 3001 }) and let the supervisor own the returned listener. The same auth object is the guarded arm of ServiceAuthPolicy accepted by defineService(router, { name: 'orders', auth }). Do not use auth: { public: true, reason: '...' } for a service containing internal procedures: that posture opts out of authentication and authorization. Do not protect just /api/rpc; the OpenAPI projection must use the same overlay.

Call from another backend

Save this as internal-client.ts. Create the client once in the calling service or background runtime's composition root, then invoke it from a job, saga step or trigger handler. The contribution supplies the bearer; no app session or per-request secret read is needed.

import {
  createInternalCredentialSdkClientContribution,
  createServiceClient,
} from '@netscript/sdk/client';
import { ordersContract } from './internal-contract.ts';

export function createOrdersClient() {
  return createServiceClient({
    contract: ordersContract,
    serviceName: 'orders',
    contributions: [
      createInternalCredentialSdkClientContribution({ service: 'orders' }),
    ] as const,
  });
}

In the backend handler, call await orders.internal.sync({}) on the client returned by createOrdersClient(). Keep it server-side. The contribution owns authorization and uses direct-only response caching; do not combine it with another contribution that owns that header. Procedures explicitly declaring authentication: 'none' receive no internal credential.

Verify both projections

The executable recipe test generates the actual credential helper and service/background resource registrations, writes the three snippets above into the generated project, and runs them against a loopback HTTP listener. It checks these calls for both sync (default OpenAPI path) and reindex (explicit OpenAPI route):

Caller RPC /api/rpc/v1/orders/internal/<procedure> OpenAPI /api/v1/internal/<procedure>
Anonymous 401 401
Wrong installation or target-service credential 401 401
Valid user session, even with service roles 403 403
Valid installation credential for orders 200 200
Old credential after restart with rotated material 401 401

An OpenAPI URL is not a bypass: it accepts the same internal service identity, and refuses anonymous or session callers. RPC POST bodies use {"json":{}}; OpenAPI POST bodies use {}. Public ping remains callable anonymously on both projections. Run the focused recipe proof from the framework checkout:

deno run --allow-read --allow-write --allow-run .llm/tools/run-deno-test.ts -- --allow-all packages/cli/src/kernel/templates/aspire/helpers/tests/installation-provisioning_test.ts

This checks the generated carrier and recipe without starting the AppHost. The full scaffold.runtime suite under the runtime lease remains the acceptance check for actual Aspire resource startup and service discovery.

Rotate the installation credential

Stop the AppHost and all packaged backend processes that share the installation. Replace the secret file with fresh cryptographically random textual material (at least 32 bytes, at most 4 KiB), keeping restrictive permissions and the same reference path. For a generated local project, remove only .data/aspire-secrets/installation.secret while stopped and let the next AppHost startup provision fresh material. Do not delete the whole secrets directory.

Restart every service, plugin API, worker, saga and trigger sharing the installation. Services retain the loaded secret and SDK contributions retain their derived bearer; rewriting a file alone does not rotate running instances. Recreate caller clients as part of restart. Verify a fresh backend call succeeds and a retained old bearer is refused on both projections, without printing either credential. Rotation invalidates credentials for every service derived from that installation; it is not a per-user signout operation.