Skip to content

Commit b3f2356

Browse files
authored
Merge pull request #11 from phasehq/feat--cross-app-secret-referencing
feat: cross app secret referencing
2 parents ac6b630 + a2971ec commit b3f2356

File tree

4 files changed

+492
-34
lines changed

4 files changed

+492
-34
lines changed

src/index.ts

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,8 @@ export default class Phase {
200200
);
201201

202202
// Fetcher for resolving references
203-
const fetcher: SecretFetcher = async (envName, path, key) => {
204-
const cacheKey = normalizeKey(envName, path, key);
203+
const fetcher: SecretFetcher = async (envName, path, key, appName) => {
204+
const cacheKey = normalizeKey(envName, path, key, appName || undefined);
205205

206206
if (cache.has(cacheKey)) {
207207
return {
@@ -222,18 +222,51 @@ export default class Phase {
222222

223223
let secret = secretLookup.get(cacheKey);
224224
if (!secret) {
225-
const crossEnvSecrets = await this.get({
226-
...options,
227-
envName,
228-
path,
229-
key,
230-
tags: undefined,
231-
});
232-
secret = crossEnvSecrets.find((s) => s.key === key);
233-
if (!secret)
234-
throw new Error(`Missing secret: ${envName}:${path}:${key}`);
235-
236-
secretLookup.set(cacheKey, secret);
225+
try {
226+
// For cross-app references, find the target app ID
227+
let targetAppId = options.appId;
228+
if (appName) {
229+
// Check if appName might be an ID first
230+
const appById = this.apps.find(a => a.id === appName);
231+
if (appById) {
232+
targetAppId = appName; // It was an ID, use it directly
233+
} else {
234+
// Treat appName as a name and check for duplicates
235+
const matchingApps = this.apps.filter(a => a.name === appName);
236+
237+
if (matchingApps.length === 0) {
238+
// No app found by ID or name
239+
throw new Error(`App not found: '${appName}'. Please check the app name or ID and ensure your token has access.`);
240+
} else if (matchingApps.length > 1) {
241+
// Found multiple apps with the same name - ambiguous!
242+
const appDetails = matchingApps.map(a => `'${a.name}' (ID: ${a.id})`).join(', ');
243+
throw new Error(`Ambiguous app name: '${appName}'. Multiple apps found: ${appDetails}.`);
244+
} else {
245+
// Found exactly one app by name
246+
targetAppId = matchingApps[0].id;
247+
}
248+
}
249+
}
250+
251+
// Fetch the secret from the target app/environment
252+
const crossEnvSecrets = await this.get({
253+
appId: targetAppId,
254+
envName,
255+
path,
256+
key,
257+
tags: undefined,
258+
});
259+
260+
secret = crossEnvSecrets.find(s => s.key === key);
261+
if (!secret) {
262+
throw new Error(`Secret not found: ${key} in ${envName}${path !== '/' ? `, path ${path}` : ''}${appName ? `, app ${appName}` : ''}`);
263+
}
264+
265+
secretLookup.set(cacheKey, secret);
266+
} catch (error: any) {
267+
const msg = error.message || String(error);
268+
throw new Error(`Failed to resolve reference: ${msg}`);
269+
}
237270
}
238271

239272
cache.set(cacheKey, secret.value);
@@ -249,6 +282,7 @@ export default class Phase {
249282
options.envName,
250283
options.path || "/",
251284
fetcher,
285+
null,
252286
cache
253287
),
254288
}))

src/utils/secretReferencing.ts

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,117 @@
11
import { Secret } from "../types";
22

33
type SecretReference = {
4+
app: string | null;
45
env: string | null;
5-
path: string | null;
6+
path: string;
67
key: string;
78
};
89

910
export type SecretFetcher = (
1011
env: string,
1112
path: string,
12-
key: string
13+
key: string,
14+
app?: string | null
1315
) => Promise<Secret>;
1416

1517
// Regex pattern for secret references
1618
const REFERENCE_REGEX =
17-
/\${(?:(?<env>[^.\/}]+)\.)?(?:(?<path>[^}]+)\/)?(?<key>[^}]+)}/g;
19+
/\${(?:(?<app>[^:}]+)::)?(?:(?<env>[^.\/}]+)\.)?(?:(?<path>[^}]+)\/)?(?<key>[^}]+)}/g;
1820

