Delete the runtime app registration surface (R7, fork 3)
registerApp minted a client_secret whose hash nothing ever verified (six routes gate on the shared-secret caller-identity check, not on registration); registered_apps sits empty on the live host. Deleted: POST /v1/apps, getRegistrationSecret, config.appRegistrationSecret, registerApp/ hashClientSecret/verifyClientSecret, the client_secret_hash column (dropped idempotently on startup, same as it is created), and tests/app-registration-endpoint.test.ts. registered_apps and resolveAppProfile stay as a profile store; app-registry.test.ts fixtures that used registerApp purely to seed a profile now write through store.registerApp directly, the same primitive the built-in-shadow test already used.
This commit is contained in:
parent
326f613ffe
commit
776565effd
9 changed files with 45 additions and 376 deletions
|
|
@ -10,8 +10,6 @@
|
|||
// profile, which is what stops a registration from granting itself something
|
||||
// Heimdall never decided was true.
|
||||
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
import { builtInAppProfiles, type AppProfile } from "./app-profiles.js";
|
||||
import { validateCapabilityRules, type CapabilityDefinition } from "./capability-rules.js";
|
||||
import { providers, type Provider } from "./contracts.js";
|
||||
|
|
@ -36,12 +34,6 @@ export interface RegistrationProblem {
|
|||
reason: string;
|
||||
}
|
||||
|
||||
export interface AppRegistrationResult {
|
||||
app: StoredRegisteredApp;
|
||||
/** Returned once, at registration. Only its hash is stored. */
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export function isBuiltInApp(slug: string): boolean {
|
||||
return Object.hasOwn(builtInAppProfiles, slug);
|
||||
}
|
||||
|
|
@ -122,47 +114,6 @@ export function validateAppRegistration(request: AppRegistrationRequest): Regist
|
|||
return problems;
|
||||
}
|
||||
|
||||
export function hashClientSecret(secret: string): string {
|
||||
// A generated 256-bit secret does not need a slow KDF; it needs to not be
|
||||
// stored in the clear. Passwords would be a different question.
|
||||
return createHash("sha256").update(secret, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
export function verifyClientSecret(secret: string, storedHash: string | null): boolean {
|
||||
if (!storedHash) return false;
|
||||
const candidate = Buffer.from(hashClientSecret(secret), "hex");
|
||||
const expected = Buffer.from(storedHash, "hex");
|
||||
if (candidate.length !== expected.length) return false;
|
||||
return timingSafeEqual(candidate, expected);
|
||||
}
|
||||
|
||||
export async function registerApp(
|
||||
store: HeimdallStore,
|
||||
request: AppRegistrationRequest,
|
||||
registeredAt: string = new Date().toISOString(),
|
||||
): Promise<AppRegistrationResult> {
|
||||
const problems = validateAppRegistration(request);
|
||||
if (problems.length > 0) {
|
||||
throw new AppRegistrationError(problems);
|
||||
}
|
||||
|
||||
const clientSecret = randomBytes(32).toString("base64url");
|
||||
const app = await store.registerApp({
|
||||
slug: request.slug,
|
||||
displayName: request.displayName.trim(),
|
||||
profileVersion: request.profileVersion?.trim() || registeredAt.slice(0, 10),
|
||||
registeredAt,
|
||||
identityProviders: request.identityProviders as Provider[],
|
||||
entitlementSources: (request.entitlementSources ?? []) as Provider[],
|
||||
managedConnectionProviders: (request.managedConnectionProviders ?? []) as Provider[],
|
||||
capabilities: request.capabilities,
|
||||
redirectUris: request.redirectUris,
|
||||
clientSecretHash: hashClientSecret(clientSecret),
|
||||
});
|
||||
|
||||
return { app, clientSecret };
|
||||
}
|
||||
|
||||
export class AppRegistrationError extends Error {
|
||||
constructor(readonly problems: RegistrationProblem[]) {
|
||||
super(`App registration rejected: ${problems.map((problem) => problem.reason).join(" ")}`);
|
||||
|
|
|
|||
61
src/app.ts
61
src/app.ts
|
|
@ -23,14 +23,7 @@ import {
|
|||
} from "./contracts.js";
|
||||
import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js";
|
||||
import { builtInAppProfiles, getAppProfile, serializeAppProfile, supportsProvider } from "./app-profiles.js";
|
||||
import {
|
||||
AppRegistrationError,
|
||||
listAppProfiles,
|
||||
registerApp,
|
||||
registeredAppToProfile,
|
||||
resolveAppProfile,
|
||||
type AppRegistrationRequest,
|
||||
} from "./app-registry.js";
|
||||
import { listAppProfiles, resolveAppProfile } from "./app-registry.js";
|
||||
import { renderBrowserHandoffPage } from "./browser-handoff.js";
|
||||
import { type HeimdallConfig, loadConfig } from "./config.js";
|
||||
import { createTokenCustody, type TokenCustody } from "./custody.js";
|
||||
|
|
@ -338,11 +331,6 @@ function getSharedSecret(request: { headers: Record<string, string | string[] |
|
|||
return Array.isArray(header) ? header[0] : header;
|
||||
}
|
||||
|
||||
function getRegistrationSecret(request: { headers: Record<string, unknown> }): string | undefined {
|
||||
const header = request.headers["x-heimdall-registration-secret"];
|
||||
return typeof header === "string" ? header : undefined;
|
||||
}
|
||||
|
||||
function secretMatches(expected: string | undefined, provided: string | undefined): boolean {
|
||||
if (!expected || !provided) {
|
||||
return false;
|
||||
|
|
@ -563,53 +551,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
apps: (await listAppProfiles(store)).map(serializeAppProfile),
|
||||
}));
|
||||
|
||||
// POST /v1/apps registration.
|
||||
//
|
||||
// Gated on an operator secret rather than open. Heimdall is an OAuth
|
||||
// provider: an unauthenticated registration endpoint lets anyone create a
|
||||
// client that starts Discord and Patreon flows under this instance identity,
|
||||
// which is a phishing surface wearing the operator name. Registration is
|
||||
// closed until an operator sets one, so a fresh instance is not accidentally
|
||||
// open.
|
||||
app.post<{ Body: AppRegistrationRequest }>("/v1/apps", async (request, reply) => {
|
||||
if (!config.appRegistrationSecret) {
|
||||
reply.code(404);
|
||||
return {
|
||||
error: "registration_closed",
|
||||
detail: "Runtime app registration is not enabled on this instance.",
|
||||
};
|
||||
}
|
||||
if (!secretMatches(config.appRegistrationSecret, getRegistrationSecret(request))) {
|
||||
reply.code(401);
|
||||
return { error: "unauthorized", detail: "A valid registration secret is required." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { app: registered, clientSecret } = await registerApp(store, request.body);
|
||||
reply.code(201);
|
||||
// RFC 7591 response shape, so an ordinary OAuth client library can read
|
||||
// it. client_secret is returned exactly once; only its hash is stored.
|
||||
return {
|
||||
client_id: registered.slug,
|
||||
client_secret: clientSecret,
|
||||
client_name: registered.displayName,
|
||||
redirect_uris: registered.redirectUris,
|
||||
client_id_issued_at: Math.floor(Date.parse(registered.createdAt) / 1000),
|
||||
profile: serializeAppProfile(registeredAppToProfile(registered)),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppRegistrationError) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "invalid_client_metadata",
|
||||
detail: "App registration was rejected.",
|
||||
problems: error.problems,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { appSlug: AppSlug } }>(
|
||||
"/v1/apps/:appSlug",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -44,8 +44,6 @@ export interface HeimdallConfig {
|
|||
appRuntimeIds?: Partial<Record<AppSlug, string[]>>;
|
||||
appBackendCallbacks: Partial<Record<AppSlug, string[]>>;
|
||||
bifrostPatronSupportEndpoint?: string;
|
||||
/** Operator secret gating runtime app registration. Unset means registration is closed. */
|
||||
appRegistrationSecret?: string;
|
||||
bifrostPatronSupportSecret?: string;
|
||||
storage: StorageConfig;
|
||||
providers: Record<Provider, ProviderClientConfig>;
|
||||
|
|
@ -302,9 +300,6 @@ export function loadConfig(
|
|||
}
|
||||
|
||||
if (env.GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET) {
|
||||
if (env.GC_ACCESS_APP_REGISTRATION_SECRET) {
|
||||
config.appRegistrationSecret = env.GC_ACCESS_APP_REGISTRATION_SECRET;
|
||||
}
|
||||
config.bifrostPatronSupportSecret = env.GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ export class InMemoryStore implements HeimdallStore {
|
|||
managedConnectionProviders: [...input.managedConnectionProviders],
|
||||
capabilities: structuredClone(input.capabilities),
|
||||
redirectUris: [...input.redirectUris],
|
||||
clientSecretHash: input.clientSecretHash,
|
||||
};
|
||||
this.registeredApps.set(record.slug, record);
|
||||
return structuredClone(record);
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@ interface RegisteredAppRow extends QueryResultRow {
|
|||
managed_connection_providers: Provider[];
|
||||
capabilities_json: CapabilityDefinition[];
|
||||
redirect_uris: string[];
|
||||
client_secret_hash: string | null;
|
||||
}
|
||||
|
||||
interface SessionRow extends QueryResultRow {
|
||||
|
|
@ -308,9 +307,9 @@ export class PostgresStore implements HeimdallStore {
|
|||
INSERT INTO registered_apps (
|
||||
slug, display_name, profile_version, created_at, updated_at,
|
||||
identity_providers, entitlement_sources, managed_connection_providers,
|
||||
capabilities_json, redirect_uris, client_secret_hash
|
||||
capabilities_json, redirect_uris
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $4, $5, $6, $7, $8, $9, $10)
|
||||
VALUES ($1, $2, $3, $4, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (slug)
|
||||
DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
|
|
@ -320,8 +319,7 @@ export class PostgresStore implements HeimdallStore {
|
|||
entitlement_sources = EXCLUDED.entitlement_sources,
|
||||
managed_connection_providers = EXCLUDED.managed_connection_providers,
|
||||
capabilities_json = EXCLUDED.capabilities_json,
|
||||
redirect_uris = EXCLUDED.redirect_uris,
|
||||
client_secret_hash = COALESCE(EXCLUDED.client_secret_hash, registered_apps.client_secret_hash)
|
||||
redirect_uris = EXCLUDED.redirect_uris
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
|
|
@ -334,7 +332,6 @@ export class PostgresStore implements HeimdallStore {
|
|||
JSON.stringify(input.managedConnectionProviders),
|
||||
JSON.stringify(input.capabilities),
|
||||
JSON.stringify(input.redirectUris),
|
||||
input.clientSecretHash,
|
||||
],
|
||||
);
|
||||
return mapRegisteredApp(result.rows[0]!);
|
||||
|
|
@ -860,6 +857,5 @@ function mapRegisteredApp(row: RegisteredAppRow): StoredRegisteredApp {
|
|||
managedConnectionProviders: row.managed_connection_providers,
|
||||
capabilities: row.capabilities_json,
|
||||
redirectUris: row.redirect_uris,
|
||||
clientSecretHash: row.client_secret_hash,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,14 @@ CREATE TABLE IF NOT EXISTS registered_apps (
|
|||
entitlement_sources JSONB NOT NULL,
|
||||
managed_connection_providers JSONB NOT NULL,
|
||||
capabilities_json JSONB NOT NULL,
|
||||
redirect_uris JSONB NOT NULL,
|
||||
client_secret_hash TEXT
|
||||
redirect_uris JSONB NOT NULL
|
||||
);
|
||||
|
||||
-- Runtime app registration (POST /v1/apps) minted a client_secret_hash that
|
||||
-- nothing ever verified. Deleted with the caller-identity cut; drop the
|
||||
-- column on any store built before this schema too.
|
||||
ALTER TABLE registered_apps DROP COLUMN IF EXISTS client_secret_hash;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import {
|
|||
* An app registered at runtime.
|
||||
*
|
||||
* The built-in profiles are seed data of the same shape; nothing here is
|
||||
* special-cased for them. clientSecretHash is stored rather than the secret,
|
||||
* so a database read cannot impersonate a registered app.
|
||||
* special-cased for them. There is no runtime registration authority anymore
|
||||
* (deleted with the auth-hole cut: a minted client_secret nobody verified);
|
||||
* this remains a profile store consulted by resolveAppProfile.
|
||||
*/
|
||||
export interface StoredRegisteredApp {
|
||||
slug: string;
|
||||
|
|
@ -25,7 +26,6 @@ export interface StoredRegisteredApp {
|
|||
managedConnectionProviders: Provider[];
|
||||
capabilities: CapabilityDefinition[];
|
||||
redirectUris: string[];
|
||||
clientSecretHash: string | null;
|
||||
}
|
||||
|
||||
export interface RegisterAppInput {
|
||||
|
|
@ -38,7 +38,6 @@ export interface RegisterAppInput {
|
|||
managedConnectionProviders: Provider[];
|
||||
capabilities: CapabilityDefinition[];
|
||||
redirectUris: string[];
|
||||
clientSecretHash: string | null;
|
||||
}
|
||||
|
||||
export interface StoredAccount {
|
||||
|
|
|
|||
|
|
@ -1,195 +0,0 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { type FastifyInstance } from "fastify";
|
||||
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { type HeimdallConfig } from "../src/config.js";
|
||||
import { identityFacts } from "../src/facts.js";
|
||||
|
||||
const REGISTRATION_SECRET = "operator-registration-secret";
|
||||
|
||||
function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConfig {
|
||||
return {
|
||||
serviceName: "heimdall",
|
||||
host: "127.0.0.1",
|
||||
port: 4100,
|
||||
workspaceRoot: ".",
|
||||
dataRoot: "./.heimdall-data",
|
||||
cultCachePath: "./.heimdall-data/cultcache/heimdall.service.cc",
|
||||
publicBaseUrl: "https://heimdall.test",
|
||||
issuer: "https://heimdall.test",
|
||||
daemonId: "test-heimdall",
|
||||
idunnRudpHealth: undefined,
|
||||
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
|
||||
providerHealthIdentityPath: "./.heimdall-data/provider-health-identity.cc",
|
||||
sessionTtlSeconds: 3600,
|
||||
refreshTtlSeconds: 60 * 60 * 24 * 30,
|
||||
stateTtlSeconds: 600,
|
||||
completionTtlSeconds: 300,
|
||||
bootstrapSigningPrivateKeyOnMissing: false,
|
||||
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
|
||||
appSharedSecrets: {},
|
||||
appBackendCallbacks: {},
|
||||
storage: { backend: "memory", applySchemaOnStartup: true },
|
||||
providers: {
|
||||
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
||||
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
|
||||
github: { clientId: "github-client", clientSecret: "github-secret" },
|
||||
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
|
||||
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
|
||||
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
|
||||
},
|
||||
...overrides,
|
||||
} as HeimdallConfig;
|
||||
}
|
||||
|
||||
const registration = {
|
||||
slug: "erycina",
|
||||
displayName: "Erycina",
|
||||
identityProviders: ["discord"],
|
||||
redirectUris: ["https://erycina.test/auth/callback"],
|
||||
capabilities: [
|
||||
{ key: "viewer_access", mode: "shared", summary: "Signed-in viewer.", anyOf: [identityFacts.authenticated] },
|
||||
],
|
||||
};
|
||||
|
||||
const apps: FastifyInstance[] = [];
|
||||
afterEach(async () => {
|
||||
while (apps.length > 0) await apps.pop()!.close();
|
||||
});
|
||||
|
||||
async function buildOpenInstance(): Promise<FastifyInstance> {
|
||||
const app = await buildApp({ config: createTestConfig({ appRegistrationSecret: REGISTRATION_SECRET }) });
|
||||
apps.push(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("POST /v1/apps", () => {
|
||||
it("is closed on an instance with no registration secret configured", async () => {
|
||||
// A fresh instance must not be accidentally open. Heimdall is an OAuth
|
||||
// provider; an open registration endpoint lets anyone create a client that
|
||||
// starts provider flows under this instance's identity.
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({ method: "POST", url: "/v1/apps", payload: registration });
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json().error).toBe("registration_closed");
|
||||
});
|
||||
|
||||
it("refuses a request with no registration secret", async () => {
|
||||
const app = await buildOpenInstance();
|
||||
const response = await app.inject({ method: "POST", url: "/v1/apps", payload: registration });
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("refuses a request with the wrong registration secret", async () => {
|
||||
const app = await buildOpenInstance();
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps",
|
||||
headers: { "x-heimdall-registration-secret": "not-it" },
|
||||
payload: registration,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("registers an app and returns an RFC 7591 shaped response", async () => {
|
||||
const app = await buildOpenInstance();
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps",
|
||||
headers: { "x-heimdall-registration-secret": REGISTRATION_SECRET },
|
||||
payload: registration,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = response.json();
|
||||
expect(body.client_id).toBe("erycina");
|
||||
expect(body.client_secret).toBeTruthy();
|
||||
expect(body.client_name).toBe("Erycina");
|
||||
expect(body.redirect_uris).toEqual(["https://erycina.test/auth/callback"]);
|
||||
expect(typeof body.client_id_issued_at).toBe("number");
|
||||
});
|
||||
|
||||
it("rejects an invalid registration with the problems that caused it", async () => {
|
||||
const app = await buildOpenInstance();
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps",
|
||||
headers: { "x-heimdall-registration-secret": REGISTRATION_SECRET },
|
||||
payload: { ...registration, slug: "bifrost" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("invalid_client_metadata");
|
||||
expect(JSON.stringify(response.json().problems)).toMatch(/reserved by a built-in/);
|
||||
});
|
||||
|
||||
it("rejects a capability rule naming a fact Heimdall does not produce", async () => {
|
||||
const app = await buildOpenInstance();
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps",
|
||||
headers: { "x-heimdall-registration-secret": REGISTRATION_SECRET },
|
||||
payload: {
|
||||
...registration,
|
||||
capabilities: [{ key: "admin", mode: "shared", summary: "x", anyOf: ["invented.superuser"] }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(JSON.stringify(response.json().problems)).toMatch(/Unknown term/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("app discovery after registration", () => {
|
||||
it("lists a registered app alongside the built-ins", async () => {
|
||||
const app = await buildOpenInstance();
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps",
|
||||
headers: { "x-heimdall-registration-secret": REGISTRATION_SECRET },
|
||||
payload: registration,
|
||||
});
|
||||
|
||||
const listed = (await app.inject({ method: "GET", url: "/v1/apps" })).json();
|
||||
const slugs = listed.apps.map((profile: { slug: string }) => profile.slug);
|
||||
|
||||
expect(slugs).toContain("erycina");
|
||||
expect(slugs).toContain("bifrost");
|
||||
});
|
||||
|
||||
it("serves a registered app's profile by slug", async () => {
|
||||
const app = await buildOpenInstance();
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps",
|
||||
headers: { "x-heimdall-registration-secret": REGISTRATION_SECRET },
|
||||
payload: registration,
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/v1/apps/erycina" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().displayName).toBe("Erycina");
|
||||
});
|
||||
|
||||
it("404s an app nobody registered, rather than serving an empty profile", async () => {
|
||||
const app = await buildOpenInstance();
|
||||
const response = await app.inject({ method: "GET", url: "/v1/apps/nobody" });
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json().error).toBe("unknown_app");
|
||||
});
|
||||
|
||||
it("still serves the built-in profiles", async () => {
|
||||
const app = await buildOpenInstance();
|
||||
const response = await app.inject({ method: "GET", url: "/v1/apps/repixelizer" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().slug).toBe("repixelizer");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,17 +1,14 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AppRegistrationError,
|
||||
hashClientSecret,
|
||||
isBuiltInApp,
|
||||
listAppProfiles,
|
||||
registerApp,
|
||||
resolveAppProfile,
|
||||
validateAppRegistration,
|
||||
verifyClientSecret,
|
||||
type AppRegistrationRequest,
|
||||
} from "../src/app-registry.js";
|
||||
import { evaluateCapabilityRules } from "../src/capability-rules.js";
|
||||
import { type Provider } from "../src/contracts.js";
|
||||
import { entitlementFacts, grantFacts, identityFacts } from "../src/facts.js";
|
||||
import { InMemoryStore } from "../src/store/in-memory.js";
|
||||
|
||||
|
|
@ -31,6 +28,28 @@ beforeEach(() => {
|
|||
store = new InMemoryStore();
|
||||
});
|
||||
|
||||
/**
|
||||
* There is no runtime registration authority anymore (deleted with the
|
||||
* caller-identity cut: registerApp minted a client_secret nobody verified).
|
||||
* `registered_apps` remains a profile store, so tests that need a resolvable
|
||||
* profile write the row directly through the store primitive, the same way
|
||||
* production code would seed one outside the deleted HTTP surface.
|
||||
*/
|
||||
async function seedRegisteredApp(overrides: Partial<AppRegistrationRequest> = {}, registeredAt = "2026-01-01T00:00:00.000Z") {
|
||||
const req = request(overrides);
|
||||
return store.registerApp({
|
||||
slug: req.slug,
|
||||
displayName: req.displayName.trim(),
|
||||
profileVersion: req.profileVersion?.trim() || registeredAt.slice(0, 10),
|
||||
registeredAt,
|
||||
identityProviders: req.identityProviders as Provider[],
|
||||
entitlementSources: (req.entitlementSources ?? []) as Provider[],
|
||||
managedConnectionProviders: (req.managedConnectionProviders ?? []) as Provider[],
|
||||
capabilities: req.capabilities,
|
||||
redirectUris: req.redirectUris,
|
||||
});
|
||||
}
|
||||
|
||||
describe("validateAppRegistration", () => {
|
||||
it("accepts a well-formed registration", () => {
|
||||
expect(validateAppRegistration(request())).toEqual([]);
|
||||
|
|
@ -100,45 +119,9 @@ describe("validateAppRegistration", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("registerApp", () => {
|
||||
it("stores the app and returns a secret exactly once", async () => {
|
||||
const { app, clientSecret } = await registerApp(store, request());
|
||||
|
||||
expect(app.slug).toBe("erycina");
|
||||
expect(clientSecret).toBeTruthy();
|
||||
// The secret is returned, never stored.
|
||||
expect(app).not.toHaveProperty("clientSecret");
|
||||
expect(app.clientSecretHash).toBe(hashClientSecret(clientSecret));
|
||||
});
|
||||
|
||||
it("refuses an invalid registration rather than storing a broken profile", async () => {
|
||||
await expect(registerApp(store, request({ slug: "bifrost" }))).rejects.toBeInstanceOf(AppRegistrationError);
|
||||
expect(await store.findRegisteredApp("bifrost")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the original creation time when an app re-registers", async () => {
|
||||
const first = await registerApp(store, request(), "2026-01-01T00:00:00.000Z");
|
||||
const second = await registerApp(store, request({ displayName: "Erycina v2" }), "2026-06-01T00:00:00.000Z");
|
||||
|
||||
expect(second.app.createdAt).toBe(first.app.createdAt);
|
||||
expect(second.app.updatedAt).toBe("2026-06-01T00:00:00.000Z");
|
||||
expect(second.app.displayName).toBe("Erycina v2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyClientSecret", () => {
|
||||
it("accepts the issued secret and rejects anything else", async () => {
|
||||
const { app, clientSecret } = await registerApp(store, request());
|
||||
|
||||
expect(verifyClientSecret(clientSecret, app.clientSecretHash)).toBe(true);
|
||||
expect(verifyClientSecret("not-the-secret", app.clientSecretHash)).toBe(false);
|
||||
expect(verifyClientSecret(clientSecret, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAppProfile", () => {
|
||||
it("resolves a registered app", async () => {
|
||||
await registerApp(store, request());
|
||||
await seedRegisteredApp();
|
||||
const profile = await resolveAppProfile(store, "erycina");
|
||||
|
||||
expect(profile?.displayName).toBe("Erycina");
|
||||
|
|
@ -165,7 +148,6 @@ describe("resolveAppProfile", () => {
|
|||
managedConnectionProviders: [],
|
||||
capabilities: [],
|
||||
redirectUris: [],
|
||||
clientSecretHash: null,
|
||||
});
|
||||
|
||||
expect((await resolveAppProfile(store, "bifrost"))?.displayName).toBe("Bifrost");
|
||||
|
|
@ -174,15 +156,12 @@ describe("resolveAppProfile", () => {
|
|||
|
||||
describe("a registered profile evaluates like a built-in", () => {
|
||||
it("grants capabilities through the same evaluator", async () => {
|
||||
await registerApp(
|
||||
store,
|
||||
request({
|
||||
capabilities: [
|
||||
{ key: "member_access", mode: "shared", summary: "member", anyOf: [entitlementFacts.appAccess, grantFacts.globalMember] },
|
||||
{ key: "post_create", mode: "shared", summary: "post", anyOf: ["member_access"] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await seedRegisteredApp({
|
||||
capabilities: [
|
||||
{ key: "member_access", mode: "shared", summary: "member", anyOf: [entitlementFacts.appAccess, grantFacts.globalMember] },
|
||||
{ key: "post_create", mode: "shared", summary: "post", anyOf: ["member_access"] },
|
||||
],
|
||||
});
|
||||
const profile = await resolveAppProfile(store, "erycina");
|
||||
|
||||
expect(evaluateCapabilityRules(profile!.capabilities, new Set([grantFacts.globalMember]))).toEqual([
|
||||
|
|
@ -195,7 +174,7 @@ describe("a registered profile evaluates like a built-in", () => {
|
|||
|
||||
describe("listAppProfiles", () => {
|
||||
it("lists built-ins alongside registered apps", async () => {
|
||||
await registerApp(store, request());
|
||||
await seedRegisteredApp();
|
||||
const slugs = (await listAppProfiles(store)).map((profile) => profile.slug);
|
||||
|
||||
expect(slugs).toContain("bifrost");
|
||||
|
|
@ -203,7 +182,7 @@ describe("listAppProfiles", () => {
|
|||
});
|
||||
|
||||
it("lists each app once", async () => {
|
||||
await registerApp(store, request());
|
||||
await seedRegisteredApp();
|
||||
const slugs = (await listAppProfiles(store)).map((profile) => profile.slug);
|
||||
|
||||
expect(new Set(slugs).size).toBe(slugs.length);
|
||||
|
|
|
|||
Loading…
Reference in a new issue