diff --git a/src/api/FaableApi.project.test.ts b/src/api/FaableApi.project.test.ts new file mode 100644 index 0000000..1fcb881 --- /dev/null +++ b/src/api/FaableApi.project.test.ts @@ -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_`. The app row still +// says `team_` (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 | undefined)?.[name]; + +test("projectHeader maps team_ to project_", (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][] = [ + ["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); + }); +} diff --git a/src/api/FaableApi.ts b/src/api/FaableApi.ts index 0da49de..66d4108 100644 --- a/src/api/FaableApi.ts +++ b/src/api/FaableApi.ts @@ -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_`; the old +// `x-faable-team` is deprecated. A project and a team share the hex suffix, +// so the app's `team` (`team_`) 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 @@ -450,7 +458,7 @@ export class FaableApi { // 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, @@ -460,7 +468,7 @@ export class FaableApi { this.client.post( `/secret/createbatch`, { context_id, secrets }, - { headers: { 'x-faable-team': team } } + { headers: projectHeader(team) } ) ) } @@ -522,7 +530,7 @@ export class FaableApi { data( this.client.get>(`/deployment`, { params: { app_id }, - headers: { 'x-faable-team': team } + headers: projectHeader(team) }) ) ) @@ -546,7 +554,7 @@ export class FaableApi { this.client.post( `/deployment/${deployment_id}/redeploy`, undefined, - { headers: { 'x-faable-team': team } } + { headers: projectHeader(team) } ) ) } @@ -560,20 +568,20 @@ export class FaableApi { this.client.post( `/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>(`/domain`, { params: { app_id }, - headers: { 'x-faable-team': team } + headers: projectHeader(team) }) ) ) @@ -585,7 +593,7 @@ export class FaableApi { ) { return data( this.client.post(`/domain`, params, { - headers: { 'x-faable-team': team } + headers: projectHeader(team) }) ) } @@ -593,7 +601,7 @@ export class FaableApi { async getDomain(domain_id: string, team: string) { return data( this.client.get(`/domain/${domain_id}`, { - headers: { 'x-faable-team': team } + headers: projectHeader(team) }) ) } @@ -601,16 +609,16 @@ export class FaableApi { 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(`/app/${app_id}/waf`))