@learncard/learn-cloud-service 2.5.35 → 2.5.36
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/.env.example +24 -25
- package/CHANGELOG.md +24 -0
- package/bootstrapLambda.test.ts +68 -0
- package/config/config.dev.json +8 -0
- package/config/config.example.json +8 -0
- package/config/config.json +1 -0
- package/config/config.production.json +8 -0
- package/config/config.scouts.dev.json +3 -0
- package/config/config.scouts.production.json +4 -0
- package/didWebLambda.test.ts +41 -0
- package/didWebLambda.ts +13 -19
- package/didWebLambdaApp.ts +23 -0
- package/dist/config/applyDockerStageConfig.d.ts +2 -0
- package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
- package/dist/config/stageConfig.d.ts +10 -0
- package/dist/config/stageConfig.d.ts.map +1 -0
- package/dist/config/stageConfig.test.d.ts +2 -0
- package/dist/config/stageConfig.test.d.ts.map +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/docker-entry.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +22 -153
- package/lambdaApp.ts +163 -0
- package/oidcLambda.test.ts +41 -0
- package/oidcLambda.ts +13 -19
- package/oidcLambdaApp.ts +23 -0
- package/package.json +18 -16
- package/serverless-local.yml +3 -1
- package/serverless.function-env.cjs +31 -0
- package/serverless.yml +13 -29
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/serverlessFunctionEnv.test.ts +81 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +2 -0
- package/vitest.config.ts +3 -0
- package/xApiLambda.test.ts +41 -0
- package/xApiLambda.ts +13 -19
- package/xApiLambdaApp.ts +23 -0
package/lambdaApp.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import serverlessHttp from 'serverless-http';
|
|
2
|
+
import type { Context, APIGatewayProxyResultV2, APIGatewayProxyEventV2 } from 'aws-lambda';
|
|
3
|
+
import { awsLambdaRequestHandler } from '@trpc/server/adapters/aws-lambda';
|
|
4
|
+
import * as Sentry from '@sentry/serverless';
|
|
5
|
+
import Fastify, { type FastifyInstance } from 'fastify';
|
|
6
|
+
|
|
7
|
+
import app from './src/openapi';
|
|
8
|
+
import { appRouter, createContext } from './src/app';
|
|
9
|
+
import { createOpenApiAwsLambdaHandler } from './src/helpers/shim';
|
|
10
|
+
import {
|
|
11
|
+
handleTrpcError,
|
|
12
|
+
sentryBeforeSend,
|
|
13
|
+
getTracesSampleRate,
|
|
14
|
+
} from './src/helpers/sentry.helpers';
|
|
15
|
+
import { environment } from './src/config/environment';
|
|
16
|
+
import { toServerlessApplication } from './src/helpers/serverlessApplication';
|
|
17
|
+
import {
|
|
18
|
+
assertShareContentEnvironmentConfiguration,
|
|
19
|
+
createShareContentRuntimeFromEnvironment,
|
|
20
|
+
registerShareContentRoutes,
|
|
21
|
+
} from './src/share-content';
|
|
22
|
+
|
|
23
|
+
Sentry.AWSLambda.init({
|
|
24
|
+
dsn: environment.SENTRY_DSN,
|
|
25
|
+
environment: environment.SENTRY_ENV,
|
|
26
|
+
enabled: Boolean(environment.SENTRY_DSN),
|
|
27
|
+
tracesSampleRate: getTracesSampleRate(),
|
|
28
|
+
beforeSend: sentryBeforeSend,
|
|
29
|
+
integrations: [
|
|
30
|
+
new Sentry.Integrations.Console(),
|
|
31
|
+
new Sentry.Integrations.Http({ tracing: true }),
|
|
32
|
+
new Sentry.Integrations.ContextLines(),
|
|
33
|
+
new Sentry.Integrations.Mongo(),
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Fail the cold start on an enabled-but-invalid LC-2187 share-content
|
|
38
|
+
// configuration; a disabled configuration is a no-op.
|
|
39
|
+
assertShareContentEnvironmentConfiguration();
|
|
40
|
+
|
|
41
|
+
let shareContentProxyPromise: Promise<ReturnType<typeof serverlessHttp> | null> | null = null;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Lazy per-container boot for the LC-2187 service routes. Resolving the runtime
|
|
45
|
+
* awaits repository index initialization and opens Redis only when enabled, so a
|
|
46
|
+
* disabled deployment installs nothing and returns 404 for every internal path.
|
|
47
|
+
*/
|
|
48
|
+
const getShareContentProxy = async (): Promise<ReturnType<typeof serverlessHttp> | null> => {
|
|
49
|
+
if (!shareContentProxyPromise) {
|
|
50
|
+
shareContentProxyPromise = (async () => {
|
|
51
|
+
const runtime = await createShareContentRuntimeFromEnvironment();
|
|
52
|
+
|
|
53
|
+
if (!runtime.enabled) return null;
|
|
54
|
+
|
|
55
|
+
const shareContentApp: FastifyInstance = Fastify({ maxParamLength: 5000 });
|
|
56
|
+
await registerShareContentRoutes(shareContentApp, runtime);
|
|
57
|
+
await shareContentApp.ready();
|
|
58
|
+
|
|
59
|
+
return serverlessHttp(toServerlessApplication(shareContentApp.server));
|
|
60
|
+
})();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return shareContentProxyPromise;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const shareContentHandler = async (
|
|
67
|
+
event: APIGatewayProxyEventV2,
|
|
68
|
+
context: Context
|
|
69
|
+
): Promise<APIGatewayProxyResultV2> => {
|
|
70
|
+
let proxy: ReturnType<typeof serverlessHttp> | null;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
proxy = await getShareContentProxy();
|
|
74
|
+
} catch {
|
|
75
|
+
// Never return a raw boot error. Allow a later invocation to retry.
|
|
76
|
+
shareContentProxyPromise = null;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
statusCode: 503,
|
|
80
|
+
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
|
|
81
|
+
body: JSON.stringify({ ok: false, error: 'UNAVAILABLE' }),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!proxy) {
|
|
86
|
+
return { statusCode: 404, headers: { 'Cache-Control': 'no-store' }, body: '' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return proxy(event, context);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const swaggerUiHandler = serverlessHttp(toServerlessApplication(app), {
|
|
93
|
+
basePath: '/docs',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const _openApiHandler = createOpenApiAwsLambdaHandler({
|
|
97
|
+
router: appRouter,
|
|
98
|
+
responseMeta: () => {
|
|
99
|
+
return {
|
|
100
|
+
headers: {
|
|
101
|
+
'Access-Control-Allow-Origin': '*',
|
|
102
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
103
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
createContext,
|
|
108
|
+
onError: handleTrpcError,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export const _trpcHandler = awsLambdaRequestHandler({
|
|
112
|
+
allowMethodOverride: true,
|
|
113
|
+
router: appRouter,
|
|
114
|
+
createContext,
|
|
115
|
+
onError: handleTrpcError,
|
|
116
|
+
responseMeta: () => {
|
|
117
|
+
return {
|
|
118
|
+
headers: {
|
|
119
|
+
'Access-Control-Allow-Origin': '*',
|
|
120
|
+
'Access-Control-Allow-Methods': '*',
|
|
121
|
+
'Access-Control-Allow-Headers': 'authorization',
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
export const openApiHandler = Sentry.AWSLambda.wrapHandler(
|
|
128
|
+
async (event: APIGatewayProxyEventV2, context: Context): Promise<APIGatewayProxyResultV2> => {
|
|
129
|
+
if (event.requestContext.http.method === 'OPTIONS') {
|
|
130
|
+
return {
|
|
131
|
+
statusCode: 200,
|
|
132
|
+
headers: {
|
|
133
|
+
'Access-Control-Allow-Origin': '*',
|
|
134
|
+
'Access-Control-Allow-Headers': '*',
|
|
135
|
+
'Access-Control-Allow-Methods': '*',
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
context.callbackWaitsForEmptyEventLoop = false;
|
|
141
|
+
|
|
142
|
+
return _openApiHandler(event, context);
|
|
143
|
+
}
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
export const trpcHandler = Sentry.AWSLambda.wrapHandler(
|
|
147
|
+
async (event: APIGatewayProxyEventV2, context: Context): Promise<APIGatewayProxyResultV2> => {
|
|
148
|
+
if (event.requestContext.http.method === 'OPTIONS') {
|
|
149
|
+
return {
|
|
150
|
+
statusCode: 200,
|
|
151
|
+
headers: {
|
|
152
|
+
'Access-Control-Allow-Origin': '*',
|
|
153
|
+
'Access-Control-Allow-Headers': '*',
|
|
154
|
+
'Access-Control-Allow-Methods': '*',
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
context.callbackWaitsForEmptyEventLoop = false;
|
|
160
|
+
|
|
161
|
+
return _trpcHandler(event, context);
|
|
162
|
+
}
|
|
163
|
+
);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { APIGatewayProxyEventV2, Context } from 'aws-lambda';
|
|
2
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({ bootstrap: vi.fn(), getApp: vi.fn(), handler: vi.fn() }));
|
|
5
|
+
|
|
6
|
+
vi.mock('@learncard/service-config', () => ({ bootstrapLambda: mocks.bootstrap }));
|
|
7
|
+
vi.mock('./src/config/stageConfig', () => ({ base: {}, stages: { dev: {}, production: {} } }));
|
|
8
|
+
vi.mock('./oidcLambdaApp', () => ({ oidcHandler: mocks.handler }));
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vi.resetModules();
|
|
12
|
+
vi.clearAllMocks();
|
|
13
|
+
mocks.bootstrap.mockReturnValue(mocks.getApp);
|
|
14
|
+
mocks.getApp.mockResolvedValue({ oidcHandler: mocks.handler });
|
|
15
|
+
mocks.handler.mockResolvedValue({ statusCode: 200 });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('bootstraps the oidc app with checked-in stage config and the deploy stage', async () => {
|
|
19
|
+
vi.stubEnv('AWS_LAMBDA_FUNCTION_NAME', 'learn-cloud-service-dev-oidc');
|
|
20
|
+
vi.stubEnv('LAMBDA_STAGE', 'dev');
|
|
21
|
+
await import('./oidcLambda');
|
|
22
|
+
expect(mocks.bootstrap).toHaveBeenCalledTimes(1);
|
|
23
|
+
expect(mocks.bootstrap).toHaveBeenCalledWith({
|
|
24
|
+
base: {},
|
|
25
|
+
stages: { dev: {}, production: {} },
|
|
26
|
+
stage: 'dev',
|
|
27
|
+
importApp: expect.any(Function),
|
|
28
|
+
});
|
|
29
|
+
vi.unstubAllEnvs();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('resolves the app once per cold start and forwards every argument', async () => {
|
|
33
|
+
const { oidcHandler } = await import('./oidcLambda');
|
|
34
|
+
const event = {} as APIGatewayProxyEventV2;
|
|
35
|
+
const context = {} as Context;
|
|
36
|
+
const callback = vi.fn();
|
|
37
|
+
await oidcHandler(event, context, callback);
|
|
38
|
+
await oidcHandler(event, context, callback);
|
|
39
|
+
expect(mocks.getApp).toHaveBeenCalledTimes(2);
|
|
40
|
+
expect(mocks.handler).toHaveBeenLastCalledWith(event, context, callback);
|
|
41
|
+
});
|
package/oidcLambda.ts
CHANGED
|
@@ -1,23 +1,17 @@
|
|
|
1
|
-
import
|
|
2
|
-
import * as Sentry from '@sentry/serverless';
|
|
1
|
+
import { bootstrapLambda } from '@learncard/service-config';
|
|
3
2
|
|
|
4
|
-
import
|
|
5
|
-
import { environment } from './src/config/environment';
|
|
6
|
-
import { toServerlessApplication } from './src/helpers/serverlessApplication';
|
|
3
|
+
import { base, stages } from './src/config/stageConfig';
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
new Sentry.Integrations.Mongo(),
|
|
18
|
-
],
|
|
5
|
+
type OidcApp = typeof import('./oidcLambdaApp');
|
|
6
|
+
|
|
7
|
+
const getApplication = bootstrapLambda<OidcApp>({
|
|
8
|
+
base,
|
|
9
|
+
stages,
|
|
10
|
+
stage: process.env.AWS_LAMBDA_FUNCTION_NAME
|
|
11
|
+
? process.env.LAMBDA_STAGE
|
|
12
|
+
: process.env.CONFIG_STAGE,
|
|
13
|
+
importApp: () => import('./oidcLambdaApp'),
|
|
19
14
|
});
|
|
20
15
|
|
|
21
|
-
export const oidcHandler =
|
|
22
|
-
|
|
23
|
-
);
|
|
16
|
+
export const oidcHandler = async (...args: Parameters<OidcApp['oidcHandler']>) =>
|
|
17
|
+
(await getApplication()).oidcHandler(...args);
|
package/oidcLambdaApp.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import serverlessHttp from 'serverless-http';
|
|
2
|
+
import * as Sentry from '@sentry/serverless';
|
|
3
|
+
|
|
4
|
+
import oidcApp from './src/oidc';
|
|
5
|
+
import { environment } from './src/config/environment';
|
|
6
|
+
import { toServerlessApplication } from './src/helpers/serverlessApplication';
|
|
7
|
+
|
|
8
|
+
Sentry.AWSLambda.init({
|
|
9
|
+
dsn: environment.SENTRY_DSN,
|
|
10
|
+
environment: environment.SENTRY_ENV,
|
|
11
|
+
enabled: Boolean(environment.SENTRY_DSN),
|
|
12
|
+
tracesSampleRate: 1.0,
|
|
13
|
+
integrations: [
|
|
14
|
+
new Sentry.Integrations.Console(),
|
|
15
|
+
new Sentry.Integrations.Http(),
|
|
16
|
+
new Sentry.Integrations.ContextLines(),
|
|
17
|
+
new Sentry.Integrations.Mongo(),
|
|
18
|
+
],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const oidcHandler = Sentry.AWSLambda.wrapHandler(
|
|
22
|
+
serverlessHttp(toServerlessApplication(oidcApp))
|
|
23
|
+
);
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/learn-cloud-service",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.36",
|
|
4
4
|
"description": "",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24.12 <25"
|
|
7
7
|
},
|
|
8
|
-
"packageManager": "bun@1.
|
|
8
|
+
"packageManager": "bun@1.4.2",
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"types": "dist/index.d.ts",
|
|
11
11
|
"scripts": {
|
|
@@ -61,27 +61,28 @@
|
|
|
61
61
|
"serverless-offline": "^12.0.4",
|
|
62
62
|
"serverless-prune-plugin": "^2.1.0",
|
|
63
63
|
"shx": "^0.3.4",
|
|
64
|
-
"swagger-ui-dist": "^5.18.2",
|
|
65
64
|
"testcontainers": "^9.1.1"
|
|
66
65
|
},
|
|
67
66
|
"dependencies": {
|
|
68
67
|
"@fastify/cors": "^11.2.0",
|
|
69
68
|
"@fastify/rate-limit": "^10.2.0",
|
|
70
69
|
"@fastify/static": "^10.1.1",
|
|
71
|
-
"@learncard/core": "9.4.
|
|
72
|
-
"@learncard/crypto-plugin": "1.1.
|
|
73
|
-
"@learncard/did-web-plugin": "1.1.
|
|
74
|
-
"@learncard/didkey-plugin": "1.1.
|
|
75
|
-
"@learncard/didkit-plugin": "^1.10.
|
|
76
|
-
"@learncard/didkit-plugin-node": "0.3.
|
|
77
|
-
"@learncard/encryption-plugin": "1.1.
|
|
78
|
-
"@learncard/expiration-plugin": "1.2.
|
|
79
|
-
"@learncard/helpers": "1.6.
|
|
80
|
-
"@learncard/learn-card-plugin": "1.2.
|
|
81
|
-
"@learncard/
|
|
82
|
-
"@learncard/
|
|
83
|
-
"@learncard/vc-
|
|
70
|
+
"@learncard/core": "9.4.38",
|
|
71
|
+
"@learncard/crypto-plugin": "1.1.38",
|
|
72
|
+
"@learncard/did-web-plugin": "1.1.38",
|
|
73
|
+
"@learncard/didkey-plugin": "1.1.38",
|
|
74
|
+
"@learncard/didkit-plugin": "^1.10.3",
|
|
75
|
+
"@learncard/didkit-plugin-node": "0.3.3",
|
|
76
|
+
"@learncard/encryption-plugin": "1.1.38",
|
|
77
|
+
"@learncard/expiration-plugin": "1.2.38",
|
|
78
|
+
"@learncard/helpers": "1.6.2",
|
|
79
|
+
"@learncard/learn-card-plugin": "1.2.38",
|
|
80
|
+
"@learncard/service-config": "0.1.1",
|
|
81
|
+
"@learncard/types": "5.23.0",
|
|
82
|
+
"@learncard/vc-plugin": "1.6.4",
|
|
83
|
+
"@learncard/vc-templates-plugin": "1.1.38",
|
|
84
84
|
"@sentry/serverless": "7.61.0",
|
|
85
|
+
"@traversable/zod": "^0.0.57",
|
|
85
86
|
"@trpc/server": "11.8.0",
|
|
86
87
|
"@types/lodash": "^4.14.191",
|
|
87
88
|
"@xapi/xapi": "^3.0.0",
|
|
@@ -103,6 +104,7 @@
|
|
|
103
104
|
"neo4j-driver": "^4.3.0",
|
|
104
105
|
"neogma": "^1.11.1",
|
|
105
106
|
"simple-redis-mutex": "^1.3.1",
|
|
107
|
+
"swagger-ui-dist": "^5.18.2",
|
|
106
108
|
"trpc-to-openapi": "^3.1.0",
|
|
107
109
|
"tsc-alias": "^1.8.10",
|
|
108
110
|
"zod-openapi": "^5.0.1",
|
package/serverless-local.yml
CHANGED
|
@@ -40,9 +40,11 @@ provider:
|
|
|
40
40
|
timeout: 20
|
|
41
41
|
stage: ${opt:stage, "dev"}
|
|
42
42
|
region: ${opt:region, "us-east-1"}
|
|
43
|
+
# serverless-offline sets AWS_LAMBDA_FUNCTION_NAME; stage config stays opt-in locally.
|
|
43
44
|
environment:
|
|
44
45
|
PORT: ${env:PORT, 3000}
|
|
45
|
-
LAMBDA_STAGE: ${
|
|
46
|
+
LAMBDA_STAGE: ${env:CONFIG_STAGE, ''}
|
|
47
|
+
CONFIG_STAGE: ${env:CONFIG_STAGE, ''}
|
|
46
48
|
LEARN_CLOUD_SEED: ${env:LEARN_CLOUD_SEED}
|
|
47
49
|
LEARN_CLOUD_MONGO_URI: ${env:LEARN_CLOUD_MONGO_URI}
|
|
48
50
|
LEARN_CLOUD_MONGO_DB_NAME: ${env:LEARN_CLOUD_MONGO_DB_NAME}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const { functionEnvironment } = require('@learncard/service-config/function-env.cjs');
|
|
2
|
+
|
|
3
|
+
const PROVIDER_SECRETS = [
|
|
4
|
+
'LEARN_CLOUD_SEED',
|
|
5
|
+
'LEARN_CLOUD_MONGO_URI',
|
|
6
|
+
'LEARN_CLOUD_MONGO_DB_NAME',
|
|
7
|
+
'RSA_PRIVATE_KEY',
|
|
8
|
+
'RSA_PUBLIC_KEY',
|
|
9
|
+
'JWT_SIGNING_KEY',
|
|
10
|
+
'XAPI_ENDPOINT',
|
|
11
|
+
'XAPI_USERNAME',
|
|
12
|
+
'XAPI_PASSWORD',
|
|
13
|
+
'SENTRY_DSN',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
// Serverless v3 supplies CLI options to file functions and preserves CF intrinsics.
|
|
17
|
+
// Every function shares the one generated execution role, so the bundle pointer and the
|
|
18
|
+
// credential fallbacks sit together on the provider. Bundle mode emits only infra plus the
|
|
19
|
+
// pointer; fallback mode retains the per-value GitHub secrets. XAPI_ENDPOINT is a
|
|
20
|
+
// user-classified bundle secret (fallback env only), not infra, so it rides the fallback
|
|
21
|
+
// list and is dropped once a bundle is configured. Non-secret per-stage values are applied
|
|
22
|
+
// from checked-in stage config at cold start, never forwarded here.
|
|
23
|
+
exports.provider = ({ options = {}, env = process.env } = {}) => ({
|
|
24
|
+
LAMBDA_STAGE: options.stage || 'dev',
|
|
25
|
+
// Non-secret product selector for the shared stage files (config.<tenant>.<stage>.json).
|
|
26
|
+
CONFIG_TENANT: env.CONFIG_TENANT || 'learncard',
|
|
27
|
+
PORT: String(options.httpPort || '3000'),
|
|
28
|
+
REDIS_HOST: { 'Fn::GetAtt': ['ElasticCacheCluster', 'RedisEndpoint.Address'] },
|
|
29
|
+
REDIS_PORT: { 'Fn::GetAtt': ['ElasticCacheCluster', 'RedisEndpoint.Port'] },
|
|
30
|
+
...functionEnvironment({ always: ['RUNTIME_SECRETS_ID'], fallback: PROVIDER_SECRETS, env }),
|
|
31
|
+
});
|
package/serverless.yml
CHANGED
|
@@ -118,35 +118,19 @@ provider:
|
|
|
118
118
|
timeout: 29
|
|
119
119
|
stage: ${opt:stage, "dev"}
|
|
120
120
|
region: ${opt:region, "us-east-1"}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
SENTRY_DSN: ${env:SENTRY_DSN}
|
|
135
|
-
SENTRY_ENV: ${env:SENTRY_ENV}
|
|
136
|
-
SENTRY_AUTH_TOKEN: ${env:SENTRY_AUTH_TOKEN}
|
|
137
|
-
SENTRY_ORG: ${env:SENTRY_ORG}
|
|
138
|
-
SENTRY_PROJECT: ${env:SENTRY_PROJECT}
|
|
139
|
-
REDIS_HOST:
|
|
140
|
-
'Fn::GetAtt': [ElasticCacheCluster, RedisEndpoint.Address]
|
|
141
|
-
REDIS_PORT:
|
|
142
|
-
'Fn::GetAtt': [ElasticCacheCluster, RedisEndpoint.Port]
|
|
143
|
-
# LC-2187 service-only share-content routes. Require an explicit
|
|
144
|
-
# audience, service DID/key-method allowlists and
|
|
145
|
-
# per-service namespace bindings. No secret material is passed here.
|
|
146
|
-
SHARE_CONTENT_AUDIENCE: ${env:SHARE_CONTENT_AUDIENCE, ''}
|
|
147
|
-
SHARE_CONTENT_SERVICE_DIDS: ${env:SHARE_CONTENT_SERVICE_DIDS, ''}
|
|
148
|
-
SHARE_CONTENT_VERIFICATION_METHODS: ${env:SHARE_CONTENT_VERIFICATION_METHODS, ''}
|
|
149
|
-
SHARE_CONTENT_NAMESPACE_BINDINGS: ${env:SHARE_CONTENT_NAMESPACE_BINDINGS, ''}
|
|
121
|
+
# Infra values and the CF intrinsics live in the helper alongside the bundle pointer.
|
|
122
|
+
# Bundle mode emits only infra + RUNTIME_SECRETS_ID; fallback mode retains the GitHub
|
|
123
|
+
# secrets. Non-secret per-stage values (SENTRY_ENV, SERVER_URL, SHARE_CONTENT_*) are
|
|
124
|
+
# applied from checked-in stage config at cold start, not passed here.
|
|
125
|
+
environment: ${file(./serverless.function-env.cjs):provider}
|
|
126
|
+
# Only this generated role runs every function; grant it read of the runtime bundle.
|
|
127
|
+
iam:
|
|
128
|
+
role:
|
|
129
|
+
statements:
|
|
130
|
+
- Effect: Allow
|
|
131
|
+
Action: secretsmanager:GetSecretValue
|
|
132
|
+
Resource:
|
|
133
|
+
Fn::Sub: arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:learn-cloud-service/${sls:stage}/runtime-secrets-*
|
|
150
134
|
tracing:
|
|
151
135
|
apiGateway: true
|
|
152
136
|
lambda: true
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { applyLearnCloudStageConfig } from './stageConfig';
|
|
2
|
+
|
|
3
|
+
// Side-effect import: runs before @environment is evaluated so Docker/local processes
|
|
4
|
+
// pick up checked-in per-stage config. Keyed on CONFIG_STAGE (Lambda uses LAMBDA_STAGE);
|
|
5
|
+
// explicit non-empty env values from the developer's .env still win.
|
|
6
|
+
applyLearnCloudStageConfig(process.env.CONFIG_STAGE);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const functions: {
|
|
6
|
+
provider: (context?: {
|
|
7
|
+
options: { stage: string; httpPort?: string };
|
|
8
|
+
}) => Record<string, unknown>;
|
|
9
|
+
} = require('../../serverless.function-env.cjs');
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.stubEnv('RUNTIME_SECRETS_ID', '');
|
|
13
|
+
vi.stubEnv('LEARN_CLOUD_SEED', 'seed');
|
|
14
|
+
vi.stubEnv('LEARN_CLOUD_MONGO_URI', 'mongodb://localhost:27017');
|
|
15
|
+
// XAPI_ENDPOINT is a user-classified bundle secret, so populate a stale fallback value
|
|
16
|
+
// to prove it never leaks onto the provider once a runtime bundle is configured.
|
|
17
|
+
vi.stubEnv('XAPI_ENDPOINT', 'https://stale.xapi.example.com');
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
20
|
+
|
|
21
|
+
describe('learn-cloud-service serverless function environments', () => {
|
|
22
|
+
it('omits all credential fallbacks in bundle mode', () => {
|
|
23
|
+
vi.stubEnv('RUNTIME_SECRETS_ID', 'learn-cloud-service/dev/runtime-secrets');
|
|
24
|
+
expect(Object.keys(functions.provider()).sort()).toEqual([
|
|
25
|
+
'CONFIG_TENANT',
|
|
26
|
+
'LAMBDA_STAGE',
|
|
27
|
+
'PORT',
|
|
28
|
+
'REDIS_HOST',
|
|
29
|
+
'REDIS_PORT',
|
|
30
|
+
'RUNTIME_SECRETS_ID',
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('drops the XAPI_ENDPOINT bundle secret in bundle mode even when a stale fallback is set', () => {
|
|
35
|
+
vi.stubEnv('RUNTIME_SECRETS_ID', 'learn-cloud-service/dev/runtime-secrets');
|
|
36
|
+
expect(functions.provider()).not.toHaveProperty('XAPI_ENDPOINT');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('retains the existing provider credentials only in fallback mode', () => {
|
|
40
|
+
const provider = functions.provider();
|
|
41
|
+
expect(provider.LEARN_CLOUD_SEED).toBe('seed');
|
|
42
|
+
expect(provider.LEARN_CLOUD_MONGO_URI).toBe('mongodb://localhost:27017');
|
|
43
|
+
expect(provider.XAPI_ENDPOINT).toBe('https://stale.xapi.example.com');
|
|
44
|
+
expect(provider.RUNTIME_SECRETS_ID).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('preserves the CloudFormation Redis intrinsics and CLI options', () => {
|
|
48
|
+
const provider = functions.provider({ options: { stage: 'production', httpPort: '5100' } });
|
|
49
|
+
expect(provider.LAMBDA_STAGE).toBe('production');
|
|
50
|
+
expect(provider.PORT).toBe('5100');
|
|
51
|
+
expect(provider.REDIS_HOST).toEqual({
|
|
52
|
+
'Fn::GetAtt': ['ElasticCacheCluster', 'RedisEndpoint.Address'],
|
|
53
|
+
});
|
|
54
|
+
expect(provider.REDIS_PORT).toEqual({
|
|
55
|
+
'Fn::GetAtt': ['ElasticCacheCluster', 'RedisEndpoint.Port'],
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it.each(['', 'bundle'])(
|
|
60
|
+
'resolves the full provider environment through Serverless v3 (%s)',
|
|
61
|
+
async bundle => {
|
|
62
|
+
const source = require('serverless/lib/configuration/variables/sources/file');
|
|
63
|
+
vi.stubEnv('RUNTIME_SECRETS_ID', bundle);
|
|
64
|
+
const options = { stage: 'production', httpPort: '5100' };
|
|
65
|
+
const result = await source.resolve({
|
|
66
|
+
serviceDir: new URL('../../', import.meta.url).pathname,
|
|
67
|
+
params: [new URL('../../serverless.function-env.cjs', import.meta.url).pathname],
|
|
68
|
+
address: 'provider',
|
|
69
|
+
options,
|
|
70
|
+
resolveConfigurationProperty: vi.fn(),
|
|
71
|
+
resolveVariable: vi.fn(),
|
|
72
|
+
});
|
|
73
|
+
expect(result.value).toEqual(functions.provider({ options }));
|
|
74
|
+
expect(result.value.LAMBDA_STAGE).toBe('production');
|
|
75
|
+
expect(result.value.PORT).toBe('5100');
|
|
76
|
+
expect(result.value.REDIS_HOST).toEqual({
|
|
77
|
+
'Fn::GetAtt': ['ElasticCacheCluster', 'RedisEndpoint.Address'],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
);
|
|
81
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Model a non-empty .env value arriving before the stage defaults are applied.
|
|
4
|
+
vi.mock('dotenv/config', () => {
|
|
5
|
+
vi.stubEnv('SENTRY_ENV', 'self-hosted');
|
|
6
|
+
return {};
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
10
|
+
|
|
11
|
+
it('loads local env before applying statically imported stage defaults', async () => {
|
|
12
|
+
const { applyLearnCloudStageConfig, stages } = await import('./stageConfig');
|
|
13
|
+
for (const key of Object.keys(stages.dev)) {
|
|
14
|
+
if (key !== 'SENTRY_ENV') vi.stubEnv(key, '');
|
|
15
|
+
}
|
|
16
|
+
applyLearnCloudStageConfig('dev');
|
|
17
|
+
expect(process.env.SENTRY_ENV).toBe('self-hosted');
|
|
18
|
+
expect(process.env.SERVER_URL).toBe(stages.dev.SERVER_URL);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('does not select a deployed stage for an unknown local stage', async () => {
|
|
22
|
+
const { applyLearnCloudStageConfig } = await import('./stageConfig');
|
|
23
|
+
vi.stubEnv('SERVER_URL', '');
|
|
24
|
+
applyLearnCloudStageConfig('unregistered-self-hosted-stage');
|
|
25
|
+
expect(process.env.SERVER_URL).toBe('');
|
|
26
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { applyStageConfig } from '@learncard/service-config';
|
|
3
|
+
|
|
4
|
+
import baseConfig from '../../config/config.json';
|
|
5
|
+
import devConfig from '../../config/config.dev.json';
|
|
6
|
+
import productionConfig from '../../config/config.production.json';
|
|
7
|
+
import scoutsDevConfig from '../../config/config.scouts.dev.json';
|
|
8
|
+
import scoutsProductionConfig from '../../config/config.scouts.production.json';
|
|
9
|
+
|
|
10
|
+
export const base: Record<string, string> = baseConfig;
|
|
11
|
+
|
|
12
|
+
export const stages = {
|
|
13
|
+
dev: devConfig,
|
|
14
|
+
production: productionConfig,
|
|
15
|
+
// ScoutPass deploys the same services and stage names; CONFIG_TENANT=scouts selects these.
|
|
16
|
+
'scouts.dev': scoutsDevConfig,
|
|
17
|
+
'scouts.production': scoutsProductionConfig,
|
|
18
|
+
} satisfies Record<string, Record<string, string>>;
|
|
19
|
+
|
|
20
|
+
// Must run before any module that reads configuration on import (e.g. @environment):
|
|
21
|
+
// merges base + the selected stage into process.env; explicit non-empty env still wins.
|
|
22
|
+
export const applyLearnCloudStageConfig = (stage: string | undefined): void => {
|
|
23
|
+
applyStageConfig({ base, stages, stage });
|
|
24
|
+
};
|
package/src/docker-entry.ts
CHANGED
package/vitest.config.ts
CHANGED
|
@@ -7,6 +7,9 @@ export default createVitestConfig(nodePreset, {
|
|
|
7
7
|
'test/query.helpers.spec.ts',
|
|
8
8
|
'test/xapi.helpers.spec.ts',
|
|
9
9
|
'test/share-content-auth.spec.ts',
|
|
10
|
+
'src/config/stageConfig.test.ts',
|
|
11
|
+
'src/config/serverlessFunctionEnv.test.ts',
|
|
12
|
+
'*Lambda.test.ts',
|
|
10
13
|
],
|
|
11
14
|
},
|
|
12
15
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { APIGatewayProxyEventV2, Context } from 'aws-lambda';
|
|
2
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({ bootstrap: vi.fn(), getApp: vi.fn(), handler: vi.fn() }));
|
|
5
|
+
|
|
6
|
+
vi.mock('@learncard/service-config', () => ({ bootstrapLambda: mocks.bootstrap }));
|
|
7
|
+
vi.mock('./src/config/stageConfig', () => ({ base: {}, stages: { dev: {}, production: {} } }));
|
|
8
|
+
vi.mock('./xApiLambdaApp', () => ({ xApiHandler: mocks.handler }));
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vi.resetModules();
|
|
12
|
+
vi.clearAllMocks();
|
|
13
|
+
mocks.bootstrap.mockReturnValue(mocks.getApp);
|
|
14
|
+
mocks.getApp.mockResolvedValue({ xApiHandler: mocks.handler });
|
|
15
|
+
mocks.handler.mockResolvedValue({ statusCode: 200 });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('bootstraps the xApi app with checked-in stage config and the deploy stage', async () => {
|
|
19
|
+
vi.stubEnv('AWS_LAMBDA_FUNCTION_NAME', 'learn-cloud-service-dev-xapi');
|
|
20
|
+
vi.stubEnv('LAMBDA_STAGE', 'dev');
|
|
21
|
+
await import('./xApiLambda');
|
|
22
|
+
expect(mocks.bootstrap).toHaveBeenCalledTimes(1);
|
|
23
|
+
expect(mocks.bootstrap).toHaveBeenCalledWith({
|
|
24
|
+
base: {},
|
|
25
|
+
stages: { dev: {}, production: {} },
|
|
26
|
+
stage: 'dev',
|
|
27
|
+
importApp: expect.any(Function),
|
|
28
|
+
});
|
|
29
|
+
vi.unstubAllEnvs();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('resolves the app once per cold start and forwards every argument', async () => {
|
|
33
|
+
const { xApiHandler } = await import('./xApiLambda');
|
|
34
|
+
const event = {} as APIGatewayProxyEventV2;
|
|
35
|
+
const context = {} as Context;
|
|
36
|
+
const callback = vi.fn();
|
|
37
|
+
await xApiHandler(event, context, callback);
|
|
38
|
+
await xApiHandler(event, context, callback);
|
|
39
|
+
expect(mocks.getApp).toHaveBeenCalledTimes(2);
|
|
40
|
+
expect(mocks.handler).toHaveBeenLastCalledWith(event, context, callback);
|
|
41
|
+
});
|