19-
export const normalizeKey = (env: string, path: string, key: string) =>
20-
`${env.toLowerCase()}:${path.replace(/\/+$/, "")}:${key}`;
21+
export const normalizeKey = (env: string, path: string, key: string, app?: string) =>
22+
`${app ? `${app}:` : ''}${env.toLowerCase()}:${path.replace(/\/+$/, "")}:${key}`;
2123

2224
export function parseSecretReference(reference: string): SecretReference {
2325
const match = new RegExp(REFERENCE_REGEX.source).exec(reference);
2426
if (!match?.groups) {
2527
throw new Error(`Invalid secret reference format: ${reference}`);
2628
}
2729

28-
let { env, path, key } = match.groups;
29-
env = env?.trim() || "";
30-
key = key.trim();
31-
path = path ? `/${path.replace(/\.+/g, "/")}`.replace(/\/+/g, "/") : "/";
30+
const { app: appMatch, env: envMatch, path: pathMatch, key: keyMatch } = match.groups;
31+
const app = appMatch?.trim() || null;
32+
const env = envMatch?.trim() || null;
33+
const key = keyMatch.trim();
34+
const path = pathMatch ? `/${pathMatch.replace(/\.+/g, "/")}`.replace(/\/+/g, "/") : "/";
3235

33-
return { env, path, key };
36+
return { app, env, path, key };
3437
}
3538

3639
export async function resolveSecretReferences(
3740
value: string,
3841
currentEnv: string,
3942
currentPath: string,
4043
fetcher: SecretFetcher,
41-
cache: Map<string, string> = new Map(),
42-
resolutionStack: Set<string> = new Set()
44+
currentApp?: string | null,
45+
cache = new Map<string, string>(),
46+
resolutionStack = new Set<string>()
4347
): Promise<string> {
48+
// Skip processing if there are no references to resolve
49+
if (!value.includes("${")) {
50+
return value;
51+
}
52+
4453
const references = Array.from(value.matchAll(REFERENCE_REGEX));
4554
let resolvedValue = value;
4655

4756
for (const ref of references) {
4857
try {
4958
const {
59+
app: refApp,
5060
env: refEnv,
5161
path: refPath,
5262
key: refKey,
5363
} = parseSecretReference(ref[0]);
64+
65+
const targetApp = refApp || currentApp;
5466
const targetEnv = refEnv || currentEnv;
55-
const targetPath = refPath || currentPath;
56-
const cacheKey = normalizeKey(targetEnv, targetPath, refKey);
67+
const targetPath = refPath || currentPath || "/";
68+
69+
// Create cache key from normalized values
70+
const cacheKey = normalizeKey(
71+
targetEnv || "",
72+
targetPath,
73+
refKey,
74+
targetApp || undefined
75+
);
5776

77+
// Check for circular references
5878
if (resolutionStack.has(cacheKey)) {
59-
throw new Error(`Circular reference detected: ${cacheKey}`);
79+
console.warn(`Circular reference detected: ${ref[0]}${cacheKey}`);
80+
continue;
6081
}
6182

83+
// Resolve the reference if not in cache
6284
if (!cache.has(cacheKey)) {
6385
resolutionStack.add(cacheKey);
6486
try {
65-
const secret = await fetcher(targetEnv, targetPath, refKey);
87+
// Fetch the referenced secret
88+
const secret = await fetcher(targetEnv || "", targetPath, refKey, targetApp);
89+
90+
// Recursively resolve any references in the secret value
6691
const resolvedSecretValue = await resolveSecretReferences(
6792
secret.value,
6893
targetEnv,
6994
targetPath,
7095
fetcher,
96+
targetApp,
7197
cache,
7298
resolutionStack
7399
);
100+
74101
cache.set(cacheKey, resolvedSecretValue);
102+
} catch (error: any) {
103+
console.warn(`Failed to resolve reference ${ref[0]}: ${error.message || error}`);
104+
resolutionStack.delete(cacheKey);
105+
continue;
75106
} finally {
76107
resolutionStack.delete(cacheKey);
77108
}
78109
}
79110

111+
// Replace the reference with its resolved value
80112
resolvedValue = resolvedValue.replace(ref[0], cache.get(cacheKey)!);
81-
} catch (error) {
82-
console.error(`Error resolving reference ${ref[0]}:`, error);
83-
throw error;
113+
} catch (error: any) {
114+
console.warn(`Error resolving reference ${ref[0]}: ${error.message || error}`);
84115
}
85116
}
86117

0 commit comments

Comments
 (0)