Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/api/FaableApi.project.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import test from "ava";
import { AxiosRequestConfig, AxiosResponse } from "axios";
import { FaableApi, projectHeader } from "./FaableApi";

// The tenant travels as `x-faable-project: project_<hex>`. The app row still
// says `team_<hex>` (same hex), and `x-faable-team` is deprecated: the api
// answers 400 when both headers come in and disagree, so we never send it.

const HEX = "6a8ebd616d2f4b0012345678";
const TEAM = `team_${HEX}`;
const PROJECT = `project_${HEX}`;

const capture = (api: FaableApi) => {
const seen: AxiosRequestConfig[] = [];
api.client.defaults.adapter = async (config) => {
seen.push(config);
const results = config.method === "get" && !/\/domain\/./.test(config.url ?? "");
return {
data: results ? { results: [], next: null } : {},
status: 200,
statusText: "OK",
headers: {},
config,
} as AxiosResponse;
};
return seen;
};

const header = (c: AxiosRequestConfig, name: string) =>
(c.headers as Record<string, unknown> | undefined)?.[name];

test("projectHeader maps team_<hex> to project_<hex>", (t) => {
t.deepEqual(projectHeader(TEAM), { "x-faable-project": PROJECT });
// Already a project id: left alone.
t.deepEqual(projectHeader(PROJECT), { "x-faable-project": PROJECT });
});

const calls: [string, (api: FaableApi) => Promise<unknown>][] = [
["createSecretsBatch", (api) => api.createSecretsBatch("app_1", TEAM, [])],
["listDeployments", (api) => api.listDeployments("app_1", TEAM)],
["redeployDeployment", (api) => api.redeployDeployment("deployment_1", TEAM)],
["cancelDeployment", (api) => api.cancelDeployment("deployment_1", TEAM)],
["listDomains", (api) => api.listDomains("app_1", TEAM)],
["createDomain", (api) => api.createDomain(TEAM, { fqdn: "a.example.com", app_id: "app_1" })],
["getDomain", (api) => api.getDomain("domain_1", TEAM)],
["deleteDomain", (api) => api.deleteDomain("domain_1", TEAM)],
];

for (const [name, call] of calls) {
test(`${name} sends x-faable-project (project_ form) and never x-faable-team`, async (t) => {
const api = FaableApi.create();
const seen = capture(api);
await call(api);
t.is(seen.length, 1);
t.is(header(seen[0], "x-faable-project"), PROJECT);
t.is(header(seen[0], "x-faable-team"), undefined);
});
}
36 changes: 22 additions & 14 deletions src/api/FaableApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ const is_connection_reset = (e: AxiosError) =>
e.isAxiosError &&
!e.response &&
(RESET_CODES.has(e.code ?? '') || e.message.includes('socket hang up'))

// The api's tenant header is `x-faable-project: project_<hex>`; the old
// `x-faable-team` is deprecated. A project and a team share the hex suffix,
// so the app's `team` (`team_<hex>`) maps 1:1. Never send both: the api
// rejects a pair that disagrees.
export const projectHeader = (team: string) => ({
'x-faable-project': team.replace(/^team_/, 'project_')
})
export interface FaableApp {
id: string
name: string
Expand Down Expand Up @@ -450,7 +458,7 @@ export class FaableApi<T = any> {
// app; the per-secret upsert/delete endpoints are not used by the CLI.
// The endpoint stamps the created secrets with the team from the request
// context, which a CLI user token does not carry — pass the app's team
// (from getApp) so it travels as the `x-faable-team` header.
// (from getApp) so it travels as the `x-faable-project` header.
async createSecretsBatch(
context_id: string,
team: string,
Expand All @@ -460,7 +468,7 @@ export class FaableApi<T = any> {
this.client.post<Secret[]>(
`/secret/createbatch`,
{ context_id, secrets },
{ headers: { 'x-faable-team': team } }
{ headers: projectHeader(team) }
)
)
}
Expand Down Expand Up @@ -522,7 +530,7 @@ export class FaableApi<T = any> {
data(
this.client.get<Page<FaableDeployment>>(`/deployment`, {
params: { app_id },
headers: { 'x-faable-team': team }
headers: projectHeader(team)
})
)
)
Expand All @@ -546,7 +554,7 @@ export class FaableApi<T = any> {
this.client.post<FaableDeployment>(
`/deployment/${deployment_id}/redeploy`,
undefined,
{ headers: { 'x-faable-team': team } }
{ headers: projectHeader(team) }
)
)
}
Expand All @@ -560,20 +568,20 @@ export class FaableApi<T = any> {
this.client.post<FaableDeployment>(
`/deployment/${deployment_id}/cancel`,
undefined,
{ headers: { 'x-faable-team': team } }
{ headers: projectHeader(team) }
)
)
}

// Domains are team-scoped rows; a CLI user token carries no default team,
// so every call pins the app's team via `x-faable-team` (same pattern as
// createSecretsBatch).
// so every call pins the app's project via `x-faable-project` (same pattern
// as createSecretsBatch).
async listDomains(app_id: string, team: string) {
return firstPage(
data(
this.client.get<Page<FaableDomain>>(`/domain`, {
params: { app_id },
headers: { 'x-faable-team': team }
headers: projectHeader(team)
})
)
)
Expand All @@ -585,32 +593,32 @@ export class FaableApi<T = any> {
) {
return data(
this.client.post<FaableDomain>(`/domain`, params, {
headers: { 'x-faable-team': team }
headers: projectHeader(team)
})
)
}

async getDomain(domain_id: string, team: string) {
return data(
this.client.get<FaableDomain>(`/domain/${domain_id}`, {
headers: { 'x-faable-team': team }
headers: projectHeader(team)
})
)
}

async deleteDomain(domain_id: string, team: string) {
return data(
this.client.delete(`/domain/${domain_id}`, {
headers: { 'x-faable-team': team }
headers: projectHeader(team)
})
)
}

// ── per-app WAF ───────────────────────────────────────────────────────────
//
// No `x-faable-team` header on any of these: the routes are scoped by the
// app in the path (the server reads the team off the App row), and sending
// a team override would only narrow the lookup.
// No `x-faable-project` header on any of these: the routes are scoped by the
// app in the path (the server reads the project off the App row), and sending
// a project override would only narrow the lookup.

async getAppWaf(app_id: string) {
return data(this.client.get<FaableAppWaf>(`/app/${app_id}/waf`))
Expand Down
Loading