@aikagi/runtime 0.1.0
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/README.md +41 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +153 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @aikagi/runtime
|
|
2
|
+
|
|
3
|
+
Load your [aikagi](https://aikagi.dev) keys into a running app at start-up. Nothing is copied into your hosting provider.
|
|
4
|
+
|
|
5
|
+
- **Vercel**: uses the deployment's OIDC token. No key is stored on Vercel.
|
|
6
|
+
- **Cloudflare Workers / Pages**: uses one read-only key (`AIKAGI_KEY`) that `aikagi init` puts there for you.
|
|
7
|
+
|
|
8
|
+
Values are fetched once per instance and kept in memory. If aikagi cannot be reached, start-up fails instead of running with missing values.
|
|
9
|
+
|
|
10
|
+
## Set up
|
|
11
|
+
|
|
12
|
+
In your repository, run `aikagi init`. It finds `.vercel/project.json` or your `wrangler` config and connects the project.
|
|
13
|
+
|
|
14
|
+
## Next.js on Vercel
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// instrumentation.ts
|
|
18
|
+
import { loadAikagi } from '@aikagi/runtime'
|
|
19
|
+
|
|
20
|
+
export async function register() {
|
|
21
|
+
await loadAikagi()
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Cloudflare Workers
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { withAikagi } from '@aikagi/runtime'
|
|
29
|
+
|
|
30
|
+
export default withAikagi({
|
|
31
|
+
async fetch(request, env) {
|
|
32
|
+
return new Response(env.DATABASE_URL ? 'ok' : 'missing')
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Every fetch shows up in the project's access history in aikagi.
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 実行中のアプリが、起動時に aikagi から値を取る。
|
|
3
|
+
*
|
|
4
|
+
* - Vercel(Next.js など): `instrumentation.ts` の `register()` で `await loadAikagi()`。
|
|
5
|
+
* デプロイが持つ OIDC トークンを使うので、Vercel に鍵は置かない。
|
|
6
|
+
* - Cloudflare Workers: `export default withAikagi({ fetch(request, env) { ... } })`。
|
|
7
|
+
* `aikagi init` が置いた `AIKAGI_KEY`(読み取りだけの鍵)を使う。
|
|
8
|
+
*
|
|
9
|
+
* どちらも 1 つのインスタンス(isolate)の中では 1 回だけ取りに行き、あとはメモリの値を使う。
|
|
10
|
+
* aikagi に届かなければ起動(最初の要求)が失敗する。黙って空のまま動かさない。
|
|
11
|
+
*
|
|
12
|
+
* 依存は無い(fetch だけ)。値はログにもエラーの文言にも出さない。
|
|
13
|
+
*/
|
|
14
|
+
export declare const DEFAULT_API_URL = "https://app.aikagi.dev";
|
|
15
|
+
/** Cloudflare に置く鍵の名前(`aikagi init` がこの名前で置く)。 */
|
|
16
|
+
export declare const KEY_BINDING = "AIKAGI_KEY";
|
|
17
|
+
export interface LoadOptions {
|
|
18
|
+
/** aikagi の API。既定は `AIKAGI_API_URL`、無ければ https://app.aikagi.dev。 */
|
|
19
|
+
apiUrl?: string;
|
|
20
|
+
/** 同じ Vercel プロジェクトを複数の aikagi プロジェクトが信頼しているときだけ要る。 */
|
|
21
|
+
workspace?: string;
|
|
22
|
+
project?: string;
|
|
23
|
+
/**
|
|
24
|
+
* 実行時の鍵。既定は `AIKAGI_KEY`。渡すと Vercel の OIDC ではなくこちらを使う。
|
|
25
|
+
*/
|
|
26
|
+
key?: string;
|
|
27
|
+
/** Vercel の OIDC トークン。既定は要求の `x-vercel-oidc-token`、無ければ `VERCEL_OIDC_TOKEN`。 */
|
|
28
|
+
vercelOidcToken?: string;
|
|
29
|
+
/** テスト用。 */
|
|
30
|
+
fetch?: typeof fetch;
|
|
31
|
+
}
|
|
32
|
+
export declare class AikagiError extends Error {
|
|
33
|
+
readonly status: number | null;
|
|
34
|
+
constructor(message: string, status?: number | null);
|
|
35
|
+
}
|
|
36
|
+
type Vars = Record<string, string | undefined>;
|
|
37
|
+
/** 資格を交換し、その環境の値を取る。キャッシュはしない(呼ぶ側が持つ)。 */
|
|
38
|
+
export declare function fetchAikagi(options?: LoadOptions, vars?: Vars): Promise<Record<string, string>>;
|
|
39
|
+
/**
|
|
40
|
+
* Node.js(Vercel の関数、Next.js)で使う。値を取り、`process.env` に入れる。
|
|
41
|
+
* 1 つのインスタンスで 2 回目からはメモリの値を返す(取り直さない)。
|
|
42
|
+
* 失敗したら次の呼び出しでもう一度取りに行く。
|
|
43
|
+
*/
|
|
44
|
+
export declare function loadAikagi(options?: LoadOptions): Promise<Record<string, string>>;
|
|
45
|
+
/** テスト用。インスタンスのキャッシュを捨てる。 */
|
|
46
|
+
export declare function resetAikagiCache(): void;
|
|
47
|
+
/**
|
|
48
|
+
* Cloudflare Workers / Pages Functions の `env` に aikagi の値を足したものを返す。
|
|
49
|
+
* 鍵ごとに isolate の中で 1 回だけ取りに行く。
|
|
50
|
+
*/
|
|
51
|
+
export declare function aikagiEnv<E extends object>(env: E, options?: LoadOptions): Promise<E & Record<string, string>>;
|
|
52
|
+
type Handler = Record<string, unknown>;
|
|
53
|
+
/**
|
|
54
|
+
* モジュール形式の Worker を包む。`fetch` / `scheduled` / `queue` などの第 2 引数(env)に
|
|
55
|
+
* aikagi の値を足してから元の関数を呼ぶ。
|
|
56
|
+
*/
|
|
57
|
+
export declare function withAikagi<H extends Handler>(handler: H, options?: LoadOptions): H;
|
|
58
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 実行中のアプリが、起動時に aikagi から値を取る。
|
|
3
|
+
*
|
|
4
|
+
* - Vercel(Next.js など): `instrumentation.ts` の `register()` で `await loadAikagi()`。
|
|
5
|
+
* デプロイが持つ OIDC トークンを使うので、Vercel に鍵は置かない。
|
|
6
|
+
* - Cloudflare Workers: `export default withAikagi({ fetch(request, env) { ... } })`。
|
|
7
|
+
* `aikagi init` が置いた `AIKAGI_KEY`(読み取りだけの鍵)を使う。
|
|
8
|
+
*
|
|
9
|
+
* どちらも 1 つのインスタンス(isolate)の中では 1 回だけ取りに行き、あとはメモリの値を使う。
|
|
10
|
+
* aikagi に届かなければ起動(最初の要求)が失敗する。黙って空のまま動かさない。
|
|
11
|
+
*
|
|
12
|
+
* 依存は無い(fetch だけ)。値はログにもエラーの文言にも出さない。
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_API_URL = 'https://app.aikagi.dev';
|
|
15
|
+
/** Cloudflare に置く鍵の名前(`aikagi init` がこの名前で置く)。 */
|
|
16
|
+
export const KEY_BINDING = 'AIKAGI_KEY';
|
|
17
|
+
export class AikagiError extends Error {
|
|
18
|
+
status;
|
|
19
|
+
constructor(message, status = null) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'AikagiError';
|
|
22
|
+
this.status = status;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function processEnv() {
|
|
26
|
+
const proc = globalThis.process;
|
|
27
|
+
return proc?.env ?? {};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Vercel が要求ごとに渡す OIDC トークン(`@vercel/oidc` と同じ探し方)。
|
|
31
|
+
* 要求の外(起動時)では `VERCEL_OIDC_TOKEN` を読む。
|
|
32
|
+
*/
|
|
33
|
+
function vercelOidcToken(vars) {
|
|
34
|
+
const context = globalThis[Symbol.for('@vercel/request-context')];
|
|
35
|
+
const fromRequest = context?.get?.()?.headers?.['x-vercel-oidc-token'];
|
|
36
|
+
return fromRequest || vars.VERCEL_OIDC_TOKEN || undefined;
|
|
37
|
+
}
|
|
38
|
+
async function post(fetchImpl, url, body, token) {
|
|
39
|
+
let res;
|
|
40
|
+
try {
|
|
41
|
+
res = await fetchImpl(url, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: {
|
|
44
|
+
'content-type': 'application/json',
|
|
45
|
+
'user-agent': 'aikagi-runtime',
|
|
46
|
+
...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify(body),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new AikagiError(`aikagi: could not reach ${new URL(url).origin}`);
|
|
53
|
+
}
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
// 本文のうち、文言と手がかりだけを出す(値は入っていない)。
|
|
56
|
+
let detail = '';
|
|
57
|
+
try {
|
|
58
|
+
const parsed = (await res.json());
|
|
59
|
+
detail = [parsed.error?.message, parsed.error?.hint].filter(Boolean).join(' — ');
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// 本文が JSON でなければ番号だけ。
|
|
63
|
+
}
|
|
64
|
+
throw new AikagiError(`aikagi: ${new URL(url).pathname} returned ${res.status}${detail ? `: ${detail}` : ''}`, res.status);
|
|
65
|
+
}
|
|
66
|
+
return (await res.json());
|
|
67
|
+
}
|
|
68
|
+
/** 資格を交換し、その環境の値を取る。キャッシュはしない(呼ぶ側が持つ)。 */
|
|
69
|
+
export async function fetchAikagi(options = {}, vars = processEnv()) {
|
|
70
|
+
const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
|
|
71
|
+
const apiUrl = (options.apiUrl ?? vars.AIKAGI_API_URL ?? DEFAULT_API_URL).replace(/\/+$/, '');
|
|
72
|
+
const key = options.key ?? vars[KEY_BINDING];
|
|
73
|
+
let exchanged;
|
|
74
|
+
if (key) {
|
|
75
|
+
exchanged = await post(fetchImpl, `${apiUrl}/api/auth/runtime-key`, { key });
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const token = options.vercelOidcToken ?? vercelOidcToken(vars);
|
|
79
|
+
if (!token) {
|
|
80
|
+
throw new AikagiError(`aikagi: no credential found; on Vercel turn on OIDC for the project, on Cloudflare run \`aikagi init\` to set ${KEY_BINDING}`);
|
|
81
|
+
}
|
|
82
|
+
exchanged = await post(fetchImpl, `${apiUrl}/api/auth/vercel-oidc`, {
|
|
83
|
+
token,
|
|
84
|
+
...(options.workspace === undefined ? {} : { workspace: options.workspace }),
|
|
85
|
+
...(options.project === undefined ? {} : { project: options.project }),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const resolved = await post(fetchImpl, `${apiUrl}/api/runtime/resolve`, {
|
|
89
|
+
workspace: exchanged.workspace,
|
|
90
|
+
project: exchanged.project,
|
|
91
|
+
environment: exchanged.environment,
|
|
92
|
+
// 監査の resolve 行に「どこから取ったか」を残す。
|
|
93
|
+
command: key ? 'runtime-key' : 'vercel',
|
|
94
|
+
}, exchanged.access_token);
|
|
95
|
+
return resolved.env;
|
|
96
|
+
}
|
|
97
|
+
let loaded = null;
|
|
98
|
+
/**
|
|
99
|
+
* Node.js(Vercel の関数、Next.js)で使う。値を取り、`process.env` に入れる。
|
|
100
|
+
* 1 つのインスタンスで 2 回目からはメモリの値を返す(取り直さない)。
|
|
101
|
+
* 失敗したら次の呼び出しでもう一度取りに行く。
|
|
102
|
+
*/
|
|
103
|
+
export function loadAikagi(options = {}) {
|
|
104
|
+
if (loaded === null) {
|
|
105
|
+
const env = processEnv();
|
|
106
|
+
loaded = fetchAikagi(options, env).then((values) => {
|
|
107
|
+
// aikagi の値を正とする(前に先方へ置いた古い値があっても上書きする)。
|
|
108
|
+
Object.assign(env, values);
|
|
109
|
+
return values;
|
|
110
|
+
}, (err) => {
|
|
111
|
+
loaded = null;
|
|
112
|
+
throw err;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return loaded;
|
|
116
|
+
}
|
|
117
|
+
/** テスト用。インスタンスのキャッシュを捨てる。 */
|
|
118
|
+
export function resetAikagiCache() {
|
|
119
|
+
loaded = null;
|
|
120
|
+
workerCache.clear();
|
|
121
|
+
}
|
|
122
|
+
const workerCache = new Map();
|
|
123
|
+
/**
|
|
124
|
+
* Cloudflare Workers / Pages Functions の `env` に aikagi の値を足したものを返す。
|
|
125
|
+
* 鍵ごとに isolate の中で 1 回だけ取りに行く。
|
|
126
|
+
*/
|
|
127
|
+
export function aikagiEnv(env, options = {}) {
|
|
128
|
+
const vars = env;
|
|
129
|
+
const key = options.key ?? vars[KEY_BINDING];
|
|
130
|
+
if (!key) {
|
|
131
|
+
return Promise.reject(new AikagiError(`aikagi: ${KEY_BINDING} is not set; run \`aikagi init\` in the repository`));
|
|
132
|
+
}
|
|
133
|
+
let pending = workerCache.get(key);
|
|
134
|
+
if (pending === undefined) {
|
|
135
|
+
pending = fetchAikagi({ ...options, key }, vars);
|
|
136
|
+
workerCache.set(key, pending);
|
|
137
|
+
pending.catch(() => workerCache.delete(key));
|
|
138
|
+
}
|
|
139
|
+
return pending.then((values) => ({ ...env, ...values }));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* モジュール形式の Worker を包む。`fetch` / `scheduled` / `queue` などの第 2 引数(env)に
|
|
143
|
+
* aikagi の値を足してから元の関数を呼ぶ。
|
|
144
|
+
*/
|
|
145
|
+
export function withAikagi(handler, options = {}) {
|
|
146
|
+
const wrapped = { ...handler };
|
|
147
|
+
for (const [name, value] of Object.entries(handler)) {
|
|
148
|
+
if (typeof value !== 'function')
|
|
149
|
+
continue;
|
|
150
|
+
wrapped[name] = async (first, env, ...rest) => value.call(handler, first, await aikagiEnv(env, options), ...rest);
|
|
151
|
+
}
|
|
152
|
+
return wrapped;
|
|
153
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aikagi/runtime",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Load your aikagi keys into a running app at start-up (Vercel via OIDC, Cloudflare Workers via one read-only key).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/dx-proj/aikagi.git",
|
|
9
|
+
"directory": "packages/runtime"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://aikagi.dev/guide/ci/",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"aikagi",
|
|
14
|
+
"secrets",
|
|
15
|
+
"environment-variables",
|
|
16
|
+
"vercel",
|
|
17
|
+
"cloudflare-workers",
|
|
18
|
+
"oidc"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.build.json",
|
|
35
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
36
|
+
"test": "vitest run"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"typescript": "7.0.2",
|
|
40
|
+
"vitest": "4.1.11"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public",
|
|
44
|
+
"provenance": true
|
|
45
|
+
}
|
|
46
|
+
}
|