@amerged/ohmyhost-cli 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +11 -0
- package/THIRD_PARTY_NOTICES.md +139 -0
- package/dist/bin.js +18 -0
- package/dist/chunk-4HFQ54FP.js +138 -0
- package/dist/chunk-Z75ASLEB.js +24669 -0
- package/dist/credential-store.js +15 -0
- package/dist/index.js +249 -0
- package/package.json +33 -0
- package/src/apps/product-cli/bin.ts +24 -0
- package/src/apps/product-cli/cli.ts +2271 -0
- package/src/apps/product-cli/command.ts +1811 -0
- package/src/apps/product-cli/composition.ts +823 -0
- package/src/apps/product-cli/credential-store.ts +149 -0
- package/src/apps/product-cli/device-token-expiry.ts +89 -0
- package/src/apps/product-cli/file-credential-store.ts +166 -0
- package/src/apps/product-cli/index.ts +23 -0
- package/src/apps/product-cli/product-api.ts +1082 -0
- package/src/apps/product-cli/project-link-store.ts +30 -0
- package/src/apps/product-cli/psql-session.ts +29 -0
- package/src/apps/product-cli/repository-init.ts +1622 -0
- package/src/apps/product-cli/runtime-contract.ts +1747 -0
- package/src/apps/product-cli/user-token-file.ts +214 -0
- package/src/apps/product-cli/workspace.ts +92 -0
- package/src/packages/contracts/application-root.ts +80 -0
- package/src/packages/contracts/billing.ts +172 -0
- package/src/packages/contracts/credit-pricing.ts +214 -0
- package/src/packages/contracts/database-access.ts +257 -0
- package/src/packages/contracts/database-compute.ts +72 -0
- package/src/packages/contracts/database-migration-admission.ts +235 -0
- package/src/packages/contracts/feedback.ts +96 -0
- package/src/packages/contracts/framework-admission.ts +280 -0
- package/src/packages/contracts/framework-family.ts +18 -0
- package/src/packages/contracts/functions.ts +76 -0
- package/src/packages/contracts/index.ts +245 -0
- package/src/packages/contracts/operation-deployment-result.ts +165 -0
- package/src/packages/contracts/operation-failure.ts +150 -0
- package/src/packages/contracts/platform-origins.ts +5 -0
- package/src/packages/contracts/project-context.ts +59 -0
- package/src/packages/contracts/project-exports.ts +102 -0
- package/src/packages/contracts/user-api-keys.ts +98 -0
- package/src/packages/sdk-ts/generated/client/client.gen.ts +277 -0
- package/src/packages/sdk-ts/generated/client/index.ts +27 -0
- package/src/packages/sdk-ts/generated/client/types.gen.ts +217 -0
- package/src/packages/sdk-ts/generated/client/utils.gen.ts +316 -0
- package/src/packages/sdk-ts/generated/client.gen.ts +24 -0
- package/src/packages/sdk-ts/generated/core/auth.gen.ts +48 -0
- package/src/packages/sdk-ts/generated/core/bodySerializer.gen.ts +82 -0
- package/src/packages/sdk-ts/generated/core/params.gen.ts +178 -0
- package/src/packages/sdk-ts/generated/core/pathSerializer.gen.ts +171 -0
- package/src/packages/sdk-ts/generated/core/queryKeySerializer.gen.ts +117 -0
- package/src/packages/sdk-ts/generated/core/serverSentEvents.gen.ts +242 -0
- package/src/packages/sdk-ts/generated/core/types.gen.ts +114 -0
- package/src/packages/sdk-ts/generated/core/utils.gen.ts +140 -0
- package/src/packages/sdk-ts/generated/index.ts +578 -0
- package/src/packages/sdk-ts/generated/sdk.gen.ts +3440 -0
- package/src/packages/sdk-ts/generated/types.gen.ts +5790 -0
- package/src/packages/sdk-ts/index.ts +7 -0
- package/src/packages/workos-auth-contracts/device-flow.ts +271 -0
- package/src/packages/workos-auth-contracts/index.ts +8 -0
- package/src/packages/workos-auth-contracts/management-rpc.ts +379 -0
- package/src/packages/workos-auth-contracts/organizations.ts +113 -0
- package/src/packages/workos-auth-contracts/parsing.ts +75 -0
- package/src/packages/workos-auth-contracts/session-status.ts +53 -0
- package/src/packages/workos-auth-contracts/tokens.ts +216 -0
- package/src/packages/workos-auth-contracts/user-api-keys.ts +85 -0
- package/src/packages/workos-auth-contracts/webhooks.ts +127 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { constants, lstat, mkdir, open, realpath, type FileHandle } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, resolve } from "node:path";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { parseEnv, promisify } from "node:util";
|
|
5
|
+
import {
|
|
6
|
+
parseUserApiKeyCreation,
|
|
7
|
+
type UserApiKey,
|
|
8
|
+
type UserApiKeyCreation,
|
|
9
|
+
} from "@ohmyhost/contracts/user-api-keys";
|
|
10
|
+
|
|
11
|
+
export interface StoredUserApiKey {
|
|
12
|
+
request_id: string;
|
|
13
|
+
key: UserApiKey;
|
|
14
|
+
token_file: string;
|
|
15
|
+
}
|
|
16
|
+
export class UserTokenFileError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
readonly code:
|
|
19
|
+
| "token_file_not_ignored"
|
|
20
|
+
| "token_file_unsafe"
|
|
21
|
+
| "token_file_has_token"
|
|
22
|
+
| "token_file_changed"
|
|
23
|
+
| "token_value_unavailable",
|
|
24
|
+
readonly keyId?: string,
|
|
25
|
+
) {
|
|
26
|
+
super(
|
|
27
|
+
{
|
|
28
|
+
token_file_not_ignored:
|
|
29
|
+
"Add the selected env file to .gitignore or choose a location outside the repository. No token was created or written.",
|
|
30
|
+
token_file_unsafe:
|
|
31
|
+
"Choose a private .env file owned by you. Symlinks, shared files and files larger than 64 KiB are not accepted.",
|
|
32
|
+
token_file_has_token:
|
|
33
|
+
"The selected file already contains OHMYHOST_TOKEN. Use that token or choose a new file; existing credentials were preserved.",
|
|
34
|
+
token_file_changed:
|
|
35
|
+
"The environment file changed during creation. It was not overwritten. List tokens to inspect the issued key before creating another.",
|
|
36
|
+
token_value_unavailable:
|
|
37
|
+
"This token was already issued and its full value cannot be retrieved. Use the previously saved file, or explicitly revoke the listed key before creating a replacement.",
|
|
38
|
+
}[code] + (keyId ? ` Token ID: ${keyId}.` : ""),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Prepare before the API call; append only OHMYHOST_TOKEN and never return its value to a chat. */
|
|
44
|
+
export async function prepareUserTokenFile(output: string) {
|
|
45
|
+
const path = resolve(output);
|
|
46
|
+
if (!basename(path).startsWith(".env") && !basename(path).endsWith(".env"))
|
|
47
|
+
throw new UserTokenFileError("token_file_unsafe");
|
|
48
|
+
let file: FileHandle | undefined;
|
|
49
|
+
try {
|
|
50
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
51
|
+
await requireIgnoredTokenFile(path);
|
|
52
|
+
const existing = await lstat(path).catch((error: unknown) => {
|
|
53
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
54
|
+
return null;
|
|
55
|
+
throw error;
|
|
56
|
+
});
|
|
57
|
+
if (existing && (!existing.isFile() || existing.isSymbolicLink() || existing.nlink !== 1))
|
|
58
|
+
throw new UserTokenFileError("token_file_unsafe");
|
|
59
|
+
file = await open(
|
|
60
|
+
path,
|
|
61
|
+
constants.O_RDWR |
|
|
62
|
+
constants.O_APPEND |
|
|
63
|
+
(constants.O_NOFOLLOW ?? 0) |
|
|
64
|
+
(existing ? 0 : constants.O_CREAT | constants.O_EXCL),
|
|
65
|
+
0o600,
|
|
66
|
+
);
|
|
67
|
+
const handle = file;
|
|
68
|
+
await secureTokenFile(path, handle);
|
|
69
|
+
const initial = await tokenFileText(handle);
|
|
70
|
+
if (parseEnv(initial)["OHMYHOST_TOKEN"]) throw new UserTokenFileError("token_file_has_token");
|
|
71
|
+
const preview = "sk_ohmyho_token_preview_only";
|
|
72
|
+
if (parseEnv(`${initial}\nOHMYHOST_TOKEN=${preview}\n`)["OHMYHOST_TOKEN"] !== preview)
|
|
73
|
+
throw new UserTokenFileError("token_file_unsafe");
|
|
74
|
+
const identity = await handle.stat();
|
|
75
|
+
return {
|
|
76
|
+
async save(value: UserApiKeyCreation): Promise<StoredUserApiKey> {
|
|
77
|
+
const result = parseUserApiKeyCreation(value);
|
|
78
|
+
if (result.value === null)
|
|
79
|
+
throw new UserTokenFileError("token_value_unavailable", result.key.id);
|
|
80
|
+
const current = await lstat(path);
|
|
81
|
+
if (
|
|
82
|
+
!current.isFile() ||
|
|
83
|
+
current.isSymbolicLink() ||
|
|
84
|
+
current.nlink !== 1 ||
|
|
85
|
+
current.ino !== identity.ino ||
|
|
86
|
+
current.dev !== identity.dev ||
|
|
87
|
+
(await tokenFileText(handle)) !== initial
|
|
88
|
+
)
|
|
89
|
+
throw new UserTokenFileError("token_file_changed");
|
|
90
|
+
await handle.appendFile(`\nOHMYHOST_TOKEN=${result.value}\n`, "utf8");
|
|
91
|
+
await handle.sync();
|
|
92
|
+
if (parseEnv(await tokenFileText(handle))["OHMYHOST_TOKEN"] !== result.value)
|
|
93
|
+
throw new UserTokenFileError("token_file_changed");
|
|
94
|
+
const saved = await lstat(path);
|
|
95
|
+
if (
|
|
96
|
+
!saved.isFile() ||
|
|
97
|
+
saved.isSymbolicLink() ||
|
|
98
|
+
saved.ino !== identity.ino ||
|
|
99
|
+
saved.dev !== identity.dev
|
|
100
|
+
)
|
|
101
|
+
throw new UserTokenFileError("token_file_changed", result.key.id);
|
|
102
|
+
return { request_id: result.request_id, key: result.key, token_file: path };
|
|
103
|
+
},
|
|
104
|
+
close: () => handle.close(),
|
|
105
|
+
};
|
|
106
|
+
} catch (error) {
|
|
107
|
+
await file?.close();
|
|
108
|
+
if (error instanceof UserTokenFileError) throw error;
|
|
109
|
+
throw new UserTokenFileError("token_file_unsafe");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function tokenFileText(file: FileHandle) {
|
|
114
|
+
const status = await file.stat();
|
|
115
|
+
if (!status.isFile() || status.nlink !== 1 || status.size > 65_536)
|
|
116
|
+
throw new UserTokenFileError("token_file_unsafe");
|
|
117
|
+
const bytes = Buffer.alloc(status.size);
|
|
118
|
+
const result = await file.read(bytes, 0, bytes.length, 0);
|
|
119
|
+
if (result.bytesRead !== bytes.length) throw new UserTokenFileError("token_file_changed");
|
|
120
|
+
return new TextDecoder("utf8", { fatal: true }).decode(bytes);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const windowsAcl = String.raw`
|
|
124
|
+
$ErrorActionPreference = 'Stop'
|
|
125
|
+
$path = $env:OMH_TOKEN_FILE
|
|
126
|
+
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
127
|
+
$current = Get-Acl -LiteralPath $path
|
|
128
|
+
if ($current.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { throw 'Unexpected owner' }
|
|
129
|
+
$acl = New-Object System.Security.AccessControl.FileSecurity
|
|
130
|
+
$acl.SetOwner($sid)
|
|
131
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
132
|
+
$acl.AddAccessRule((New-Object -TypeName System.Security.AccessControl.FileSystemAccessRule -ArgumentList @($sid, 'FullControl', 'Allow')))
|
|
133
|
+
$system = New-Object -TypeName System.Security.Principal.SecurityIdentifier -ArgumentList 'S-1-5-18'
|
|
134
|
+
$acl.AddAccessRule((New-Object -TypeName System.Security.AccessControl.FileSystemAccessRule -ArgumentList @($system, 'FullControl', 'Allow')))
|
|
135
|
+
Set-Acl -LiteralPath $path -AclObject $acl
|
|
136
|
+
$verified = Get-Acl -LiteralPath $path
|
|
137
|
+
$rules = @($verified.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))
|
|
138
|
+
if (-not $verified.AreAccessRulesProtected -or $rules.Count -ne 2) { throw 'Unexpected access rules' }
|
|
139
|
+
foreach ($rule in $rules) {
|
|
140
|
+
if ($rule.IdentityReference.Value -notin @($sid.Value, 'S-1-5-18') -or $rule.AccessControlType -ne 'Allow' -or $rule.FileSystemRights -ne 'FullControl') { throw 'Unexpected access rule' }
|
|
141
|
+
}
|
|
142
|
+
[Console]::Write('secured')
|
|
143
|
+
`;
|
|
144
|
+
|
|
145
|
+
async function secureTokenFile(path: string, file: FileHandle) {
|
|
146
|
+
const status = await file.stat();
|
|
147
|
+
if (process.platform === "win32") {
|
|
148
|
+
const environment: Record<string, string> = { OMH_TOKEN_FILE: path };
|
|
149
|
+
for (const name of ["SystemRoot", "WINDIR", "PATH", "TEMP", "TMP", "USERPROFILE"]) {
|
|
150
|
+
const value = process.env[name];
|
|
151
|
+
if (value !== undefined) environment[name] = value;
|
|
152
|
+
}
|
|
153
|
+
const result = await promisify(execFile)(
|
|
154
|
+
"powershell.exe",
|
|
155
|
+
[
|
|
156
|
+
"-NoProfile",
|
|
157
|
+
"-NonInteractive",
|
|
158
|
+
"-EncodedCommand",
|
|
159
|
+
Buffer.from(windowsAcl, "utf16le").toString("base64"),
|
|
160
|
+
],
|
|
161
|
+
{ env: environment, timeout: 10_000, windowsHide: true, maxBuffer: 4096 },
|
|
162
|
+
);
|
|
163
|
+
if (result.stdout !== "secured") throw new UserTokenFileError("token_file_unsafe");
|
|
164
|
+
} else {
|
|
165
|
+
if (status.uid !== process.getuid?.()) throw new UserTokenFileError("token_file_unsafe");
|
|
166
|
+
await file.chmod(0o600);
|
|
167
|
+
if (((await file.stat()).mode & 0o777) !== 0o600)
|
|
168
|
+
throw new UserTokenFileError("token_file_unsafe");
|
|
169
|
+
}
|
|
170
|
+
const observed = await lstat(path);
|
|
171
|
+
if (observed.isSymbolicLink() || observed.ino !== status.ino || observed.dev !== status.dev)
|
|
172
|
+
throw new UserTokenFileError("token_file_unsafe");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function requireIgnoredTokenFile(path: string) {
|
|
176
|
+
const parent = await realpath(dirname(path));
|
|
177
|
+
let directory = parent;
|
|
178
|
+
while (true) {
|
|
179
|
+
const marker = await lstat(resolve(directory, ".git")).catch((error: unknown) => {
|
|
180
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
181
|
+
return null;
|
|
182
|
+
throw error;
|
|
183
|
+
});
|
|
184
|
+
if (marker !== null) {
|
|
185
|
+
const environment: Record<string, string> = {};
|
|
186
|
+
for (const name of ["PATH", "HOME", "USERPROFILE", "SystemRoot", "WINDIR", "TEMP", "TMP"]) {
|
|
187
|
+
const value = process.env[name];
|
|
188
|
+
if (value !== undefined) environment[name] = value;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await promisify(execFile)(
|
|
192
|
+
"git",
|
|
193
|
+
[
|
|
194
|
+
"-c",
|
|
195
|
+
"core.fsmonitor=false",
|
|
196
|
+
"-C",
|
|
197
|
+
parent,
|
|
198
|
+
"check-ignore",
|
|
199
|
+
"--quiet",
|
|
200
|
+
"--",
|
|
201
|
+
resolve(parent, basename(path)),
|
|
202
|
+
],
|
|
203
|
+
{ env: environment, timeout: 5000, windowsHide: true, maxBuffer: 4096 },
|
|
204
|
+
);
|
|
205
|
+
} catch {
|
|
206
|
+
throw new UserTokenFileError("token_file_not_ignored");
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const next = dirname(directory);
|
|
211
|
+
if (next === directory) return;
|
|
212
|
+
directory = next;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { TokenRefresher } from "./cli.js";
|
|
2
|
+
import type { CredentialStore, StoredCredential } from "./credential-store.js";
|
|
3
|
+
import type { PublicWorkspace } from "./runtime-contract.js";
|
|
4
|
+
|
|
5
|
+
/** The credential material a workspace selection needs; absent for a user API token. */
|
|
6
|
+
export interface WorkspaceSession {
|
|
7
|
+
readonly store: CredentialStore;
|
|
8
|
+
readonly refresher: TokenRefresher;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A user API token carries a fixed scope and can never select another workspace. */
|
|
12
|
+
export class InteractiveSessionRequiredError extends Error {
|
|
13
|
+
public constructor() {
|
|
14
|
+
super("Workspace selection requires an interactive login");
|
|
15
|
+
this.name = "InteractiveSessionRequiredError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type WorkspaceSelection =
|
|
20
|
+
| { readonly outcome: "selected"; readonly organization: PublicWorkspace }
|
|
21
|
+
| { readonly outcome: "no_workspace" }
|
|
22
|
+
| { readonly outcome: "choice_required"; readonly organizations: readonly PublicWorkspace[] }
|
|
23
|
+
| { readonly outcome: "not_a_member"; readonly organizations: readonly PublicWorkspace[] };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Decide which workspace a login or creation should select. A caller that named one gets exactly
|
|
27
|
+
* that one or nothing; an unnamed choice is only made when the answer cannot be wrong.
|
|
28
|
+
*/
|
|
29
|
+
export const chooseWorkspace = (
|
|
30
|
+
organizations: readonly PublicWorkspace[],
|
|
31
|
+
requested: string | undefined,
|
|
32
|
+
): WorkspaceSelection => {
|
|
33
|
+
if (requested !== undefined) {
|
|
34
|
+
const match = organizations.find((organization) => organization.id === requested);
|
|
35
|
+
return match === undefined
|
|
36
|
+
? { outcome: "not_a_member", organizations }
|
|
37
|
+
: { outcome: "selected", organization: match };
|
|
38
|
+
}
|
|
39
|
+
if (organizations.length === 0) return { outcome: "no_workspace" };
|
|
40
|
+
const only = organizations[0];
|
|
41
|
+
if (organizations.length === 1 && only !== undefined)
|
|
42
|
+
return { outcome: "selected", organization: only };
|
|
43
|
+
return { outcome: "choice_required", organizations };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Put the chosen workspace into the stored credential. The organization lives in the access token,
|
|
48
|
+
* so selecting means asking the provider for a token scoped to it and keeping both identifiers.
|
|
49
|
+
*/
|
|
50
|
+
export const selectWorkspace = async (
|
|
51
|
+
session: WorkspaceSession,
|
|
52
|
+
organization: PublicWorkspace,
|
|
53
|
+
signal: AbortSignal,
|
|
54
|
+
): Promise<void> => {
|
|
55
|
+
const stored = await session.store.read();
|
|
56
|
+
if (stored === undefined) throw new InteractiveSessionRequiredError();
|
|
57
|
+
const refreshed = await session.refresher.refresh(
|
|
58
|
+
stored.refreshToken,
|
|
59
|
+
signal,
|
|
60
|
+
organization.workos_id,
|
|
61
|
+
);
|
|
62
|
+
await session.store.replace({
|
|
63
|
+
...refreshed,
|
|
64
|
+
workosOrganizationId: organization.workos_id,
|
|
65
|
+
organizationId: organization.id,
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The workspace the stored credential is scoped to, or null while none was selected. */
|
|
70
|
+
export const selectedWorkspaceId = async (session: WorkspaceSession): Promise<string | null> =>
|
|
71
|
+
(await session.store.read())?.organizationId ?? null;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The workspace fields of a credential, present only once one has been selected. A refresh returns
|
|
75
|
+
* a bare credential, so carrying these forward is what keeps the selection alive.
|
|
76
|
+
*/
|
|
77
|
+
export const workspaceOf = (
|
|
78
|
+
credential: StoredCredential,
|
|
79
|
+
): { workosOrganizationId?: string; organizationId?: string } => ({
|
|
80
|
+
...(credential.workosOrganizationId === undefined
|
|
81
|
+
? {}
|
|
82
|
+
: { workosOrganizationId: credential.workosOrganizationId }),
|
|
83
|
+
...(credential.organizationId === undefined ? {} : { organizationId: credential.organizationId }),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** The customer-visible projection; the provider identifier stays inside the client. */
|
|
87
|
+
export const publicWorkspace = (
|
|
88
|
+
organization: PublicWorkspace,
|
|
89
|
+
): { readonly id: string; readonly name: string } => ({
|
|
90
|
+
id: organization.id,
|
|
91
|
+
name: organization.name,
|
|
92
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
declare const applicationRootBrand: unique symbol;
|
|
2
|
+
export type ApplicationRoot = string & {
|
|
3
|
+
readonly [applicationRootBrand]: "ApplicationRoot";
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export const APPLICATION_ROOT_PATTERN =
|
|
7
|
+
/^(?:\.|[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*)$/u;
|
|
8
|
+
export const APPLICATION_ROOT_MAX_BYTES = 64;
|
|
9
|
+
export const APPLICATION_ROOT_MAX_SEGMENTS = 8;
|
|
10
|
+
export const APPLICATION_ROOT_MAX_SEGMENT_BYTES = 63;
|
|
11
|
+
|
|
12
|
+
export const APPLICATION_ROOT_CONFORMANCE_CORPUS = Object.freeze({
|
|
13
|
+
accepted: Object.freeze([
|
|
14
|
+
Object.freeze({ name: "repository root", value: "." }),
|
|
15
|
+
Object.freeze({ name: "single segment", value: "site" }),
|
|
16
|
+
Object.freeze({ name: "portable segment punctuation", value: "apps/web.v2_test-prod" }),
|
|
17
|
+
Object.freeze({ name: "maximum segment bytes", value: "a".repeat(63) }),
|
|
18
|
+
Object.freeze({
|
|
19
|
+
name: "maximum total bytes",
|
|
20
|
+
value: `${"a".repeat(31)}/${"b".repeat(32)}`,
|
|
21
|
+
}),
|
|
22
|
+
Object.freeze({ name: "maximum segment count", value: "a/b/c/d/e/f/g/h" }),
|
|
23
|
+
]),
|
|
24
|
+
rejected: Object.freeze([
|
|
25
|
+
Object.freeze({ name: "empty string", value: "" }),
|
|
26
|
+
Object.freeze({ name: "absolute path", value: "/site" }),
|
|
27
|
+
Object.freeze({ name: "parent traversal", value: "../site" }),
|
|
28
|
+
Object.freeze({ name: "embedded traversal", value: "apps/../site" }),
|
|
29
|
+
Object.freeze({ name: "leading dot segment", value: "./site" }),
|
|
30
|
+
Object.freeze({ name: "trailing separator", value: "site/" }),
|
|
31
|
+
Object.freeze({ name: "repeated separator", value: "apps//site" }),
|
|
32
|
+
Object.freeze({ name: "backslash", value: "apps\\site" }),
|
|
33
|
+
Object.freeze({ name: "colon syntax", value: "C:site" }),
|
|
34
|
+
Object.freeze({ name: "leading whitespace", value: " site" }),
|
|
35
|
+
Object.freeze({ name: "trailing whitespace", value: "site " }),
|
|
36
|
+
Object.freeze({ name: "control character", value: "site\nweb" }),
|
|
37
|
+
Object.freeze({ name: "unicode", value: "café" }),
|
|
38
|
+
Object.freeze({ name: "percent ambiguity", value: "site%2fweb" }),
|
|
39
|
+
Object.freeze({ name: "hidden segment", value: ".site" }),
|
|
40
|
+
Object.freeze({ name: "nine segments", value: "a/b/c/d/e/f/g/h/i" }),
|
|
41
|
+
Object.freeze({ name: "segment over 63 bytes", value: "a".repeat(64) }),
|
|
42
|
+
Object.freeze({
|
|
43
|
+
name: "path over 64 bytes",
|
|
44
|
+
value: `${"a".repeat(32)}/${"b".repeat(32)}`,
|
|
45
|
+
}),
|
|
46
|
+
Object.freeze({ name: "non-string", value: 1 }),
|
|
47
|
+
]),
|
|
48
|
+
ustarEnvelope: Object.freeze([
|
|
49
|
+
Object.freeze({
|
|
50
|
+
name: "maximum accepted application root",
|
|
51
|
+
repositoryNameBytes: 100,
|
|
52
|
+
separatorBytes: 3,
|
|
53
|
+
commitShaBytes: 40,
|
|
54
|
+
applicationRootBytes: 64,
|
|
55
|
+
inspectionLeafBytes: 24,
|
|
56
|
+
archivePathBytes: 231,
|
|
57
|
+
ustarPathEnvelopeBytes: 255,
|
|
58
|
+
fitsWithoutPax: true,
|
|
59
|
+
}),
|
|
60
|
+
]),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export function normalizeApplicationRoot(value: unknown = "."): ApplicationRoot {
|
|
64
|
+
if (typeof value !== "string" || !APPLICATION_ROOT_PATTERN.test(value)) {
|
|
65
|
+
throw new TypeError("Application root is invalid");
|
|
66
|
+
}
|
|
67
|
+
const encoder = new TextEncoder();
|
|
68
|
+
const encoded = encoder.encode(value);
|
|
69
|
+
const segments = value === "." ? [] : value.split("/");
|
|
70
|
+
if (
|
|
71
|
+
encoded.byteLength > APPLICATION_ROOT_MAX_BYTES ||
|
|
72
|
+
segments.length > APPLICATION_ROOT_MAX_SEGMENTS ||
|
|
73
|
+
segments.some(
|
|
74
|
+
(segment) => encoder.encode(segment).byteLength > APPLICATION_ROOT_MAX_SEGMENT_BYTES,
|
|
75
|
+
)
|
|
76
|
+
) {
|
|
77
|
+
throw new TypeError("Application root is invalid");
|
|
78
|
+
}
|
|
79
|
+
return value as ApplicationRoot;
|
|
80
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
export type BillingTaxIssueCode =
|
|
2
|
+
| "billing_tax_location_required"
|
|
3
|
+
| "billing_tax_calculation_failed"
|
|
4
|
+
| "billing_tax_configuration_required";
|
|
5
|
+
export interface BillingTaxIssue {
|
|
6
|
+
code: BillingTaxIssueCode;
|
|
7
|
+
invoice_id: string;
|
|
8
|
+
observed_at: string;
|
|
9
|
+
required_action: "open_billing_portal" | "contact_support";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface BillingCheckoutView {
|
|
13
|
+
organization_id: string;
|
|
14
|
+
checkout_id: string;
|
|
15
|
+
offer: "topup" | "paid";
|
|
16
|
+
state: "open" | "complete" | "expired";
|
|
17
|
+
payment_confirmed: boolean;
|
|
18
|
+
url: string | null;
|
|
19
|
+
expires_at: string;
|
|
20
|
+
packs: number;
|
|
21
|
+
credited_micros: string;
|
|
22
|
+
revoked_micros: string;
|
|
23
|
+
paid_until: string | null;
|
|
24
|
+
required_action: "none" | "complete_checkout" | "open_billing_portal" | "contact_support";
|
|
25
|
+
billing_issue: BillingTaxIssue | null;
|
|
26
|
+
}
|
|
27
|
+
export interface BillingPortalView {
|
|
28
|
+
organization_id: string;
|
|
29
|
+
url: string;
|
|
30
|
+
created_at: string;
|
|
31
|
+
}
|
|
32
|
+
export function assertBillingTaxIssue(value: unknown): asserts value is BillingTaxIssue | null {
|
|
33
|
+
if (value === null) return;
|
|
34
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
35
|
+
throw new Error("billing_response_invalid");
|
|
36
|
+
const v = value as Record<string, unknown>;
|
|
37
|
+
if (
|
|
38
|
+
Object.keys(v).sort().join(",") !== "code,invoice_id,observed_at,required_action" ||
|
|
39
|
+
![
|
|
40
|
+
"billing_tax_location_required",
|
|
41
|
+
"billing_tax_calculation_failed",
|
|
42
|
+
"billing_tax_configuration_required",
|
|
43
|
+
].includes(String(v["code"])) ||
|
|
44
|
+
typeof v["invoice_id"] !== "string" ||
|
|
45
|
+
!/^in_[A-Za-z0-9_]{1,253}$/u.test(v["invoice_id"]) ||
|
|
46
|
+
!timestamp(v["observed_at"]) ||
|
|
47
|
+
v["required_action"] !==
|
|
48
|
+
(v["code"] === "billing_tax_location_required" ? "open_billing_portal" : "contact_support")
|
|
49
|
+
)
|
|
50
|
+
throw new Error("billing_response_invalid");
|
|
51
|
+
}
|
|
52
|
+
const ulid = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u;
|
|
53
|
+
const timestamp = (v: unknown): v is string =>
|
|
54
|
+
typeof v === "string" && Number.isFinite(Date.parse(v)) && new Date(v).toISOString() === v;
|
|
55
|
+
function stripeUrl(value: unknown, host: string): value is string {
|
|
56
|
+
if (typeof value !== "string" || value.length > 8192) return false;
|
|
57
|
+
try {
|
|
58
|
+
const u = new URL(value);
|
|
59
|
+
return u.protocol === "https:" && u.hostname === host && !u.username && !u.password && !u.port;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function assertBillingCheckout(value: unknown): asserts value is BillingCheckoutView {
|
|
65
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
66
|
+
throw new Error("billing_response_invalid");
|
|
67
|
+
const v = value as Record<string, unknown>;
|
|
68
|
+
if (
|
|
69
|
+
Object.keys(v).sort().join(",") !==
|
|
70
|
+
"billing_issue,checkout_id,credited_micros,expires_at,offer,organization_id,packs,paid_until,payment_confirmed,required_action,revoked_micros,state,url" ||
|
|
71
|
+
typeof v["organization_id"] !== "string" ||
|
|
72
|
+
!ulid.test(v["organization_id"]) ||
|
|
73
|
+
typeof v["checkout_id"] !== "string" ||
|
|
74
|
+
!ulid.test(v["checkout_id"]) ||
|
|
75
|
+
typeof v["offer"] !== "string" ||
|
|
76
|
+
!["topup", "paid"].includes(v["offer"]) ||
|
|
77
|
+
typeof v["state"] !== "string" ||
|
|
78
|
+
!["open", "complete", "expired"].includes(v["state"]) ||
|
|
79
|
+
typeof v["payment_confirmed"] !== "boolean" ||
|
|
80
|
+
typeof v["required_action"] !== "string" ||
|
|
81
|
+
!["none", "complete_checkout", "open_billing_portal", "contact_support"].includes(
|
|
82
|
+
v["required_action"],
|
|
83
|
+
) ||
|
|
84
|
+
(v["url"] !== null && !stripeUrl(v["url"], "checkout.stripe.com")) ||
|
|
85
|
+
!timestamp(v["expires_at"]) ||
|
|
86
|
+
(v["paid_until"] !== null && !timestamp(v["paid_until"])) ||
|
|
87
|
+
!Number.isInteger(v["packs"]) ||
|
|
88
|
+
Number(v["packs"]) < 1 ||
|
|
89
|
+
Number(v["packs"]) > 100 ||
|
|
90
|
+
(v["offer"] === "paid" && v["packs"] !== 1) ||
|
|
91
|
+
![v["credited_micros"], v["revoked_micros"]].every(
|
|
92
|
+
(x) => typeof x === "string" && /^(0|[1-9][0-9]{0,18})$/u.test(x),
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
throw new Error("billing_response_invalid");
|
|
96
|
+
assertBillingTaxIssue(v["billing_issue"]);
|
|
97
|
+
}
|
|
98
|
+
export function assertBillingPortal(value: unknown): asserts value is BillingPortalView {
|
|
99
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
100
|
+
throw new Error("billing_response_invalid");
|
|
101
|
+
const v = value as Record<string, unknown>;
|
|
102
|
+
if (
|
|
103
|
+
Object.keys(v).sort().join(",") !== "created_at,organization_id,url" ||
|
|
104
|
+
typeof v["organization_id"] !== "string" ||
|
|
105
|
+
!ulid.test(v["organization_id"]) ||
|
|
106
|
+
!timestamp(v["created_at"]) ||
|
|
107
|
+
!stripeUrl(v["url"], "billing.stripe.com")
|
|
108
|
+
)
|
|
109
|
+
throw new Error("billing_response_invalid");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface RechargeView {
|
|
113
|
+
organization_id: string;
|
|
114
|
+
portal_available: boolean;
|
|
115
|
+
enabled: boolean;
|
|
116
|
+
revision: number;
|
|
117
|
+
status:
|
|
118
|
+
| "off"
|
|
119
|
+
| "setup_required"
|
|
120
|
+
| "on"
|
|
121
|
+
| "payment_required"
|
|
122
|
+
| "monthly_limit"
|
|
123
|
+
| "needs_reconciliation"
|
|
124
|
+
| "tax_required";
|
|
125
|
+
monthly_limit_minor: number;
|
|
126
|
+
spent_minor: number;
|
|
127
|
+
currency: "usd";
|
|
128
|
+
amount_minor: 900;
|
|
129
|
+
credits: 1000;
|
|
130
|
+
threshold_credits: 100;
|
|
131
|
+
setup_url: string | null;
|
|
132
|
+
invoice_url: string | null;
|
|
133
|
+
billing_issue: BillingTaxIssue | null;
|
|
134
|
+
}
|
|
135
|
+
export function assertRecharge(value: unknown): asserts value is RechargeView {
|
|
136
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
137
|
+
throw new Error("billing_response_invalid");
|
|
138
|
+
const v = value as Record<string, unknown>;
|
|
139
|
+
if (
|
|
140
|
+
Object.keys(v).sort().join(",") !==
|
|
141
|
+
"amount_minor,billing_issue,credits,currency,enabled,invoice_url,monthly_limit_minor,organization_id,portal_available,revision,setup_url,spent_minor,status,threshold_credits" ||
|
|
142
|
+
typeof v["organization_id"] !== "string" ||
|
|
143
|
+
!ulid.test(v["organization_id"]) ||
|
|
144
|
+
typeof v["portal_available"] !== "boolean" ||
|
|
145
|
+
typeof v["enabled"] !== "boolean" ||
|
|
146
|
+
!Number.isSafeInteger(v["revision"]) ||
|
|
147
|
+
Number(v["revision"]) < 0 ||
|
|
148
|
+
!Number.isSafeInteger(v["monthly_limit_minor"]) ||
|
|
149
|
+
Number(v["monthly_limit_minor"]) < 1000 ||
|
|
150
|
+
Number(v["monthly_limit_minor"]) > 100000 ||
|
|
151
|
+
!Number.isSafeInteger(v["spent_minor"]) ||
|
|
152
|
+
Number(v["spent_minor"]) < 0 ||
|
|
153
|
+
v["currency"] !== "usd" ||
|
|
154
|
+
v["amount_minor"] !== 900 ||
|
|
155
|
+
v["credits"] !== 1000 ||
|
|
156
|
+
v["threshold_credits"] !== 100 ||
|
|
157
|
+
typeof v["status"] !== "string" ||
|
|
158
|
+
![
|
|
159
|
+
"off",
|
|
160
|
+
"setup_required",
|
|
161
|
+
"on",
|
|
162
|
+
"payment_required",
|
|
163
|
+
"monthly_limit",
|
|
164
|
+
"needs_reconciliation",
|
|
165
|
+
"tax_required",
|
|
166
|
+
].includes(v["status"]) ||
|
|
167
|
+
(v["setup_url"] !== null && !stripeUrl(v["setup_url"], "checkout.stripe.com")) ||
|
|
168
|
+
(v["invoice_url"] !== null && !stripeUrl(v["invoice_url"], "invoice.stripe.com"))
|
|
169
|
+
)
|
|
170
|
+
throw new Error("billing_response_invalid");
|
|
171
|
+
assertBillingTaxIssue(v["billing_issue"]);
|
|
172
|
+
}
|