diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts
index 753351a0e..9bbeec8da 100644
--- a/src/backend/clients/database/SqliteDatabaseClient.ts
+++ b/src/backend/clients/database/SqliteDatabaseClient.ts
@@ -90,6 +90,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[54, ['0059_add_card_verification.sql']],
[55, ['0060_add_card_fingerprint.sql']],
[56, ['0061_add_suspended_at.sql']],
+ [57, ['0062_blocked-app-origins.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_17.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_17.sql
new file mode 100644
index 000000000..c04f0f85c
--- /dev/null
+++ b/src/backend/clients/database/migrations/mysql/mysql_mig_17.sql
@@ -0,0 +1,36 @@
+-- Copyright (C) 2024-present Puter Technologies Inc.
+--
+-- This file is part of Puter.
+--
+-- Puter is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as published
+-- by the Free Software Foundation, either version 3 of the License, or
+-- (at your option) any later version.
+--
+-- This program is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+
+-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062.
+-- An app whose `index_url` host (or a request origin) matches an entry is
+-- denied access to Puter resources: it cannot obtain an app token and
+-- already-issued app tokens are rejected on each request. `include_subdomains
+-- = 1` also blocks every subdomain of `domain`. Enforced in AuthService via
+-- AppOriginBlocklistService.
+--
+-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
+
+CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `domain` VARCHAR(255) NOT NULL,
+ `include_subdomains` TINYINT(1) NOT NULL DEFAULT 0,
+ `reason` TEXT DEFAULT NULL,
+ `created_by` VARCHAR(255) DEFAULT NULL,
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `idx_blocked_app_origins_domain` (`domain`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/src/backend/clients/database/migrations/sqlite/0062_blocked-app-origins.sql b/src/backend/clients/database/migrations/sqlite/0062_blocked-app-origins.sql
new file mode 100644
index 000000000..4aebdd869
--- /dev/null
+++ b/src/backend/clients/database/migrations/sqlite/0062_blocked-app-origins.sql
@@ -0,0 +1,34 @@
+-- Copyright (C) 2024-present Puter Technologies Inc.
+--
+-- This file is part of Puter.
+--
+-- Puter is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as published
+-- by the Free Software Foundation, either version 3 of the License, or
+-- (at your option) any later version.
+--
+-- This program is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+
+-- Admin-managed blocklist of app origins. An app whose `index_url` host (or
+-- a request origin) matches an entry is denied access to Puter resources:
+-- it cannot obtain an app token and already-issued app tokens are rejected
+-- on each request. `include_subdomains = 1` also blocks every subdomain of
+-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
+
+CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT,
+ "domain" TEXT NOT NULL,
+ "include_subdomains" INTEGER NOT NULL DEFAULT 0,
+ "reason" TEXT DEFAULT NULL,
+ "created_by" TEXT DEFAULT NULL,
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS `idx_blocked_app_origins_domain`
+ ON `blocked_app_origins` (`domain`);
diff --git a/src/backend/core/http/expressAugmentation.ts b/src/backend/core/http/expressAugmentation.ts
index 9a2b3c4d4..20738974b 100644
--- a/src/backend/core/http/expressAugmentation.ts
+++ b/src/backend/core/http/expressAugmentation.ts
@@ -42,6 +42,13 @@ declare global {
tokenAuthFailed?: boolean;
+ /**
+ * Set when a token authenticated but its app is on the origin
+ * blocklist. The auth probe leaves `actor` unset; gates translate
+ * this into a 403 `app_blocked`.
+ */
+ appBlocked?: { reason?: string };
+
requiresReauth?: {
reason: 'token_v1' | 'session_revoked' | 'session_expired';
auth_id?: string;
diff --git a/src/backend/core/http/middleware/authProbe.ts b/src/backend/core/http/middleware/authProbe.ts
index 121b68870..0c654afac 100644
--- a/src/backend/core/http/middleware/authProbe.ts
+++ b/src/backend/core/http/middleware/authProbe.ts
@@ -93,6 +93,13 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
);
}
+ if (result.blocked) {
+ // App is on the origin blocklist: leave `actor` unset so gates
+ // reject. `appBlocked` lets the gate emit a clear 403 instead
+ // of the generic "token failed" 401.
+ req.appBlocked = { reason: result.blocked.reason };
+ }
+
if (result.actor) {
req.actor = result.actor;
req.token = token;
diff --git a/src/backend/core/http/middleware/gates.ts b/src/backend/core/http/middleware/gates.ts
index 66a03a4c0..b71edc146 100644
--- a/src/backend/core/http/middleware/gates.ts
+++ b/src/backend/core/http/middleware/gates.ts
@@ -79,6 +79,16 @@ export const subdomainGate = (allowed: string | string[]): RequestHandler => {
*/
export const requireAuthGate = (): RequestHandler => {
return (req, _res, next) => {
+ if (req.appBlocked) {
+ next(
+ new HttpError(
+ 403,
+ 'This app is not allowed to access Puter resources',
+ { legacyCode: 'app_blocked' },
+ ),
+ );
+ return;
+ }
if (!req.actor) {
next(rejectAuth(req));
return;
diff --git a/src/backend/services/abuse/AppOriginBlocklistService.test.ts b/src/backend/services/abuse/AppOriginBlocklistService.test.ts
new file mode 100644
index 000000000..9538f2dcb
--- /dev/null
+++ b/src/backend/services/abuse/AppOriginBlocklistService.test.ts
@@ -0,0 +1,174 @@
+/**
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { describe, expect, it } from 'vitest';
+import { AppOriginBlocklistService } from './AppOriginBlocklistService.js';
+
+type Row = {
+ domain: string;
+ include_subdomains: number;
+ reason?: string | null;
+};
+
+const makeService = (
+ rows: Row[],
+ read?: () => Promise,
+): { service: AppOriginBlocklistService; reads: { count: number } } => {
+ const reads = { count: 0 };
+ const db = {
+ read:
+ read ??
+ (async () => {
+ reads.count++;
+ return rows;
+ }),
+ };
+ const service = new AppOriginBlocklistService(
+ {} as never,
+ { db } as never,
+ {} as never,
+ {} as never,
+ );
+ return { service, reads };
+};
+
+describe('AppOriginBlocklistService', () => {
+ describe('isHostBlocked — exact entries', () => {
+ it('matches the exact host only', async () => {
+ const { service } = makeService([
+ { domain: 'some.evil.com', include_subdomains: 0 },
+ ]);
+ expect(await service.isHostBlocked('some.evil.com')).toEqual({
+ blocked: true,
+ reason: undefined,
+ });
+ expect((await service.isHostBlocked('evil.com')).blocked).toBe(
+ false,
+ );
+ expect(
+ (await service.isHostBlocked('x.some.evil.com')).blocked,
+ ).toBe(false);
+ });
+ });
+
+ describe('isHostBlocked — include_subdomains entries', () => {
+ it('matches the apex and any subdomain, but not lookalikes', async () => {
+ const { service } = makeService([
+ { domain: 'evil.com', include_subdomains: 1, reason: 'abuse' },
+ ]);
+ expect(await service.isHostBlocked('evil.com')).toEqual({
+ blocked: true,
+ reason: 'abuse',
+ });
+ expect((await service.isHostBlocked('a.evil.com')).blocked).toBe(
+ true,
+ );
+ expect((await service.isHostBlocked('a.b.evil.com')).blocked).toBe(
+ true,
+ );
+ // Suffix-but-not-subdomain must NOT match.
+ expect((await service.isHostBlocked('notevil.com')).blocked).toBe(
+ false,
+ );
+ expect((await service.isHostBlocked('evil.com.org')).blocked).toBe(
+ false,
+ );
+ });
+ });
+
+ describe('normalization', () => {
+ it('lowercases, strips port, and ignores empty input', async () => {
+ const { service } = makeService([
+ { domain: 'evil.com', include_subdomains: 1 },
+ ]);
+ expect((await service.isHostBlocked('A.EVIL.COM')).blocked).toBe(
+ true,
+ );
+ expect(
+ (await service.isHostBlocked('a.evil.com:8080')).blocked,
+ ).toBe(true);
+ expect((await service.isHostBlocked('')).blocked).toBe(false);
+ });
+
+ it('normalizes stored entries too (uppercase/leading dot)', async () => {
+ const { service } = makeService([
+ { domain: '.Evil.COM', include_subdomains: 1 },
+ ]);
+ expect((await service.isHostBlocked('a.evil.com')).blocked).toBe(
+ true,
+ );
+ });
+ });
+
+ describe('isOriginBlocked', () => {
+ it('extracts the host from a full URL', async () => {
+ const { service } = makeService([
+ { domain: 'evil.com', include_subdomains: 1 },
+ ]);
+ expect(
+ (await service.isOriginBlocked('https://app.evil.com/path?q=1'))
+ .blocked,
+ ).toBe(true);
+ expect(
+ (await service.isOriginBlocked('https://good.com/')).blocked,
+ ).toBe(false);
+ });
+
+ it('accepts a scheme-less origin', async () => {
+ const { service } = makeService([
+ { domain: 'evil.com', include_subdomains: 0 },
+ ]);
+ expect((await service.isOriginBlocked('evil.com')).blocked).toBe(
+ true,
+ );
+ });
+ });
+
+ describe('caching', () => {
+ it('reuses the cached snapshot within the TTL', async () => {
+ const { service, reads } = makeService([
+ { domain: 'evil.com', include_subdomains: 0 },
+ ]);
+ await service.isHostBlocked('evil.com');
+ await service.isHostBlocked('evil.com');
+ expect(reads.count).toBe(1);
+ });
+
+ it('reloads after invalidate()', async () => {
+ const { service, reads } = makeService([
+ { domain: 'evil.com', include_subdomains: 0 },
+ ]);
+ await service.isHostBlocked('evil.com');
+ service.invalidate();
+ await service.isHostBlocked('evil.com');
+ expect(reads.count).toBe(2);
+ });
+ });
+
+ describe('resilience', () => {
+ it('fails open (not blocked) when the DB read throws', async () => {
+ const { service } = makeService([], async () => {
+ throw new Error('no such table: blocked_app_origins');
+ });
+ expect((await service.isHostBlocked('evil.com')).blocked).toBe(
+ false,
+ );
+ });
+ });
+});
diff --git a/src/backend/services/abuse/AppOriginBlocklistService.ts b/src/backend/services/abuse/AppOriginBlocklistService.ts
new file mode 100644
index 000000000..b42af2050
--- /dev/null
+++ b/src/backend/services/abuse/AppOriginBlocklistService.ts
@@ -0,0 +1,172 @@
+/**
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { PuterService } from '../types.js';
+
+export interface BlockMatch {
+ blocked: boolean;
+ reason?: string;
+}
+
+interface BlocklistEntry {
+ domain: string;
+ includeSubdomains: boolean;
+ reason: string | null;
+}
+
+const NOT_BLOCKED: BlockMatch = { blocked: false };
+
+/**
+ * In-memory, TTL-cached view of the `blocked_app_origins` table.
+ *
+ * Admins manage the table (the admin extension writes it directly, mirroring
+ * how it writes the `user` table for suspend). This service answers the hot
+ * "is this app/origin blocked?" question from a cached snapshot so the
+ * per-request app-token validation path never hits the DB.
+ *
+ * Consistency: the cache refreshes lazily after {@link CACHE_TTL_MS}. Because
+ * each worker process holds its own cache, a freshly-added block can take up
+ * to one TTL to take effect across the fleet — acceptable for an
+ * admin-initiated block.
+ */
+export class AppOriginBlocklistService extends PuterService {
+ private static readonly CACHE_TTL_MS = 30_000;
+
+ #entries: BlocklistEntry[] = [];
+ #loadedAt = 0;
+ #inflight: Promise | null = null;
+
+ /**
+ * Decide whether a bare host (already without scheme/path) is blocked.
+ * Exact entries match the host verbatim; `include_subdomains` entries
+ * also match any subdomain of `domain`.
+ */
+ async isHostBlocked(host: string): Promise {
+ const normalized = normalizeHost(host);
+ if (!normalized) return NOT_BLOCKED;
+
+ await this.#ensureFresh();
+ for (const entry of this.#entries) {
+ const matches = entry.includeSubdomains
+ ? normalized === entry.domain ||
+ normalized.endsWith(`.${entry.domain}`)
+ : normalized === entry.domain;
+ if (matches) {
+ return {
+ blocked: true,
+ reason: entry.reason ?? undefined,
+ };
+ }
+ }
+ return NOT_BLOCKED;
+ }
+
+ /**
+ * Decide whether an origin/URL is blocked by extracting its host. Accepts
+ * full URLs (`https://app.example.com/path`) and bare hosts alike.
+ */
+ async isOriginBlocked(origin: string): Promise {
+ return this.isHostBlocked(hostFromOrigin(origin));
+ }
+
+ /** Drop the cached snapshot so the next query reloads from the DB. */
+ invalidate(): void {
+ this.#loadedAt = 0;
+ }
+
+ async #ensureFresh(): Promise {
+ const age = Date.now() - this.#loadedAt;
+ if (
+ this.#loadedAt !== 0 &&
+ age < AppOriginBlocklistService.CACHE_TTL_MS
+ ) {
+ return;
+ }
+ // Single-flight: concurrent callers share one reload.
+ if (!this.#inflight) {
+ this.#inflight = this.#reload().finally(() => {
+ this.#inflight = null;
+ });
+ }
+ await this.#inflight;
+ }
+
+ async #reload(): Promise {
+ try {
+ const rows = (await this.clients.db.read(
+ 'SELECT `domain`, `include_subdomains`, `reason` FROM `blocked_app_origins`',
+ )) as Array>;
+ this.#entries = rows
+ .map((row) => {
+ const domain = normalizeHost(String(row.domain ?? ''));
+ if (!domain) return null;
+ return {
+ domain,
+ includeSubdomains: Boolean(
+ Number(row.include_subdomains ?? 0),
+ ),
+ reason: row.reason == null ? null : String(row.reason),
+ } satisfies BlocklistEntry;
+ })
+ .filter((e): e is BlocklistEntry => e !== null);
+ this.#loadedAt = Date.now();
+ } catch (e) {
+ // Never let a transient DB error turn into a request-blocking
+ // throw on the auth hot path. Keep serving the previous snapshot;
+ // a missing table (fresh dev DB pre-migration) yields an empty
+ // blocklist, which is the safe-open default.
+ console.warn(
+ '[app-origin-blocklist] reload failed:',
+ (e as Error)?.message ?? e,
+ );
+ if (this.#loadedAt === 0) {
+ this.#entries = [];
+ this.#loadedAt = Date.now();
+ }
+ }
+ }
+}
+
+/** Lowercase, trim, drop a leading dot and any port. Returns '' when unusable. */
+const normalizeHost = (host: string): string => {
+ let h = (host ?? '').trim().toLowerCase();
+ if (!h) return '';
+ h = h.replace(/^\./, '');
+ // Strip a trailing :port (IPv6 literals are not app origins, so the
+ // simple split is safe here).
+ const colon = h.indexOf(':');
+ if (colon !== -1) h = h.slice(0, colon);
+ return h;
+};
+
+/** Extract the host from a full URL, falling back to treating input as a host. */
+const hostFromOrigin = (origin: string): string => {
+ const raw = (origin ?? '').trim();
+ if (!raw) return '';
+ try {
+ return new URL(raw).hostname;
+ } catch {
+ // Not a parseable URL — maybe a scheme-less origin or bare host.
+ try {
+ return new URL(`https://${raw}`).hostname;
+ } catch {
+ return raw;
+ }
+ }
+};
diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts
index 323fc3b79..824334b33 100644
--- a/src/backend/services/auth/AuthService.test.ts
+++ b/src/backend/services/auth/AuthService.test.ts
@@ -1348,6 +1348,85 @@ describe('AuthService (integration)', () => {
});
});
+ describe('app origin blocklist enforcement', () => {
+ // The blocklist service caches with a TTL, so seed the row then
+ // invalidate the in-memory snapshot to force a reload for the test.
+ const blockOrigin = async (
+ domain: string,
+ includeSubdomains = false,
+ ) => {
+ await server.clients.db.write(
+ 'INSERT INTO `blocked_app_origins` (`domain`, `include_subdomains`) VALUES (?, ?)',
+ [domain, includeSubdomains ? 1 : 0],
+ );
+ (
+ server.services.appOriginBlocklist as {
+ invalidate: () => void;
+ }
+ ).invalidate();
+ };
+
+ it('appUidFromOrigin throws 403 app_blocked for a blocked exact host', async () => {
+ const host = `blocked-${uuidv4()}.example.com`;
+ await blockOrigin(host);
+ await expect(
+ authService.appUidFromOrigin(`https://${host}/`),
+ ).rejects.toMatchObject({
+ statusCode: 403,
+ legacyCode: 'app_blocked',
+ });
+ });
+
+ it('appUidFromOrigin throws for a subdomain of an include_subdomains entry', async () => {
+ const apex = `evil-${uuidv4()}.example.com`;
+ await blockOrigin(apex, true);
+ await expect(
+ authService.appUidFromOrigin(`https://app.${apex}/`),
+ ).rejects.toMatchObject({
+ statusCode: 403,
+ legacyCode: 'app_blocked',
+ });
+ });
+
+ it('appUidFromOrigin still resolves an unrelated origin', async () => {
+ const uid = await authService.appUidFromOrigin(
+ `https://fine-${uuidv4()}.example.com/`,
+ );
+ expect(uid).toMatch(/^app-/);
+ });
+
+ it('rejects an already-issued app token once its origin is blocked', async () => {
+ const user = await makeUser();
+ const host = `late-block-${uuidv4()}.example.com`;
+ const appUid = `app-${uuidv4()}`;
+ // App row carries the to-be-blocked host as its index_url.
+ await server.clients.db.write(
+ 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)',
+ [appUid, `n-${appUid}`, `t-${appUid}`, `https://${host}/`, 1],
+ );
+ const appToken = await authService.getUserAppToken(
+ {
+ user: {
+ id: user.id,
+ uuid: user.uuid,
+ username: user.username,
+ },
+ } as Actor,
+ appUid,
+ );
+
+ // Before blocking the token authenticates normally.
+ const ok = await authService.authenticate(appToken);
+ expect(ok.actor?.app?.uid).toBe(appUid);
+
+ // After blocking the same token is rejected with the blocked signal.
+ await blockOrigin(host);
+ const blocked = await authService.authenticate(appToken);
+ expect(blocked.actor).toBeUndefined();
+ expect(blocked.blocked).toBeTruthy();
+ });
+ });
+
describe('getUserAppToken', () => {
it('throws 403 when actor has no user', async () => {
await expect(
diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts
index ffb11ee96..cf9c34b06 100644
--- a/src/backend/services/auth/AuthService.ts
+++ b/src/backend/services/auth/AuthService.ts
@@ -50,6 +50,13 @@ export interface AuthResult {
actor?: Actor;
reauth?: { reason: ReauthReason; auth_id?: string };
invalid?: true;
+ /**
+ * The token authenticated, but its app is on the origin blocklist. The
+ * auth probe surfaces this as `req.appBlocked`; gates translate it to a
+ * 403 `app_blocked`. Distinct from `invalid` so the client sees a clear
+ * "app blocked" error rather than a generic auth failure.
+ */
+ blocked?: { reason?: string };
}
/**
@@ -891,6 +898,19 @@ export class AuthService extends PuterService {
const event = { origin: aliased };
await this.clients.event?.emitAndWait('app.from-origin', event, {});
+ // Blocked origins can't acquire an app token (or have one minted /
+ // checked / granted), so the app loses every path to Puter resources.
+ const block = await this.services.appOriginBlocklist.isOriginBlocked(
+ event.origin,
+ );
+ if (block.blocked) {
+ throw new HttpError(
+ 403,
+ 'This app is not allowed to access Puter resources',
+ { legacyCode: 'app_blocked' },
+ );
+ }
+
const canonicalUid = await this.#findCanonicalAppUidForOrigin(
event.origin,
);
@@ -1784,6 +1804,21 @@ export class AuthService extends PuterService {
const app = await this.stores.app.getByUid(decoded.app_uid);
if (!app) return { invalid: true };
+ // Reject already-issued app tokens whose app origin is now blocked, so
+ // a block takes effect immediately rather than waiting for token
+ // expiry. The app's `index_url` host is the same origin checked at
+ // token acquisition.
+ const indexUrl = (app as { index_url?: unknown }).index_url;
+ if (typeof indexUrl === 'string' && indexUrl) {
+ const block =
+ await this.services.appOriginBlocklist.isOriginBlocked(
+ indexUrl,
+ );
+ if (block.blocked) {
+ return { blocked: { reason: block.reason } };
+ }
+ }
+
let rawRow: SessionRow | null = null;
if (decoded.session_uid) {
rawRow = (await this.stores.session.getByUuidAny(
diff --git a/src/backend/services/index.ts b/src/backend/services/index.ts
index 5545e4a47..a33ec37a2 100644
--- a/src/backend/services/index.ts
+++ b/src/backend/services/index.ts
@@ -18,6 +18,7 @@
*/
import { ACLService } from './acl/ACLService';
+import { AppOriginBlocklistService } from './abuse/AppOriginBlocklistService';
import { AppPermissionService } from './apps/AppPermissionService';
import { RecommendedAppsService } from './apps/RecommendedAppsService';
import { SuggestedAppsService } from './apps/SuggestedAppsService';
@@ -47,6 +48,7 @@ import type { IPuterServiceRegistry } from './types';
declare module './types' {
interface IPuterServiceInstances {
metering: MeteringService;
+ appOriginBlocklist: AppOriginBlocklistService;
permission: PermissionService;
acl: ACLService;
token: TokenService;
@@ -77,6 +79,10 @@ declare module './types' {
// BroadcastService is independent — only needs the event client.
export const puterServices = {
metering: MeteringService,
+ // Declared before `auth` so AuthService sees it as a prior peer — it
+ // queries the blocklist on app-token acquisition and per-request app
+ // token validation.
+ appOriginBlocklist: AppOriginBlocklistService,
permission: PermissionService,
acl: ACLService,
token: TokenService,