2026-08-22 00:44:57 +02:00
|
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
|
|
|
import { decode, encode } from "@msgpack/msgpack";
|
|
|
|
|
import {
|
|
|
|
|
startCultNetOperationServer,
|
|
|
|
|
type CultNetOperationRequestMessage,
|
|
|
|
|
type CultNetOperationResponseMessage,
|
|
|
|
|
type CultNetOperationServer,
|
|
|
|
|
} from "cultnet-ts";
|
|
|
|
|
import { type FastifyInstance } from "fastify";
|
2026-09-30 17:07:16 +02:00
|
|
|
import { errorIdentity, getHeimdallRuntimeContext, refreshAppSession, startOAuthFlow, verifyRefreshToken } from "./app.js";
|
2026-09-08 14:22:48 +02:00
|
|
|
import { callerFromOpenedEnvelope } from "./app-caller.js";
|
R21.3: delete the unreachable registered-app resolution path
The runtime app-registration writer (POST /v1/apps) was deleted in an
earlier pass because it minted a client_secret nobody verified. This
pass kept resolveAppProfile's registered-app fallback and
listAppProfiles on the theory that AppSlug is an open string and
dynamic registration was a live extension point. Soul falsified that:
every HTTP route enumerates the four built-in slugs in its own JSON
schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body
field), so a registered row could never be resolved from a request.
With the writer already gone, this was unreachable code behind a dead
writer -- delete it rather than keep authenticating a door nobody can
open.
Deleted: src/app-registry.ts entirely (resolveAppProfile,
listAppProfiles, isBuiltInApp, registeredAppToProfile,
returnOriginsFromRedirectUris); StoredRegisteredApp and the
findRegisteredApp/listRegisteredApps HeimdallStore methods
(store/types.ts); their InMemoryStore and PostgresStore
implementations, including seedRegisteredApp and mapRegisteredApp; the
registered_apps table (store/schema.ts now drops it on any store built
before this schema); tests/app-registry.test.ts.
Every call site (app.ts x5, claims.ts, private-command-plane.ts) now
calls the existing built-in-only getAppProfile (app-profiles.ts)
directly -- sync, no store needed. If dynamic app registration is
wanted later it returns as a designed, authenticated feature, not by
resurrecting this vestige.
Net -305 lines against the prior commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:23 +02:00
|
|
|
import { getAppProfile } from "./app-profiles.js";
|
2026-08-22 00:44:57 +02:00
|
|
|
import { executeHeimdallAccessPlugin, HEIMDALL_ACCESS_PLUGIN_ID, type EvePluginAbiRequest } from "./access-plugin.js";
|
Register apps at runtime instead of at build time
App slugs were a compile-time union of four names. That was fine while every app
was ours and it blocks what Heimdall is for: an app it has never heard of,
running on someone else's machine, registering against a self-hosted instance
without forking and rebuilding the auth authority. A creator self-hosting
Erycina alongside Heimdall would otherwise maintain a patched auth server
forever.
AppSlug is now a string, which it always was on the wire; the union was a
narrowing the type system could enforce only because the set was closed.
Membership moved from appSlugs.includes to the registry, and where no store is
reachable — the offline token verifier is the case that matters — it is a shape
check, isAppSlug, rather than a false claim to know what exists. A host app
verifying its own tokens should compare aud against its own slug, which it knows
and Heimdall's built-in list does not.
Registration is data end to end. Storage carries the profile and a hash of the
client secret rather than the secret. Built-in profiles resolve first and their
slugs are refused at registration, in both places, so a registration cannot
shadow a shipped app and inherit its audience.
POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library
can read it, returning client_secret exactly once. It is gated on an operator
secret and closed entirely until one is configured: an open registration
endpoint on an OAuth provider lets anyone create a client that starts Discord
and Patreon flows under the instance's identity, which is a phishing surface
wearing the operator's name. A fresh instance is not accidentally open.
Redirect URIs are validated rather than stored as given. They are where an
authorization code is delivered, so a permissive one is a token-theft primitive:
absolute, no fragment, https unless loopback.
Six call sites assumed an app profile always exists. They now answer 404 for an
app nobody registered, and claim issuance refuses outright rather than signing a
token whose audience no verifier can resolve.
108 tests pass, 34 of them new. Zero type errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
|
|
|
import { isAppSlug, oauthModes, providers, type AppSlug, type OAuthEntitlementPolicy, type OAuthMode, type Provider } from "./contracts.js";
|
2026-08-22 00:44:57 +02:00
|
|
|
import { type HeimdallConfig } from "./config.js";
|
|
|
|
|
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "./private-command-security.js";
|
|
|
|
|
|
|
|
|
|
export const HEIMDALL_PRIVATE_COMMAND_SERVICE = "heimdall.private.commands";
|
|
|
|
|
export const HEIMDALL_PRIVATE_ENVELOPE_SCHEMA = "heimdall.private_command_envelope.v1";
|
|
|
|
|
|
|
|
|
|
interface CachedResult {
|
|
|
|
|
fingerprint: string;
|
|
|
|
|
status: string;
|
|
|
|
|
envelope: HeimdallPrivateEnvelope;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function startHeimdallPrivateCommandPlane(
|
|
|
|
|
app: FastifyInstance,
|
|
|
|
|
config: HeimdallConfig,
|
|
|
|
|
): Promise<CultNetOperationServer> {
|
|
|
|
|
const seenNonces = new Map<string, number>();
|
|
|
|
|
const inFlight = new Map<string, Promise<CachedResult>>();
|
|
|
|
|
return await startCultNetOperationServer({
|
|
|
|
|
runtimeId: config.daemonId,
|
|
|
|
|
host: config.privateCommandHost ?? "127.0.0.1",
|
|
|
|
|
port: config.privateCommandPort ?? 4101,
|
|
|
|
|
handler: async (request) => {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
for (const [nonce, expiry] of seenNonces) if (expiry <= now) seenNonces.delete(nonce);
|
|
|
|
|
try {
|
|
|
|
|
if (request.serviceId === HEIMDALL_ACCESS_PLUGIN_ID) {
|
|
|
|
|
if (request.payloadSchema !== "gamecult.eve.plugin_abi.request.v1" || request.payloadEncoding !== "messagepack-base64") {
|
|
|
|
|
throw new Error("Heimdall access plugin requires the Eve plugin ABI request schema.");
|
|
|
|
|
}
|
|
|
|
|
const pluginRequest = decode(Buffer.from(request.payload, "base64")) as EvePluginAbiRequest;
|
|
|
|
|
if (pluginRequest.operation !== request.operation) throw new Error("Plugin ABI operation disagrees with CultNet routing.");
|
|
|
|
|
const pluginResponse = executeHeimdallAccessPlugin(pluginRequest);
|
|
|
|
|
return {
|
|
|
|
|
schemaVersion: "cultnet.operation_response.v0",
|
|
|
|
|
messageId: request.messageId,
|
|
|
|
|
serviceId: request.serviceId,
|
|
|
|
|
operation: request.operation,
|
|
|
|
|
status: pluginResponse.status,
|
|
|
|
|
payloadSchema: "gamecult.eve.plugin_abi.response.v1",
|
|
|
|
|
payloadEncoding: "messagepack-base64",
|
|
|
|
|
payload: Buffer.from(encode(pluginResponse)).toString("base64"),
|
|
|
|
|
diagnostics: [],
|
|
|
|
|
sourceRuntimeId: config.daemonId,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (request.serviceId !== HEIMDALL_PRIVATE_COMMAND_SERVICE) throw new Error("Unknown Heimdall private command service.");
|
|
|
|
|
if (request.payloadSchema !== HEIMDALL_PRIVATE_ENVELOPE_SCHEMA || request.payloadEncoding !== "messagepack-base64") {
|
|
|
|
|
throw new Error("Heimdall private commands require the typed encrypted MessagePack envelope.");
|
|
|
|
|
}
|
|
|
|
|
const envelope = decode(Buffer.from(request.payload, "base64")) as HeimdallPrivateEnvelope;
|
Register apps at runtime instead of at build time
App slugs were a compile-time union of four names. That was fine while every app
was ours and it blocks what Heimdall is for: an app it has never heard of,
running on someone else's machine, registering against a self-hosted instance
without forking and rebuilding the auth authority. A creator self-hosting
Erycina alongside Heimdall would otherwise maintain a patched auth server
forever.
AppSlug is now a string, which it always was on the wire; the union was a
narrowing the type system could enforce only because the set was closed.
Membership moved from appSlugs.includes to the registry, and where no store is
reachable — the offline token verifier is the case that matters — it is a shape
check, isAppSlug, rather than a false claim to know what exists. A host app
verifying its own tokens should compare aud against its own slug, which it knows
and Heimdall's built-in list does not.
Registration is data end to end. Storage carries the profile and a hash of the
client secret rather than the secret. Built-in profiles resolve first and their
slugs are refused at registration, in both places, so a registration cannot
shadow a shipped app and inherit its audience.
POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library
can read it, returning client_secret exactly once. It is gated on an operator
secret and closed entirely until one is configured: an open registration
endpoint on an OAuth provider lets anyone create a client that starts Discord
and Patreon flows under the instance's identity, which is a phishing surface
wearing the operator's name. A fresh instance is not accidentally open.
Redirect URIs are validated rather than stored as given. They are where an
authorization code is delivered, so a permissive one is a token-theft primitive:
absolute, no fragment, https unless loopback.
Six call sites assumed an app profile always exists. They now answer 404 for an
app nobody registered, and claim issuance refuses outright rather than signing a
token whose audience no verifier can resolve.
108 tests pass, 34 of them new. Zero type errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
|
|
|
if (!isAppSlug(envelope.appSlug)) throw new Error("Unknown Heimdall app binding.");
|
2026-08-22 00:44:57 +02:00
|
|
|
const appSlug = envelope.appSlug as AppSlug;
|
|
|
|
|
const secret = config.appSharedSecrets[appSlug];
|
|
|
|
|
if (!secret) throw new Error("Heimdall app binding has no private command secret.");
|
|
|
|
|
if (envelope.operation !== request.operation) throw new Error("Private envelope operation disagrees with CultNet routing.");
|
|
|
|
|
const expectedContentSchema = request.operation === "heimdall.auth.begin"
|
|
|
|
|
? "heimdall.auth_begin_command.v1"
|
|
|
|
|
: request.operation === "heimdall.auth.complete"
|
|
|
|
|
? "heimdall.auth_complete_command.v1"
|
2026-08-22 01:27:28 +02:00
|
|
|
: request.operation === "heimdall.auth.refresh"
|
|
|
|
|
? "heimdall.auth_refresh_command.v1"
|
2026-08-22 01:53:21 +02:00
|
|
|
: request.operation === "heimdall.auth.logout"
|
|
|
|
|
? "heimdall.auth_logout_command.v1"
|
2026-08-22 00:44:57 +02:00
|
|
|
: undefined;
|
|
|
|
|
if (!expectedContentSchema || envelope.contentSchema !== expectedContentSchema) {
|
|
|
|
|
throw new Error("Private command content schema does not match its operation.");
|
|
|
|
|
}
|
|
|
|
|
const allowedRuntimeIds = config.appRuntimeIds?.[appSlug] ?? [`yggdrasil-${appSlug}`];
|
|
|
|
|
if (!request.sourceRuntimeId || !allowedRuntimeIds.includes(request.sourceRuntimeId)) {
|
|
|
|
|
throw new Error("Private command caller is not the configured app runtime.");
|
|
|
|
|
}
|
|
|
|
|
const payload = openPrivateEnvelope(envelope, secret, now);
|
|
|
|
|
const fingerprint = createHash("sha256").update(request.payload).digest("hex");
|
|
|
|
|
const resultKey = `${appSlug}:${envelope.idempotencyKey}`;
|
|
|
|
|
const store = getHeimdallRuntimeContext(app).store;
|
|
|
|
|
const persisted = await store.findPrivateCommandReceipt(appSlug, envelope.idempotencyKey);
|
|
|
|
|
if (persisted) {
|
|
|
|
|
if (persisted.requestFingerprint !== fingerprint) throw new Error("Idempotency key was reused with different command content.");
|
|
|
|
|
const cachedEnvelope = decode(Buffer.from(persisted.envelopeBase64, "base64")) as HeimdallPrivateEnvelope;
|
|
|
|
|
return response(request, persisted.status, cachedEnvelope, config.daemonId);
|
|
|
|
|
}
|
|
|
|
|
const pending = inFlight.get(resultKey);
|
|
|
|
|
if (pending) {
|
|
|
|
|
const cached = await pending;
|
|
|
|
|
if (cached.fingerprint !== fingerprint) throw new Error("Idempotency key was reused with different command content.");
|
|
|
|
|
return response(request, cached.status, cached.envelope, config.daemonId);
|
|
|
|
|
}
|
|
|
|
|
if (seenNonces.has(`${appSlug}:${envelope.nonce}`)) throw new Error("Private command nonce was already used.");
|
|
|
|
|
seenNonces.set(`${appSlug}:${envelope.nonce}`, Date.parse(envelope.expiresAt));
|
|
|
|
|
|
|
|
|
|
const execution = (async (): Promise<CachedResult> => {
|
|
|
|
|
const output = await executePrivateCommand(app, config, appSlug, request.operation, payload);
|
|
|
|
|
const sealed = sealPrivateEnvelope({
|
|
|
|
|
appSlug,
|
|
|
|
|
operation: request.operation,
|
|
|
|
|
contentSchema: output.payloadSchema,
|
|
|
|
|
idempotencyKey: envelope.idempotencyKey,
|
|
|
|
|
secret,
|
|
|
|
|
payload: output.payload,
|
|
|
|
|
});
|
|
|
|
|
const stored = await store.createPrivateCommandReceipt({
|
|
|
|
|
appSlug,
|
|
|
|
|
idempotencyKey: envelope.idempotencyKey,
|
|
|
|
|
requestFingerprint: fingerprint,
|
|
|
|
|
status: output.status,
|
|
|
|
|
contentSchema: output.payloadSchema,
|
|
|
|
|
envelopeBase64: Buffer.from(encode(sealed)).toString("base64"),
|
|
|
|
|
createdAt: sealed.issuedAt,
|
|
|
|
|
expiresAt: sealed.expiresAt,
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
fingerprint: stored.requestFingerprint,
|
|
|
|
|
status: stored.status,
|
|
|
|
|
envelope: decode(Buffer.from(stored.envelopeBase64, "base64")) as HeimdallPrivateEnvelope,
|
|
|
|
|
};
|
|
|
|
|
})();
|
|
|
|
|
inFlight.set(resultKey, execution);
|
|
|
|
|
let cachedResult: CachedResult;
|
|
|
|
|
try {
|
|
|
|
|
cachedResult = await execution;
|
|
|
|
|
} finally {
|
|
|
|
|
if (inFlight.get(resultKey) === execution) inFlight.delete(resultKey);
|
|
|
|
|
}
|
|
|
|
|
return response(request, cachedResult.status, cachedResult.envelope, config.daemonId);
|
|
|
|
|
} catch (error) {
|
2026-09-30 17:07:16 +02:00
|
|
|
// The reply's diagnostics travel outside the sealed envelope, and the
|
|
|
|
|
// caller logs them. Errors caught here carry input: JSON.parse quotes
|
|
|
|
|
// a provider's body, a store error names the database. So the
|
|
|
|
|
// diagnostic is fixed text plus the error's class and code; anything
|
|
|
|
|
// more lives only in a sealed payload the plane authored.
|
|
|
|
|
const { errorClass, errorCode } = errorIdentity(error);
|
2026-08-22 00:44:57 +02:00
|
|
|
return {
|
|
|
|
|
schemaVersion: "cultnet.operation_response.v0",
|
|
|
|
|
messageId: request.messageId,
|
|
|
|
|
serviceId: request.serviceId,
|
|
|
|
|
operation: request.operation,
|
|
|
|
|
status: "denied",
|
|
|
|
|
payloadSchema: "gamecult.cultnet.operation_failure.v1",
|
|
|
|
|
payloadEncoding: "messagepack-base64",
|
|
|
|
|
payload: Buffer.from(encode({ code: "private-command-denied", message: "Heimdall denied the private command." })).toString("base64"),
|
2026-09-30 17:07:16 +02:00
|
|
|
diagnostics: [`Heimdall denied the private command (${errorClass}${errorCode ? `, ${errorCode}` : ""}).`],
|
2026-08-22 00:44:57 +02:00
|
|
|
sourceRuntimeId: config.daemonId,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function executePrivateCommand(
|
|
|
|
|
app: FastifyInstance,
|
|
|
|
|
config: HeimdallConfig,
|
|
|
|
|
appSlug: AppSlug,
|
|
|
|
|
operation: string,
|
|
|
|
|
payload: Record<string, unknown>,
|
|
|
|
|
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
|
|
|
|
|
if (operation === "heimdall.auth.begin") return await beginAuth(app, config, appSlug, payload);
|
|
|
|
|
if (operation === "heimdall.auth.complete") return await completeAuth(app, appSlug, payload);
|
Add the single caller-identity organ; delete the five ad hoc predicates it replaces
src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only
code that decides 'this HTTP request is app X'. It reads one header
(x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and
compares with the existing timing-safe secretMatches, moved here unchanged.
Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the
handoff-kind trust (handoff.kind === 'backend_callback' implying identity),
trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every
site that used to answer 'may this caller supply entitlement policy'
differently now asks resolveAppCaller and produces the same 401
app_auth_required on null -- one predicate, one failure shape, applied
uniformly across OAuth start, session refresh, patron-support/sync, and
managed-credentials/resolve, regardless of handoff kind or app slug (R5,
fork 1 option B).
startOAuthFlow and refreshAppSession are extracted from the route handlers
(a move, not new logic) so the private command plane can reach the same
authority without an HTTP self-call: it already authenticates a command as
appSlug by opening the envelope with that app's shared secret and checking
sourceRuntimeId, so it constructs the AppCaller directly and calls the
handler function. The two forged app.inject calls that set
x-heimdall-app-secret from inside the process are gone; the plane's
auth-completions/redeem self-call is untouched (that route's authority is the
one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
|
|
|
if (operation === "heimdall.auth.refresh") return await refreshAuth(app, appSlug, payload);
|
2026-08-22 01:53:21 +02:00
|
|
|
if (operation === "heimdall.auth.logout") return await logoutAuth(app, config, appSlug, payload);
|
2026-08-22 00:44:57 +02:00
|
|
|
throw new Error(`Unsupported Heimdall private operation '${operation}'.`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 01:53:21 +02:00
|
|
|
async function logoutAuth(
|
|
|
|
|
app: FastifyInstance,
|
|
|
|
|
config: HeimdallConfig,
|
|
|
|
|
appSlug: AppSlug,
|
|
|
|
|
payload: Record<string, unknown>,
|
|
|
|
|
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
|
|
|
|
|
const refreshToken = String(payload.refreshToken ?? "");
|
|
|
|
|
if (!refreshToken) throw new Error("Auth logout requires the app's encrypted refresh claim.");
|
|
|
|
|
const context = getHeimdallRuntimeContext(app);
|
|
|
|
|
const claim = verifyRefreshToken(refreshToken, appSlug, config, context.keys);
|
|
|
|
|
if (!claim) throw new Error("Auth logout received an invalid or expired refresh claim.");
|
|
|
|
|
const revokedAt = new Date().toISOString();
|
|
|
|
|
const session = await context.store.revokeSession(
|
|
|
|
|
appSlug,
|
|
|
|
|
claim.sid,
|
|
|
|
|
claim.account_id,
|
|
|
|
|
claim.access_revision,
|
|
|
|
|
revokedAt,
|
|
|
|
|
);
|
|
|
|
|
if (!session || session.accessRevision !== claim.access_revision + 1) {
|
|
|
|
|
throw new Error("Auth logout could not revoke the exact session custody claim.");
|
|
|
|
|
}
|
|
|
|
|
await context.store.createAuditEvent({
|
|
|
|
|
accountId: claim.account_id,
|
|
|
|
|
sessionId: claim.sid,
|
|
|
|
|
appSlug,
|
|
|
|
|
eventType: "session_revoked",
|
|
|
|
|
eventPayloadJson: { previousAccessRevision: claim.access_revision, accessRevision: session.accessRevision },
|
|
|
|
|
createdAt: revokedAt,
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
status: "accepted",
|
|
|
|
|
payloadSchema: "heimdall.auth_logout_receipt.v1",
|
|
|
|
|
payload: {
|
|
|
|
|
schema: "heimdall.auth_logout_receipt.v1",
|
|
|
|
|
status: "revoked",
|
|
|
|
|
sessionId: claim.sid,
|
|
|
|
|
accessRevision: session.accessRevision,
|
|
|
|
|
revokedAt,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 01:27:28 +02:00
|
|
|
async function refreshAuth(
|
|
|
|
|
app: FastifyInstance,
|
|
|
|
|
appSlug: AppSlug,
|
|
|
|
|
payload: Record<string, unknown>,
|
|
|
|
|
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
|
|
|
|
|
const refreshToken = String(payload.refreshToken ?? "");
|
|
|
|
|
if (!refreshToken) throw new Error("Auth refresh requires the app's encrypted refresh claim.");
|
|
|
|
|
const entitlementPolicy = parseEntitlementPolicy(payload.entitlementPolicy);
|
Add the single caller-identity organ; delete the five ad hoc predicates it replaces
src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only
code that decides 'this HTTP request is app X'. It reads one header
(x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and
compares with the existing timing-safe secretMatches, moved here unchanged.
Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the
handoff-kind trust (handoff.kind === 'backend_callback' implying identity),
trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every
site that used to answer 'may this caller supply entitlement policy'
differently now asks resolveAppCaller and produces the same 401
app_auth_required on null -- one predicate, one failure shape, applied
uniformly across OAuth start, session refresh, patron-support/sync, and
managed-credentials/resolve, regardless of handoff kind or app slug (R5,
fork 1 option B).
startOAuthFlow and refreshAppSession are extracted from the route handlers
(a move, not new logic) so the private command plane can reach the same
authority without an HTTP self-call: it already authenticates a command as
appSlug by opening the envelope with that app's shared secret and checking
sourceRuntimeId, so it constructs the AppCaller directly and calls the
handler function. The two forged app.inject calls that set
x-heimdall-app-secret from inside the process are gone; the plane's
auth-completions/redeem self-call is untouched (that route's authority is the
one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
|
|
|
// The plane already authenticated this call as `appSlug` by opening the
|
|
|
|
|
// envelope with that app's shared secret and checking sourceRuntimeId; it
|
2026-09-08 14:22:48 +02:00
|
|
|
// gets its AppCaller from the one other constructor that authority owns
|
|
|
|
|
// (app-caller.ts) instead of writing the literal here, and calls the same
|
|
|
|
|
// handler function the HTTP route uses rather than forging the HTTP header
|
|
|
|
|
// back at itself.
|
Add the single caller-identity organ; delete the five ad hoc predicates it replaces
src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only
code that decides 'this HTTP request is app X'. It reads one header
(x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and
compares with the existing timing-safe secretMatches, moved here unchanged.
Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the
handoff-kind trust (handoff.kind === 'backend_callback' implying identity),
trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every
site that used to answer 'may this caller supply entitlement policy'
differently now asks resolveAppCaller and produces the same 401
app_auth_required on null -- one predicate, one failure shape, applied
uniformly across OAuth start, session refresh, patron-support/sync, and
managed-credentials/resolve, regardless of handoff kind or app slug (R5,
fork 1 option B).
startOAuthFlow and refreshAppSession are extracted from the route handlers
(a move, not new logic) so the private command plane can reach the same
authority without an HTTP self-call: it already authenticates a command as
appSlug by opening the envelope with that app's shared secret and checking
sourceRuntimeId, so it constructs the AppCaller directly and calls the
handler function. The two forged app.inject calls that set
x-heimdall-app-secret from inside the process are gone; the plane's
auth-completions/redeem self-call is untouched (that route's authority is the
one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
|
|
|
const context = getHeimdallRuntimeContext(app);
|
R21.3: delete the unreachable registered-app resolution path
The runtime app-registration writer (POST /v1/apps) was deleted in an
earlier pass because it minted a client_secret nobody verified. This
pass kept resolveAppProfile's registered-app fallback and
listAppProfiles on the theory that AppSlug is an open string and
dynamic registration was a live extension point. Soul falsified that:
every HTTP route enumerates the four built-in slugs in its own JSON
schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body
field), so a registered row could never be resolved from a request.
With the writer already gone, this was unreachable code behind a dead
writer -- delete it rather than keep authenticating a door nobody can
open.
Deleted: src/app-registry.ts entirely (resolveAppProfile,
listAppProfiles, isBuiltInApp, registeredAppToProfile,
returnOriginsFromRedirectUris); StoredRegisteredApp and the
findRegisteredApp/listRegisteredApps HeimdallStore methods
(store/types.ts); their InMemoryStore and PostgresStore
implementations, including seedRegisteredApp and mapRegisteredApp; the
registered_apps table (store/schema.ts now drops it on any store built
before this schema); tests/app-registry.test.ts.
Every call site (app.ts x5, claims.ts, private-command-plane.ts) now
calls the existing built-in-only getAppProfile (app-profiles.ts)
directly -- sync, no store needed. If dynamic app registration is
wanted later it returns as a designed, authenticated feature, not by
resurrecting this vestige.
Net -305 lines against the prior commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:23 +02:00
|
|
|
await requireEntitlementPolicyIfProfileDemandsIt(appSlug, entitlementPolicy, "refresh");
|
2026-09-08 14:22:48 +02:00
|
|
|
const result = await refreshAppSession(context, appSlug, callerFromOpenedEnvelope(appSlug), {
|
Add the single caller-identity organ; delete the five ad hoc predicates it replaces
src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only
code that decides 'this HTTP request is app X'. It reads one header
(x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and
compares with the existing timing-safe secretMatches, moved here unchanged.
Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the
handoff-kind trust (handoff.kind === 'backend_callback' implying identity),
trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every
site that used to answer 'may this caller supply entitlement policy'
differently now asks resolveAppCaller and produces the same 401
app_auth_required on null -- one predicate, one failure shape, applied
uniformly across OAuth start, session refresh, patron-support/sync, and
managed-credentials/resolve, regardless of handoff kind or app slug (R5,
fork 1 option B).
startOAuthFlow and refreshAppSession are extracted from the route handlers
(a move, not new logic) so the private command plane can reach the same
authority without an HTTP self-call: it already authenticates a command as
appSlug by opening the envelope with that app's shared secret and checking
sourceRuntimeId, so it constructs the AppCaller directly and calls the
handler function. The two forged app.inject calls that set
x-heimdall-app-secret from inside the process are gone; the plane's
auth-completions/redeem self-call is untouched (that route's authority is the
one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
|
|
|
refreshToken,
|
|
|
|
|
...(entitlementPolicy ? { entitlementPolicy } : {}),
|
2026-08-22 01:27:28 +02:00
|
|
|
});
|
Add the single caller-identity organ; delete the five ad hoc predicates it replaces
src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only
code that decides 'this HTTP request is app X'. It reads one header
(x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and
compares with the existing timing-safe secretMatches, moved here unchanged.
Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the
handoff-kind trust (handoff.kind === 'backend_callback' implying identity),
trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every
site that used to answer 'may this caller supply entitlement policy'
differently now asks resolveAppCaller and produces the same 401
app_auth_required on null -- one predicate, one failure shape, applied
uniformly across OAuth start, session refresh, patron-support/sync, and
managed-credentials/resolve, regardless of handoff kind or app slug (R5,
fork 1 option B).
startOAuthFlow and refreshAppSession are extracted from the route handlers
(a move, not new logic) so the private command plane can reach the same
authority without an HTTP self-call: it already authenticates a command as
appSlug by opening the envelope with that app's shared secret and checking
sourceRuntimeId, so it constructs the AppCaller directly and calls the
handler function. The two forged app.inject calls that set
x-heimdall-app-secret from inside the process are gone; the plane's
auth-completions/redeem self-call is untouched (that route's authority is the
one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
|
|
|
const refreshedPayload = result.body as Record<string, unknown>;
|
|
|
|
|
if (result.statusCode !== 201) {
|
2026-08-22 01:27:28 +02:00
|
|
|
return {
|
2026-08-22 12:01:56 +02:00
|
|
|
status: "accepted",
|
2026-08-22 01:27:28 +02:00
|
|
|
payloadSchema: "heimdall.auth_refresh_receipt.v1",
|
|
|
|
|
payload: {
|
|
|
|
|
schema: "heimdall.auth_refresh_receipt.v1",
|
|
|
|
|
status: "denied",
|
|
|
|
|
error: String(refreshedPayload.error ?? "refresh_denied"),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
status: "accepted",
|
|
|
|
|
payloadSchema: "heimdall.auth_refresh_receipt.v1",
|
|
|
|
|
payload: { ...refreshedPayload, schema: "heimdall.auth_refresh_receipt.v1", status: "authenticated" },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 00:44:57 +02:00
|
|
|
async function beginAuth(
|
|
|
|
|
app: FastifyInstance,
|
|
|
|
|
config: HeimdallConfig,
|
|
|
|
|
appSlug: AppSlug,
|
|
|
|
|
payload: Record<string, unknown>,
|
|
|
|
|
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
|
|
|
|
|
const provider = String(payload.provider ?? "discord") as Provider;
|
|
|
|
|
const mode = String(payload.mode ?? "sign_in") as OAuthMode;
|
|
|
|
|
const returnTo = String(payload.returnTo ?? "");
|
|
|
|
|
if (!providers.includes(provider) || !oauthModes.includes(mode) || !returnTo) throw new Error("Auth begin payload is incomplete.");
|
|
|
|
|
const entitlementPolicy = parseEntitlementPolicy(payload.entitlementPolicy);
|
2026-09-08 14:22:48 +02:00
|
|
|
const store = getHeimdallRuntimeContext(app).store;
|
R21.3: delete the unreachable registered-app resolution path
The runtime app-registration writer (POST /v1/apps) was deleted in an
earlier pass because it minted a client_secret nobody verified. This
pass kept resolveAppProfile's registered-app fallback and
listAppProfiles on the theory that AppSlug is an open string and
dynamic registration was a live extension point. Soul falsified that:
every HTTP route enumerates the four built-in slugs in its own JSON
schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body
field), so a registered row could never be resolved from a request.
With the writer already gone, this was unreachable code behind a dead
writer -- delete it rather than keep authenticating a door nobody can
open.
Deleted: src/app-registry.ts entirely (resolveAppProfile,
listAppProfiles, isBuiltInApp, registeredAppToProfile,
returnOriginsFromRedirectUris); StoredRegisteredApp and the
findRegisteredApp/listRegisteredApps HeimdallStore methods
(store/types.ts); their InMemoryStore and PostgresStore
implementations, including seedRegisteredApp and mapRegisteredApp; the
registered_apps table (store/schema.ts now drops it on any store built
before this schema); tests/app-registry.test.ts.
Every call site (app.ts x5, claims.ts, private-command-plane.ts) now
calls the existing built-in-only getAppProfile (app-profiles.ts)
directly -- sync, no store needed. If dynamic app registration is
wanted later it returns as a designed, authenticated feature, not by
resurrecting this vestige.
Net -305 lines against the prior commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:23 +02:00
|
|
|
await requireEntitlementPolicyIfProfileDemandsIt(appSlug, entitlementPolicy, "authentication");
|
2026-08-22 00:44:57 +02:00
|
|
|
const now = new Date();
|
2026-09-08 14:22:48 +02:00
|
|
|
const attempt = await store.createAuthAttempt({
|
2026-08-22 00:44:57 +02:00
|
|
|
handle: randomUUID(),
|
|
|
|
|
appSlug,
|
|
|
|
|
provider,
|
|
|
|
|
mode,
|
|
|
|
|
returnTo,
|
|
|
|
|
createdAt: now.toISOString(),
|
|
|
|
|
expiresAt: new Date(now.getTime() + config.stateTtlSeconds * 1000).toISOString(),
|
|
|
|
|
});
|
2026-09-08 14:22:48 +02:00
|
|
|
// Same authority, same non-HTTP entry as refreshAuth above.
|
Add the single caller-identity organ; delete the five ad hoc predicates it replaces
src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only
code that decides 'this HTTP request is app X'. It reads one header
(x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and
compares with the existing timing-safe secretMatches, moved here unchanged.
Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the
handoff-kind trust (handoff.kind === 'backend_callback' implying identity),
trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every
site that used to answer 'may this caller supply entitlement policy'
differently now asks resolveAppCaller and produces the same 401
app_auth_required on null -- one predicate, one failure shape, applied
uniformly across OAuth start, session refresh, patron-support/sync, and
managed-credentials/resolve, regardless of handoff kind or app slug (R5,
fork 1 option B).
startOAuthFlow and refreshAppSession are extracted from the route handlers
(a move, not new logic) so the private command plane can reach the same
authority without an HTTP self-call: it already authenticates a command as
appSlug by opening the envelope with that app's shared secret and checking
sourceRuntimeId, so it constructs the AppCaller directly and calls the
handler function. The two forged app.inject calls that set
x-heimdall-app-secret from inside the process are gone; the plane's
auth-completions/redeem self-call is untouched (that route's authority is the
one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
|
|
|
const context = getHeimdallRuntimeContext(app);
|
2026-09-08 14:43:33 +02:00
|
|
|
const start = await startOAuthFlow(
|
|
|
|
|
{ config: context.config, keys: context.keys, store: context.store },
|
|
|
|
|
provider,
|
|
|
|
|
callerFromOpenedEnvelope(appSlug),
|
|
|
|
|
{
|
|
|
|
|
appSlug,
|
|
|
|
|
mode,
|
|
|
|
|
returnTo,
|
|
|
|
|
handoff: { kind: "browser_completion" },
|
|
|
|
|
...(entitlementPolicy ? { entitlementPolicy } : {}),
|
|
|
|
|
},
|
|
|
|
|
{ trustedBrowserAttemptId: attempt.handle }
|
|
|
|
|
);
|
Add the single caller-identity organ; delete the five ad hoc predicates it replaces
src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only
code that decides 'this HTTP request is app X'. It reads one header
(x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and
compares with the existing timing-safe secretMatches, moved here unchanged.
Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the
handoff-kind trust (handoff.kind === 'backend_callback' implying identity),
trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every
site that used to answer 'may this caller supply entitlement policy'
differently now asks resolveAppCaller and produces the same 401
app_auth_required on null -- one predicate, one failure shape, applied
uniformly across OAuth start, session refresh, patron-support/sync, and
managed-credentials/resolve, regardless of handoff kind or app slug (R5,
fork 1 option B).
startOAuthFlow and refreshAppSession are extracted from the route handlers
(a move, not new logic) so the private command plane can reach the same
authority without an HTTP self-call: it already authenticates a command as
appSlug by opening the envelope with that app's shared secret and checking
sourceRuntimeId, so it constructs the AppCaller directly and calls the
handler function. The two forged app.inject calls that set
x-heimdall-app-secret from inside the process are gone; the plane's
auth-completions/redeem self-call is untouched (that route's authority is the
one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
|
|
|
const startPayload = start.body as Record<string, unknown>;
|
2026-08-22 00:44:57 +02:00
|
|
|
if (start.statusCode !== 201) {
|
Add the single caller-identity organ; delete the five ad hoc predicates it replaces
src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only
code that decides 'this HTTP request is app X'. It reads one header
(x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and
compares with the existing timing-safe secretMatches, moved here unchanged.
Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the
handoff-kind trust (handoff.kind === 'backend_callback' implying identity),
trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every
site that used to answer 'may this caller supply entitlement policy'
differently now asks resolveAppCaller and produces the same 401
app_auth_required on null -- one predicate, one failure shape, applied
uniformly across OAuth start, session refresh, patron-support/sync, and
managed-credentials/resolve, regardless of handoff kind or app slug (R5,
fork 1 option B).
startOAuthFlow and refreshAppSession are extracted from the route handlers
(a move, not new logic) so the private command plane can reach the same
authority without an HTTP self-call: it already authenticates a command as
appSlug by opening the envelope with that app's shared secret and checking
sourceRuntimeId, so it constructs the AppCaller directly and calls the
handler function. The two forged app.inject calls that set
x-heimdall-app-secret from inside the process are gone; the plane's
auth-completions/redeem self-call is untouched (that route's authority is the
one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
|
|
|
await context.store.updateAuthAttempt(appSlug, attempt.handle, {
|
2026-08-22 00:44:57 +02:00
|
|
|
status: "denied",
|
|
|
|
|
at: new Date().toISOString(),
|
|
|
|
|
denialCode: String(startPayload.error ?? "oauth_start_denied"),
|
|
|
|
|
});
|
|
|
|
|
throw new Error(String(startPayload.detail ?? startPayload.error ?? "OAuth start was denied."));
|
|
|
|
|
}
|
|
|
|
|
const authorizationUrl = String(startPayload.authorizationUrl ?? "");
|
|
|
|
|
const authorizationOrigin = new URL(authorizationUrl).origin;
|
|
|
|
|
return {
|
|
|
|
|
status: "accepted",
|
|
|
|
|
payloadSchema: "heimdall.auth_begin_receipt.v1",
|
|
|
|
|
payload: {
|
|
|
|
|
schema: "heimdall.auth_begin_receipt.v1",
|
|
|
|
|
status: "pending",
|
|
|
|
|
handle: attempt.handle,
|
|
|
|
|
expiresAt: attempt.expiresAt,
|
|
|
|
|
navigation: {
|
|
|
|
|
url: authorizationUrl,
|
|
|
|
|
allowedOrigins: [authorizationOrigin, new URL(config.publicBaseUrl).origin],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function completeAuth(
|
|
|
|
|
app: FastifyInstance,
|
|
|
|
|
appSlug: AppSlug,
|
|
|
|
|
payload: Record<string, unknown>,
|
|
|
|
|
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
|
|
|
|
|
const handle = String(payload.handle ?? "");
|
|
|
|
|
if (!handle) throw new Error("Auth completion requires an opaque attempt handle.");
|
|
|
|
|
const store = getHeimdallRuntimeContext(app).store;
|
|
|
|
|
const attempt = await store.findAuthAttempt(appSlug, handle);
|
|
|
|
|
if (!attempt) throw new Error("Auth attempt is unknown for this app.");
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
if (attempt.expiresAt <= now && attempt.status !== "completed") {
|
|
|
|
|
await store.updateAuthAttempt(appSlug, handle, { status: "expired", at: now });
|
2026-08-22 12:01:56 +02:00
|
|
|
return authCompletion("denied", { handle, error: "expired_attempt" });
|
2026-08-22 00:44:57 +02:00
|
|
|
}
|
|
|
|
|
if (attempt.status === "pending") return authCompletion("pending", { handle });
|
2026-08-22 12:01:56 +02:00
|
|
|
if (attempt.status !== "completed") {
|
|
|
|
|
return authCompletion("denied", { handle, error: attempt.denialCode ?? `attempt_${attempt.status}` });
|
|
|
|
|
}
|
2026-09-08 14:22:48 +02:00
|
|
|
// Consume by the attempt handle directly rather than forging an HTTP
|
|
|
|
|
// self-call into the public redeem route: the plane already authenticated
|
|
|
|
|
// `appSlug`, and the completion code is a secret this process never needs
|
|
|
|
|
// to see, let alone pass back through its own front door.
|
|
|
|
|
const completion = await store.consumeAuthCompletionByAttempt(appSlug, handle, now);
|
|
|
|
|
if (!completion) {
|
2026-08-22 12:01:56 +02:00
|
|
|
return authCompletion("denied", { handle, error: "invalid_or_expired_completion" });
|
|
|
|
|
}
|
2026-09-08 14:22:48 +02:00
|
|
|
await store.createAuditEvent({
|
|
|
|
|
accountId: completion.accountId,
|
|
|
|
|
sessionId: completion.sessionId,
|
|
|
|
|
appSlug: completion.appSlug,
|
|
|
|
|
eventType: "auth_completion_redeemed",
|
|
|
|
|
eventPayloadJson: {
|
|
|
|
|
provider: completion.provider,
|
|
|
|
|
mode: completion.mode,
|
|
|
|
|
},
|
|
|
|
|
createdAt: now,
|
|
|
|
|
});
|
|
|
|
|
await store.updateAuthAttempt(appSlug, handle, { status: "consumed", at: now });
|
|
|
|
|
return authCompletion("authenticated", { handle, ...completion.payloadJson });
|
2026-08-22 00:44:57 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function authCompletion(status: string, values: Record<string, unknown>) {
|
|
|
|
|
return {
|
2026-08-22 12:01:56 +02:00
|
|
|
// CultNet reports whether the private command executed. Authentication state
|
|
|
|
|
// remains inside the typed receipt so callers can decode pending and denied
|
|
|
|
|
// outcomes instead of mistaking them for transport failures.
|
|
|
|
|
status: "accepted",
|
2026-08-22 00:44:57 +02:00
|
|
|
payloadSchema: "heimdall.auth_completion_receipt.v1",
|
|
|
|
|
payload: { ...values, schema: "heimdall.auth_completion_receipt.v1", status },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseEntitlementPolicy(value: unknown): OAuthEntitlementPolicy | undefined {
|
|
|
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
|
|
|
const record = value as Record<string, unknown>;
|
|
|
|
|
if (record.kind === "discord_role_access" && typeof record.guildId === "string" && Array.isArray(record.allowedRoleIds)) {
|
|
|
|
|
const allowedRoleIds = record.allowedRoleIds.filter((item): item is string => typeof item === "string" && Boolean(item));
|
|
|
|
|
return allowedRoleIds.length ? { kind: "discord_role_access", guildId: record.guildId, allowedRoleIds } : undefined;
|
|
|
|
|
}
|
|
|
|
|
if (record.kind === "patreon_membership_access" && typeof record.requiredTierTitle === "string") {
|
|
|
|
|
return { kind: "patreon_membership_access", requiredTierTitle: record.requiredTierTitle };
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 14:22:48 +02:00
|
|
|
/**
|
|
|
|
|
* Ghostlight requires its caller-owned Discord role policy on every begin and
|
|
|
|
|
* refresh; that used to be a literal `appSlug === "ghostlight"` branch here.
|
|
|
|
|
* The requirement is now data on the app's profile
|
|
|
|
|
* (AppProfile.requiredEntitlementPolicyKind), so a future app with the same
|
|
|
|
|
* need declares it in its profile instead of adding another branch.
|
|
|
|
|
*/
|
|
|
|
|
async function requireEntitlementPolicyIfProfileDemandsIt(
|
|
|
|
|
appSlug: AppSlug,
|
|
|
|
|
entitlementPolicy: OAuthEntitlementPolicy | undefined,
|
|
|
|
|
action: "authentication" | "refresh",
|
|
|
|
|
): Promise<void> {
|
R21.3: delete the unreachable registered-app resolution path
The runtime app-registration writer (POST /v1/apps) was deleted in an
earlier pass because it minted a client_secret nobody verified. This
pass kept resolveAppProfile's registered-app fallback and
listAppProfiles on the theory that AppSlug is an open string and
dynamic registration was a live extension point. Soul falsified that:
every HTTP route enumerates the four built-in slugs in its own JSON
schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body
field), so a registered row could never be resolved from a request.
With the writer already gone, this was unreachable code behind a dead
writer -- delete it rather than keep authenticating a door nobody can
open.
Deleted: src/app-registry.ts entirely (resolveAppProfile,
listAppProfiles, isBuiltInApp, registeredAppToProfile,
returnOriginsFromRedirectUris); StoredRegisteredApp and the
findRegisteredApp/listRegisteredApps HeimdallStore methods
(store/types.ts); their InMemoryStore and PostgresStore
implementations, including seedRegisteredApp and mapRegisteredApp; the
registered_apps table (store/schema.ts now drops it on any store built
before this schema); tests/app-registry.test.ts.
Every call site (app.ts x5, claims.ts, private-command-plane.ts) now
calls the existing built-in-only getAppProfile (app-profiles.ts)
directly -- sync, no store needed. If dynamic app registration is
wanted later it returns as a designed, authenticated feature, not by
resurrecting this vestige.
Net -305 lines against the prior commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:23 +02:00
|
|
|
const profile = getAppProfile(appSlug);
|
2026-09-08 14:22:48 +02:00
|
|
|
const requiredKind = profile?.requiredEntitlementPolicyKind;
|
|
|
|
|
if (requiredKind && entitlementPolicy?.kind !== requiredKind) {
|
|
|
|
|
throw new Error(`${appSlug} ${action} requires its caller-owned ${requiredKind} policy.`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 00:44:57 +02:00
|
|
|
function response(
|
|
|
|
|
request: CultNetOperationRequestMessage,
|
|
|
|
|
status: string,
|
|
|
|
|
envelope: HeimdallPrivateEnvelope,
|
|
|
|
|
runtimeId: string,
|
|
|
|
|
): CultNetOperationResponseMessage {
|
|
|
|
|
return {
|
|
|
|
|
schemaVersion: "cultnet.operation_response.v0",
|
|
|
|
|
messageId: request.messageId,
|
|
|
|
|
serviceId: request.serviceId,
|
|
|
|
|
operation: request.operation,
|
|
|
|
|
status,
|
|
|
|
|
payloadSchema: HEIMDALL_PRIVATE_ENVELOPE_SCHEMA,
|
|
|
|
|
payloadEncoding: "messagepack-base64",
|
|
|
|
|
payload: Buffer.from(encode(envelope)).toString("base64"),
|
|
|
|
|
diagnostics: [],
|
|
|
|
|
sourceRuntimeId: runtimeId,
|
|
|
|
|
};
|
|
|
|
|
}
|