Skip to content
Closed
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
11 changes: 11 additions & 0 deletions docs/local-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ file outside the repo, restrict its permissions (`chmod 600`), and supply a
valid token for the gateway being used. Missing, expired, or rejected tokens
fail the run.

To get a token on your machine, mint one for the program you will run. This
opens the browser once for the OAuth login, then writes the token (`chmod 600`)
and a `<file>.json` sidecar holding the program, project, gateway URL and
refresh time:

```bash
PROGRAM=posthog-integration PROJECT_ID=12345 \
TOKEN_FILE="$HOME/.config/posthog/wizard-gateway-token" \
pnpm mint-gateway-token
```

With your personal API key already exported and gateway token saved locally:

```bash
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@
"prepare": "husky",
"screens:check": "tsx scripts/check-screens.tsx",
"wizard-ci-explore": "tsx scripts/wizard-ci-explore.no-jest.ts",
"wizard-ci-replay": "tsx scripts/tui-replay.no-jest.ts"
"wizard-ci-replay": "tsx scripts/tui-replay.no-jest.ts",
"mint-gateway-token": "tsx scripts/mint-gateway-token.no-jest.ts"
},
"lint-staged": {
".claude/settings.json": "sh -c 'printf \"\\n\\033[31mDo not commit .claude/settings.json — use .claude/settings.local.json (gitignored).\\nUnstage with: git restore --staged .claude/settings.json\\033[0m\\n\\n\" >&2 && exit 1'",
Expand Down
136 changes: 136 additions & 0 deletions scripts/mint-gateway-token.no-jest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import fs from 'fs';
import path from 'path';
import { createHash, randomBytes } from 'crypto';
import { gatewayAuth } from '@agent/gateway-session';
import { HostResolution } from '@shared/host-resolution';
import { getOAuthScopesForProgram } from '@lib/oauth/program-scopes';
import type { ProgramId } from '@lib/programs/program-registry';
import { performOAuthFlow } from '@utils/oauth';

const program = process.env.PROGRAM as ProgramId | undefined;
const projectId = Number(process.env.PROJECT_ID);
const tokenFile = process.env.TOKEN_FILE
? path.resolve(process.env.TOKEN_FILE)
: undefined;

if (
!program ||
!Number.isSafeInteger(projectId) ||
projectId <= 0 ||
!tokenFile
) {
console.error('PROGRAM, PROJECT_ID, and TOKEN_FILE are required.');
process.exit(2);
}

function refuseNonRegularDestination(destinationPath: string): void {
const destinationStat = fs.lstatSync(destinationPath, {
throwIfNoEntry: false,
});
if (destinationStat && !destinationStat.isFile()) {
throw new Error(`Refusing non-regular destination: ${destinationPath}`);
}
}

function refuseUnsafeParentDirectory(directoryPath: string): void {
const directoryStat = fs.lstatSync(directoryPath);
const isOwnedByCurrentUser = directoryStat.uid === process.getuid?.();
const isWritableByOthers = (directoryStat.mode & 0o022) !== 0;
if (
!directoryStat.isDirectory() ||
!isOwnedByCurrentUser ||
isWritableByOthers
) {
throw new Error(`Refusing unsafe destination directory: ${directoryPath}`);
}
}

function refuseUnsafeAncestorDirectory(directoryPath: string): void {
const directoryStat = fs.lstatSync(directoryPath);
const isOwnedByTrustedAccount =
directoryStat.uid === process.getuid?.() || directoryStat.uid === 0;
const isWritableByOthers = (directoryStat.mode & 0o022) !== 0;
const hasStickyBit = (directoryStat.mode & 0o1000) !== 0;
if (
!directoryStat.isDirectory() ||
!isOwnedByTrustedAccount ||
(isWritableByOthers && !hasStickyBit)
) {
throw new Error(`Refusing unsafe ancestor directory: ${directoryPath}`);
}
}

function refuseUnsafeDirectoryChain(parentDirectoryPath: string): void {
refuseUnsafeParentDirectory(parentDirectoryPath);
let childPath = parentDirectoryPath;
let ancestorPath = path.dirname(parentDirectoryPath);
while (ancestorPath !== childPath) {
refuseUnsafeAncestorDirectory(ancestorPath);
childPath = ancestorPath;
ancestorPath = path.dirname(ancestorPath);
}
}

function writePrivateFileAtomically(
destinationPath: string,
contents: string,
): void {
const tempPath = `${destinationPath}.tmp-${process.pid}-${randomBytes(
6,
).toString('hex')}`;
const fileDescriptor = fs.openSync(
tempPath,
fs.constants.O_WRONLY |
fs.constants.O_CREAT |
fs.constants.O_EXCL |
fs.constants.O_NOFOLLOW,
0o600,
);
try {
fs.writeSync(fileDescriptor, contents, null, 'utf8');
fs.fchmodSync(fileDescriptor, 0o600);
fs.closeSync(fileDescriptor);
fs.renameSync(tempPath, destinationPath);
} catch (writeError) {
fs.rmSync(tempPath, { force: true });
throw writeError;
}
}

const tokenResponse = await performOAuthFlow({
scopes: [...getOAuthScopesForProgram(program)],
projectId,
});
const host = await HostResolution.fromAccessToken(tokenResponse.access_token, {
region: tokenResponse.posthog_region,
});
const auth = await gatewayAuth(host, tokenResponse.access_token, program);

if (auth.teamId !== projectId) {
console.error(
`Requested project ${projectId}, but the token was minted for project ${
auth.teamId ?? 'unknown'
}. Nothing was written.`,
);
process.exit(1);
}

const sidecarFile = `${tokenFile}.json`;
const tokenDirectory = path.dirname(tokenFile);
fs.mkdirSync(tokenDirectory, { recursive: true, mode: 0o700 });
refuseUnsafeDirectoryChain(tokenDirectory);
refuseNonRegularDestination(tokenFile);
refuseNonRegularDestination(sidecarFile);
writePrivateFileAtomically(tokenFile, auth.token);
writePrivateFileAtomically(
sidecarFile,
JSON.stringify({
program,
projectId: auth.teamId,
gatewayUrl: auth.gatewayUrl,
refreshAtMs: auth.refreshAtMs,
tokenSha256: createHash('sha256').update(auth.token, 'utf8').digest('hex'),
}),
);
console.log(`minted gateway token for ${program} -> ${tokenFile}`);
process.exit(0);
Loading