@genoacms/adapter-gcp 0.8.2 → 0.10.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/dist/authentication/identity-platform/descriptor.d.ts +18 -0
  3. package/dist/authentication/identity-platform/descriptor.js +14 -0
  4. package/dist/authentication/identity-platform/runtime.d.ts +4 -0
  5. package/dist/authentication/identity-platform/runtime.js +140 -0
  6. package/dist/database/descriptor.d.ts +16 -0
  7. package/dist/database/descriptor.js +7 -0
  8. package/dist/database/runtime.d.ts +5 -0
  9. package/dist/database/runtime.js +68 -0
  10. package/dist/deployment/archive.d.ts +4 -0
  11. package/dist/deployment/archive.js +46 -0
  12. package/dist/deployment/descriptor.d.ts +24 -0
  13. package/dist/deployment/descriptor.js +20 -0
  14. package/dist/deployment/functions.d.ts +16 -0
  15. package/dist/deployment/functions.js +66 -0
  16. package/dist/deployment/procedure.d.ts +4 -0
  17. package/dist/deployment/procedure.js +16 -0
  18. package/dist/deployment/settings.d.ts +22 -0
  19. package/dist/deployment/settings.js +58 -0
  20. package/dist/secrets/descriptor.d.ts +14 -0
  21. package/dist/secrets/descriptor.js +7 -0
  22. package/dist/secrets/runtime.d.ts +14 -0
  23. package/dist/secrets/runtime.js +147 -0
  24. package/dist/shared/serviceAccount.d.ts +16 -0
  25. package/dist/shared/serviceAccount.js +12 -0
  26. package/dist/storage/descriptor.d.ts +14 -0
  27. package/dist/storage/descriptor.js +7 -0
  28. package/dist/storage/runtime.d.ts +8 -0
  29. package/dist/storage/runtime.js +207 -0
  30. package/package.json +47 -28
  31. package/deployment/snippets/index.js +0 -8
  32. package/dist/config.d.ts +0 -2
  33. package/dist/config.js +0 -2
  34. package/dist/services/authorization/index.d.ts +0 -3
  35. package/dist/services/authorization/index.js +0 -24
  36. package/dist/services/database/index.d.ts +0 -7
  37. package/dist/services/database/index.js +0 -63
  38. package/dist/services/deployment/deploy.d.ts +0 -2
  39. package/dist/services/deployment/deploy.js +0 -116
  40. package/dist/services/deployment/index.d.ts +0 -4
  41. package/dist/services/deployment/index.js +0 -6
  42. package/dist/services/storage/index.d.ts +0 -12
  43. package/dist/services/storage/index.js +0 -87
  44. package/dist/services/storage/storage.d.ts +0 -3
  45. package/dist/services/storage/storage.js +0 -20
  46. package/src/config.ts +0 -4
  47. package/src/genoa.config.d.ts +0 -50
  48. package/src/services/authorization/index.ts +0 -28
  49. package/src/services/database/index.ts +0 -88
  50. package/src/services/deployment/deploy.ts +0 -126
  51. package/src/services/deployment/index.ts +0 -13
  52. package/src/services/storage/index.ts +0 -119
  53. package/src/services/storage/storage.ts +0 -27
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023-2026 Filip Holčík
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,18 @@
1
+ import { type Secret } from '@genoacms/contracts';
2
+ import { type ServiceAccount } from '../../shared/serviceAccount.js';
3
+ export interface GcpIdentityPlatformOptions {
4
+ projectId: string;
5
+ /** An Identity Platform tenant. Omit for the project's own user pool. */
6
+ tenantId?: string;
7
+ /** An API key restricted to the Identity Toolkit API. Omit to sign in with the runtime identity. */
8
+ apiKey?: Secret<string>;
9
+ /** Omit for Application Default Credentials; only for running outside GCP. */
10
+ credentials?: Secret<ServiceAccount>;
11
+ }
12
+ declare module '@genoacms/contracts' {
13
+ interface AuthenticationAdapters {
14
+ '@genoacms/adapter-gcp/authentication/identity-platform': GcpIdentityPlatformOptions;
15
+ }
16
+ }
17
+ declare const _default: import("@genoacms/contracts").AdapterDescriptor<"authentication", GcpIdentityPlatformOptions>;
18
+ export default _default;
@@ -0,0 +1,14 @@
1
+ import { defineAuthenticationAdapter } from '@genoacms/contracts';
2
+ import { unknownOptions, requireString } from '../../shared/serviceAccount.js';
3
+ const OPTIONS = ['projectId', 'tenantId', 'apiKey', 'credentials'];
4
+ // AUTH-1
5
+ function optionalString(options, key) {
6
+ const value = options?.[key];
7
+ return value === undefined || (typeof value === 'string' && value !== '') ? [] : [`${key} must be a non-empty string`];
8
+ }
9
+ // AUTH-1, COM-2, COM-3
10
+ export default defineAuthenticationAdapter({
11
+ runtime: '@genoacms/adapter-gcp/authentication/identity-platform/runtime',
12
+ secretOptions: { apiKey: 'string', credentials: 'json' },
13
+ validate: options => [...unknownOptions(options, OPTIONS), ...requireString(options, 'projectId'), ...optionalString(options, 'tenantId')]
14
+ });
@@ -0,0 +1,4 @@
1
+ import type { Adapter } from '@genoacms/contracts/authentication';
2
+ import type { GcpIdentityPlatformOptions } from './descriptor.js';
3
+ declare const _default: import("@genoacms/contracts").AdapterRuntime<GcpIdentityPlatformOptions, Adapter>;
4
+ export default _default;
@@ -0,0 +1,140 @@
1
+ import { defineRuntime } from '@genoacms/contracts';
2
+ import { GoogleAuth } from 'google-auth-library';
3
+ /**
4
+ * The GenoaCMS `authentication` service backed by Identity Platform, or plain Firebase
5
+ * Authentication: one service behind one API (GU10). Users are managed there, not in the CMS.
6
+ */
7
+ const ENDPOINT = 'https://identitytoolkit.googleapis.com/v1/accounts';
8
+ const SCOPE = 'https://www.googleapis.com/auth/identitytoolkit';
9
+ // AUTH-2
10
+ const TIMEOUT_MS = 10_000;
11
+ // AUTH-5
12
+ const CREDENTIAL_CODES = new Set(['INVALID_LOGIN_CREDENTIALS', 'EMAIL_NOT_FOUND', 'INVALID_PASSWORD', 'INVALID_EMAIL', 'MISSING_PASSWORD', 'USER_DISABLED']);
13
+ // AUTH-6
14
+ const THROTTLED_CODE = 'TOO_MANY_ATTEMPTS_TRY_LATER';
15
+ const CREDENTIALS_REJECTED = Object.freeze({ rejected: 'credentials' });
16
+ const SECOND_FACTOR_REQUIRED = Object.freeze({ rejected: 'second-factor-required' });
17
+ // AUTH-3, AUTH-10, GF32
18
+ const MALFORMED = 'malformed response';
19
+ const messageOf = (error) => error instanceof Error ? error.message : String(error);
20
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
21
+ const isNonEmptyString = (value) => typeof value === 'string' && value !== '';
22
+ // AUTH-7
23
+ function providerFailed(status, message) {
24
+ return new Error(`authentication/provider-failed: ${status} ${message}`);
25
+ }
26
+ function errorMessage(body) {
27
+ return isObject(body) && isObject(body.error) && typeof body.error.message === 'string' ? body.error.message : '';
28
+ }
29
+ // AUTH-5
30
+ function errorCode(body) {
31
+ return errorMessage(body).split(' : ')[0];
32
+ }
33
+ function parsed(text) {
34
+ try {
35
+ return JSON.parse(text);
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ // AUTH-2
42
+ async function untilAborted(work, signal) {
43
+ signal.throwIfAborted();
44
+ return await new Promise((resolve, reject) => {
45
+ const abort = () => { reject(signal.reason); };
46
+ signal.addEventListener('abort', abort, { once: true });
47
+ work.then(resolve, reject).finally(() => { signal.removeEventListener('abort', abort); });
48
+ });
49
+ }
50
+ // AUTH-2, AUTH-7
51
+ async function send(url, headers, body, signal) {
52
+ try {
53
+ const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal });
54
+ return { status: response.status, body: parsed(await response.text()) };
55
+ }
56
+ catch (error) {
57
+ throw providerFailed('network', messageOf(error));
58
+ }
59
+ }
60
+ // AUTH-3, AUTH-4, AUTH-8
61
+ function signedIn(body) {
62
+ if (!isObject(body))
63
+ throw providerFailed(200, MALFORMED);
64
+ if (body.mfaPendingCredential !== undefined)
65
+ return SECOND_FACTOR_REQUIRED;
66
+ if (!isNonEmptyString(body.localId) || !isNonEmptyString(body.email))
67
+ throw providerFailed(200, MALFORMED);
68
+ return { subject: body.localId, email: body.email };
69
+ }
70
+ // AUTH-5, AUTH-6, AUTH-7
71
+ function refused({ status, body }) {
72
+ const code = errorCode(body);
73
+ if (status === 400 && CREDENTIAL_CODES.has(code))
74
+ return CREDENTIALS_REJECTED;
75
+ if (status === 400 && code === THROTTLED_CODE)
76
+ throw new Error('authentication/throttled');
77
+ throw providerFailed(status, errorMessage(body));
78
+ }
79
+ // AUTH-10
80
+ function foundIn(body) {
81
+ if (!isObject(body))
82
+ throw providerFailed(200, MALFORMED);
83
+ if (body.users === undefined)
84
+ return null;
85
+ if (!Array.isArray(body.users))
86
+ throw providerFailed(200, MALFORMED);
87
+ const user = body.users[0];
88
+ if (user === undefined)
89
+ return null;
90
+ if (!isObject(user) || !isNonEmptyString(user.localId))
91
+ throw providerFailed(200, MALFORMED);
92
+ if (user.disabled === true || typeof user.email !== 'string')
93
+ return null;
94
+ return { subject: user.localId, email: user.email };
95
+ }
96
+ export default defineRuntime({
97
+ create({ projectId, tenantId, apiKey, credentials }) {
98
+ const auth = new GoogleAuth({ scopes: [SCOPE], projectId, ...(credentials === undefined ? {} : { credentials }) });
99
+ const tenant = tenantId === undefined ? {} : { tenantId };
100
+ // AUTH-7
101
+ async function accessToken(signal) {
102
+ try {
103
+ const token = await untilAborted(auth.getAccessToken(), signal);
104
+ if (typeof token !== 'string' || token === '')
105
+ throw new Error('no access token');
106
+ return token;
107
+ }
108
+ catch (error) {
109
+ throw providerFailed('network', messageOf(error));
110
+ }
111
+ }
112
+ async function withToken(signal) {
113
+ return { 'content-type': 'application/json', authorization: `Bearer ${await accessToken(signal)}` };
114
+ }
115
+ // AUTH-2
116
+ async function signInRequest(signal) {
117
+ const url = new URL(`${ENDPOINT}:signInWithPassword`);
118
+ if (apiKey === undefined)
119
+ return { url, headers: await withToken(signal) };
120
+ url.searchParams.set('key', apiKey);
121
+ return { url, headers: { 'content-type': 'application/json' } };
122
+ }
123
+ return {
124
+ async authenticate(email, password) {
125
+ const signal = AbortSignal.timeout(TIMEOUT_MS);
126
+ const { url, headers } = await signInRequest(signal);
127
+ const answer = await send(url, headers, { email, password, returnSecureToken: true, ...tenant }, signal);
128
+ return answer.status === 200 ? signedIn(answer.body) : refused(answer);
129
+ },
130
+ // AUTH-10
131
+ async getIdentity(subject) {
132
+ const signal = AbortSignal.timeout(TIMEOUT_MS);
133
+ const answer = await send(new URL(`${ENDPOINT}:lookup`), await withToken(signal), { localId: [subject], targetProjectId: projectId, ...tenant }, signal);
134
+ if (answer.status !== 200)
135
+ throw providerFailed(answer.status, errorMessage(answer.body));
136
+ return foundIn(answer.body);
137
+ }
138
+ };
139
+ }
140
+ });
@@ -0,0 +1,16 @@
1
+ import { type Secret } from '@genoacms/contracts';
2
+ import { type ServiceAccount } from '../shared/serviceAccount.js';
3
+ export interface GcpDatabaseOptions {
4
+ projectId: string;
5
+ /** Default '(default)'. */
6
+ databaseId?: string;
7
+ /** Omitted: Application Default Credentials, i.e. the runtime's own identity. */
8
+ credentials?: Secret<ServiceAccount>;
9
+ }
10
+ declare module '@genoacms/contracts' {
11
+ interface DatabaseAdapters {
12
+ '@genoacms/adapter-gcp/database': GcpDatabaseOptions;
13
+ }
14
+ }
15
+ declare const _default: import("@genoacms/contracts").AdapterDescriptor<"database", GcpDatabaseOptions>;
16
+ export default _default;
@@ -0,0 +1,7 @@
1
+ import { defineDatabaseAdapter } from '@genoacms/contracts';
2
+ import { unknownOptions, requireString } from '../shared/serviceAccount.js';
3
+ export default defineDatabaseAdapter({
4
+ runtime: '@genoacms/adapter-gcp/database/runtime',
5
+ secretOptions: { credentials: 'json' },
6
+ validate: options => [...unknownOptions(options, ['projectId', 'databaseId', 'credentials']), ...requireString(options, 'projectId')]
7
+ });
@@ -0,0 +1,5 @@
1
+ import type { Adapter } from '@genoacms/contracts/database';
2
+ import type { GcpDatabaseOptions } from './descriptor.js';
3
+ /** One Firestore provider, with its own client and credential. */
4
+ declare const _default: import("@genoacms/contracts").AdapterRuntime<GcpDatabaseOptions, Adapter>;
5
+ export default _default;
@@ -0,0 +1,68 @@
1
+ import { defineRuntime } from '@genoacms/contracts';
2
+ import { Firestore } from '@google-cloud/firestore';
3
+ /** One Firestore provider, with its own client and credential. */
4
+ export default defineRuntime({
5
+ create({ projectId, databaseId, credentials }) {
6
+ const firestore = new Firestore({
7
+ projectId,
8
+ databaseId: databaseId ?? '(default)',
9
+ ...(credentials === undefined ? {} : { credentials })
10
+ });
11
+ const createDocument = async (reference, data) => {
12
+ const document = await firestore.collection(reference.name).add(data);
13
+ const documentReference = {
14
+ collection: reference,
15
+ id: document.id
16
+ };
17
+ return {
18
+ reference: documentReference,
19
+ data
20
+ };
21
+ };
22
+ const getCollection = async (reference) => {
23
+ const collection = await firestore.collection(reference.name).get();
24
+ const documents = [];
25
+ collection.forEach(document => {
26
+ documents.push({
27
+ reference: {
28
+ collection: reference,
29
+ id: document.id
30
+ },
31
+ data: document.data()
32
+ });
33
+ });
34
+ return documents;
35
+ };
36
+ const getDocument = async ({ collection, id }) => {
37
+ const document = await firestore.collection(collection.name).doc(id).get();
38
+ if (!document.exists)
39
+ return undefined;
40
+ const documentReference = {
41
+ collection,
42
+ id
43
+ };
44
+ const documentSnapshot = {
45
+ reference: documentReference,
46
+ data: document.data()
47
+ };
48
+ return documentSnapshot;
49
+ };
50
+ const updateDocument = async (reference, document) => {
51
+ await firestore.collection(reference.collection.name).doc(reference.id).update(document);
52
+ return {
53
+ reference,
54
+ data: document
55
+ };
56
+ };
57
+ const deleteDocument = async (reference) => {
58
+ await firestore.collection(reference.collection.name).doc(reference.id).delete();
59
+ };
60
+ return {
61
+ createDocument,
62
+ getDocument,
63
+ getCollection,
64
+ updateDocument,
65
+ deleteDocument
66
+ };
67
+ }
68
+ });
@@ -0,0 +1,4 @@
1
+ declare const FUNCTION_ENTRY = "import { handler } from './index.js'\n\n/** Cloud Run functions entry point. The name must match buildConfig.entryPoint. */\nexport function genoacms (req, res) {\n handler(req, res, undefined)\n}\n";
2
+ declare function stageArtifact(buildDir: string, app: string): Promise<string>;
3
+ declare function zipDirectory(dir: string, out: string): Promise<string>;
4
+ export { stageArtifact, zipDirectory, FUNCTION_ENTRY };
@@ -0,0 +1,46 @@
1
+ import { cp, readFile, writeFile, access } from 'node:fs/promises';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import archiver from 'archiver';
5
+ // DEP-6, DEP-10
6
+ const FUNCTION_ENTRY = `import { handler } from './index.js'
7
+
8
+ /** Cloud Run functions entry point. The name must match buildConfig.entryPoint. */
9
+ export function genoacms (req, res) {
10
+ handler(req, res, undefined)
11
+ }
12
+ `;
13
+ async function requireRuntimePackage(buildDir) {
14
+ try {
15
+ await access(join(buildDir, 'package.json'));
16
+ }
17
+ catch {
18
+ throw new Error(`deploy/no-runtime-package: ${buildDir}/package.json is missing; build with genoa build`);
19
+ }
20
+ }
21
+ async function setMain(packageJsonPath, main) {
22
+ const pkg = JSON.parse(await readFile(packageJsonPath, 'utf-8'));
23
+ await writeFile(packageJsonPath, `${JSON.stringify({ ...pkg, main }, null, 2)}\n`);
24
+ }
25
+ // DEP-6
26
+ async function stageArtifact(buildDir, app) {
27
+ await requireRuntimePackage(buildDir);
28
+ await cp(buildDir, app, { recursive: true });
29
+ await setMain(join(app, 'package.json'), 'function.js');
30
+ await writeFile(join(app, 'function.js'), FUNCTION_ENTRY);
31
+ return app;
32
+ }
33
+ // DEP-7
34
+ async function zipDirectory(dir, out) {
35
+ await new Promise((resolve, reject) => {
36
+ const output = createWriteStream(out);
37
+ const archive = archiver('zip', { zlib: { level: 9 } });
38
+ output.on('close', () => { resolve(); });
39
+ archive.on('error', (err) => { reject(err); });
40
+ archive.pipe(output);
41
+ archive.directory(dir, false);
42
+ void archive.finalize();
43
+ });
44
+ return out;
45
+ }
46
+ export { stageArtifact, zipDirectory, FUNCTION_ENTRY };
@@ -0,0 +1,24 @@
1
+ import { type Secret } from '@genoacms/contracts';
2
+ import { type ServiceAccount } from '../shared/serviceAccount.js';
3
+ import { type FunctionSettings } from './settings.js';
4
+ /** The function's settings; each defaults to today's behavior except `runtime` (architecture GD3). */
5
+ export interface GcpDeploymentOptions extends FunctionSettings {
6
+ projectId: string;
7
+ region: string;
8
+ /** Default 'genoacms'. */
9
+ functionName?: string;
10
+ /** Used by `genoa deploy` on the operator's machine only. Never embedded in the build. */
11
+ credentials?: Secret<ServiceAccount>;
12
+ }
13
+ declare module '@genoacms/contracts' {
14
+ interface DeploymentTargets {
15
+ '@genoacms/adapter-gcp/deployment': GcpDeploymentOptions;
16
+ }
17
+ }
18
+ /**
19
+ * SDK-free: the build loads this to choose the SvelteKit adapter. Both loaders are lazy imports
20
+ * resolved from this package, so neither the SvelteKit adapter nor the Cloud Functions SDK loads
21
+ * until it is needed.
22
+ */
23
+ declare const _default: import("@genoacms/contracts").DeploymentDescriptor<GcpDeploymentOptions>;
24
+ export default _default;
@@ -0,0 +1,20 @@
1
+ import { defineDeploymentTarget } from '@genoacms/contracts';
2
+ import { unknownOptions, requireString } from '../shared/serviceAccount.js';
3
+ import { SETTING_KEYS, validateSettings } from './settings.js';
4
+ /**
5
+ * SDK-free: the build loads this to choose the SvelteKit adapter. Both loaders are lazy imports
6
+ * resolved from this package, so neither the SvelteKit adapter nor the Cloud Functions SDK loads
7
+ * until it is needed.
8
+ */
9
+ export default defineDeploymentTarget({
10
+ svelteKitAdapter: async () => await import('@genoacms/sveltekit-adapter-cloud-run-functions'),
11
+ svelteKitOptions: (_options, { outDir }) => ({ out: outDir }),
12
+ procedure: async () => await import('./procedure.js'),
13
+ secretOptions: { credentials: 'json' },
14
+ validate: options => [
15
+ ...unknownOptions(options, ['projectId', 'region', 'functionName', 'credentials', ...SETTING_KEYS]),
16
+ ...requireString(options, 'projectId'),
17
+ ...requireString(options, 'region'),
18
+ ...validateSettings(options)
19
+ ]
20
+ });
@@ -0,0 +1,16 @@
1
+ import type { google } from '@google-cloud/functions/build/protos/protos.js';
2
+ import { v2 } from '@google-cloud/functions';
3
+ import type { ServiceAccount } from '../shared/serviceAccount.js';
4
+ import { type FunctionSettings } from './settings.js';
5
+ type IStorageSource = google.cloud.functions.v2.IStorageSource;
6
+ type FunctionServiceClient = InstanceType<typeof v2.FunctionServiceClient>;
7
+ interface FunctionTarget {
8
+ projectId: string;
9
+ region: string;
10
+ functionName: string;
11
+ }
12
+ declare function createFunctionsClient(credentials?: ServiceAccount): FunctionServiceClient;
13
+ declare function uploadArchive(client: FunctionServiceClient, projectId: string, region: string, archivePath: string): Promise<IStorageSource>;
14
+ declare function deployFunction(client: FunctionServiceClient, { projectId, region, functionName }: FunctionTarget, storageSource: IStorageSource, settings: FunctionSettings): Promise<string | undefined>;
15
+ export { createFunctionsClient, uploadArchive, deployFunction };
16
+ export type { FunctionTarget, FunctionServiceClient };
@@ -0,0 +1,66 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { v2 } from '@google-cloud/functions';
3
+ import { buildConfig, serviceConfig } from './settings.js';
4
+ const GRPC_NOT_FOUND = 5;
5
+ // DEP-13
6
+ function createFunctionsClient(credentials) {
7
+ return new v2.FunctionServiceClient(credentials === undefined ? {} : { credentials });
8
+ }
9
+ async function uploadArchive(client, projectId, region, archivePath) {
10
+ const location = client.locationPath(projectId, region);
11
+ const [urlResponse] = await client.generateUploadUrl({ parent: location });
12
+ const uploadUrl = urlResponse.uploadUrl;
13
+ const storageSource = urlResponse.storageSource;
14
+ if (!uploadUrl || !storageSource)
15
+ throw new Error('Upload URL not found');
16
+ const sourceArchiveStream = createReadStream(archivePath);
17
+ const response = await fetch(uploadUrl, {
18
+ method: 'PUT',
19
+ // @ts-expect-error: invalid typings
20
+ body: sourceArchiveStream,
21
+ duplex: 'half',
22
+ headers: {
23
+ 'Content-Type': 'application/zip'
24
+ }
25
+ });
26
+ if (!response.ok)
27
+ throw new Error(`deploy/upload-failed: ${response.status} ${response.statusText}`);
28
+ return storageSource;
29
+ }
30
+ // DEP-9
31
+ async function functionExists(client, name) {
32
+ try {
33
+ await client.getFunction({ name });
34
+ return true;
35
+ }
36
+ catch (error) {
37
+ if (error.code === GRPC_NOT_FOUND)
38
+ return false;
39
+ throw error;
40
+ }
41
+ }
42
+ // DEP-11, GD1
43
+ async function completeOperation(operation) {
44
+ try {
45
+ const [result] = await operation.promise();
46
+ return result;
47
+ }
48
+ catch (error) {
49
+ throw new Error(`deploy/function-failed: ${error.message}`, { cause: error });
50
+ }
51
+ }
52
+ // DEP-10, DEP-12, GD1
53
+ async function deployFunction(client, { projectId, region, functionName }, storageSource, settings) {
54
+ const name = client.functionPath(projectId, region, functionName);
55
+ const request = {
56
+ functionId: functionName,
57
+ parent: client.locationPath(projectId, region),
58
+ function: { name, buildConfig: buildConfig(settings, storageSource), serviceConfig: serviceConfig(settings) }
59
+ };
60
+ const [operation] = await functionExists(client, name)
61
+ ? await client.updateFunction(request)
62
+ : await client.createFunction(request);
63
+ const result = await completeOperation(operation);
64
+ return result.url ?? result.serviceConfig?.uri ?? undefined;
65
+ }
66
+ export { createFunctionsClient, uploadArchive, deployFunction };
@@ -0,0 +1,4 @@
1
+ import type { GcpDeploymentOptions } from './descriptor.js';
2
+ /** Uploads the build artifact, then creates or updates the Cloud Run function and waits for it. */
3
+ declare const _default: import("@genoacms/contracts").DeployProcedure<GcpDeploymentOptions>;
4
+ export default _default;
@@ -0,0 +1,16 @@
1
+ import { join } from 'node:path';
2
+ import { defineDeployProcedure } from '@genoacms/contracts';
3
+ import { stageArtifact, zipDirectory } from './archive.js';
4
+ import { createFunctionsClient, uploadArchive, deployFunction } from './functions.js';
5
+ const DEFAULT_FUNCTION_NAME = 'genoacms';
6
+ /** Uploads the build artifact, then creates or updates the Cloud Run function and waits for it. */
7
+ export default defineDeployProcedure(async (options, ctx) => {
8
+ const app = await stageArtifact(ctx.buildDir, join(ctx.workDir, 'app'));
9
+ const archive = await zipDirectory(app, join(ctx.workDir, 'build.zip'));
10
+ const client = createFunctionsClient(options.credentials);
11
+ const storageSource = await uploadArchive(client, options.projectId, options.region, archive);
12
+ const target = { projectId: options.projectId, region: options.region, functionName: options.functionName ?? DEFAULT_FUNCTION_NAME };
13
+ const url = await deployFunction(client, target, storageSource, options);
14
+ if (url !== undefined)
15
+ console.info(`Function URL: ${url}`);
16
+ });
@@ -0,0 +1,22 @@
1
+ type Ingress = 'all' | 'internal' | 'internal-and-gclb';
2
+ /** The function's settings, as the gcp target's options name them (architecture GD3). */
3
+ export interface FunctionSettings {
4
+ runtime?: string;
5
+ memory?: string;
6
+ timeoutSeconds?: number;
7
+ minInstances?: number;
8
+ maxInstances?: number;
9
+ ingress?: Ingress;
10
+ serviceAccount?: string;
11
+ /** Set as ORIGIN: the origin browsers use, when a proxy rewrites the forwarded host (architecture GD5). */
12
+ origin?: string;
13
+ /** Set as XFF_DEPTH: which X-Forwarded-For entry, from the right, is the client (architecture GD5). */
14
+ xffDepth?: number;
15
+ }
16
+ declare const SETTING_KEYS: readonly string[];
17
+ declare const DEFAULT_RUNTIME = "nodejs22";
18
+ declare function validateSettings(options: unknown): string[];
19
+ declare function buildConfig(settings: FunctionSettings, storageSource: object): object;
20
+ declare function serviceConfig(settings: FunctionSettings): object;
21
+ export { SETTING_KEYS, DEFAULT_RUNTIME, validateSettings, buildConfig, serviceConfig };
22
+ export type { Ingress };
@@ -0,0 +1,58 @@
1
+ const SETTING_KEYS = ['runtime', 'memory', 'timeoutSeconds', 'minInstances', 'maxInstances', 'ingress', 'serviceAccount', 'origin', 'xffDepth'];
2
+ const DEFAULT_RUNTIME = 'nodejs22';
3
+ // DEP-1, DEP-3
4
+ const INGRESS_SETTING_BY_OPTION = { all: 1, internal: 2, 'internal-and-gclb': 3 };
5
+ const isInteger = (value, min, max = Number.MAX_SAFE_INTEGER) => Number.isInteger(value) && value >= min && value <= max;
6
+ const matches = (value, pattern) => typeof value === 'string' && pattern.test(value);
7
+ // DEP-4, DEP-14
8
+ const RULES = {
9
+ runtime: [value => matches(value, /^nodejs\d+$/), "runtime must be a Node.js runtime such as 'nodejs22'"],
10
+ memory: [value => matches(value, /^\d+(M|Mi|G|Gi)$/), "memory must be a size such as '512Mi' or '1Gi'"],
11
+ timeoutSeconds: [value => isInteger(value, 1, 3600), 'timeoutSeconds must be an integer from 1 to 3600'],
12
+ minInstances: [value => isInteger(value, 0), 'minInstances must be an integer of at least 0'],
13
+ maxInstances: [value => isInteger(value, 1), 'maxInstances must be an integer of at least 1'],
14
+ ingress: [value => typeof value === 'string' && Object.hasOwn(INGRESS_SETTING_BY_OPTION, value), "ingress must be 'all', 'internal' or 'internal-and-gclb'"],
15
+ serviceAccount: [value => matches(value, /^[^@\s]+@[^@\s]+$/), 'serviceAccount must be a service account email'],
16
+ origin: [value => matches(value, /^https?:\/\/[^/\s]+$/), "origin must be an absolute http(s) origin such as 'https://cms.example.com'"],
17
+ xffDepth: [value => isInteger(value, 1), 'xffDepth must be an integer of at least 1']
18
+ };
19
+ function instanceOrder(options) {
20
+ const { minInstances: min, maxInstances: max } = options;
21
+ if (!isInteger(min, 0) || !isInteger(max, 1))
22
+ return [];
23
+ return min > max ? ['minInstances must not exceed maxInstances'] : [];
24
+ }
25
+ // DEP-4, DEP-14
26
+ function validateSettings(options) {
27
+ const record = typeof options === 'object' && options !== null ? options : {};
28
+ const reasons = SETTING_KEYS
29
+ .filter(key => record[key] !== undefined && !RULES[key][0](record[key]))
30
+ .map(key => RULES[key][1]);
31
+ return [...reasons, ...instanceOrder(record)];
32
+ }
33
+ // DEP-3, DEP-10
34
+ function buildConfig(settings, storageSource) {
35
+ return { entryPoint: 'genoacms', runtime: settings.runtime ?? DEFAULT_RUNTIME, source: { storageSource } };
36
+ }
37
+ // DEP-10, DEP-14, GQ1, GD8
38
+ function environmentVariables(settings) {
39
+ return {
40
+ NODE_ENV: 'production',
41
+ IGNORED_ROUTES: '',
42
+ ...(settings.origin === undefined ? {} : { ORIGIN: settings.origin }),
43
+ ...(settings.xffDepth === undefined ? {} : { XFF_DEPTH: String(settings.xffDepth) })
44
+ };
45
+ }
46
+ // DEP-3, DEP-10, GD3
47
+ function serviceConfig(settings) {
48
+ return {
49
+ minInstanceCount: settings.minInstances ?? 0,
50
+ maxInstanceCount: settings.maxInstances ?? 1,
51
+ ingressSettings: INGRESS_SETTING_BY_OPTION[settings.ingress ?? 'all'],
52
+ environmentVariables: environmentVariables(settings),
53
+ ...(settings.memory === undefined ? {} : { availableMemory: settings.memory }),
54
+ ...(settings.timeoutSeconds === undefined ? {} : { timeoutSeconds: settings.timeoutSeconds }),
55
+ ...(settings.serviceAccount === undefined ? {} : { serviceAccountEmail: settings.serviceAccount })
56
+ };
57
+ }
58
+ export { SETTING_KEYS, DEFAULT_RUNTIME, validateSettings, buildConfig, serviceConfig };
@@ -0,0 +1,14 @@
1
+ import { type BootstrapSecret } from '@genoacms/contracts';
2
+ import { type ServiceAccount } from '../shared/serviceAccount.js';
3
+ export interface GcpSecretsOptions {
4
+ projectId: string;
5
+ /** Bootstrap: env() or inline() only. Omit for Application Default Credentials (recommended). */
6
+ credentials?: BootstrapSecret<ServiceAccount>;
7
+ }
8
+ declare module '@genoacms/contracts' {
9
+ interface SecretsAdapters {
10
+ '@genoacms/adapter-gcp/secrets': GcpSecretsOptions;
11
+ }
12
+ }
13
+ declare const _default: import("@genoacms/contracts").AdapterDescriptor<"secrets", GcpSecretsOptions>;
14
+ export default _default;
@@ -0,0 +1,7 @@
1
+ import { defineSecretsAdapter } from '@genoacms/contracts';
2
+ import { unknownOptions, requireString } from '../shared/serviceAccount.js';
3
+ export default defineSecretsAdapter({
4
+ runtime: '@genoacms/adapter-gcp/secrets/runtime',
5
+ secretOptions: { credentials: 'json' },
6
+ validate: options => [...unknownOptions(options, ['projectId', 'credentials']), ...requireString(options, 'projectId')]
7
+ });
@@ -0,0 +1,14 @@
1
+ import type { Adapter } from '@genoacms/contracts/secrets';
2
+ import type { GcpSecretsOptions } from './descriptor.js';
3
+ /**
4
+ * The GenoaCMS `secrets` service backed by GCP Secret Manager.
5
+ *
6
+ * Secret Manager is versioned; this contract is not. Writes add a version and reads always take
7
+ * `latest`, so version history exists but is never consulted. That is deliberate — building the
8
+ * contract on a provider-specific behavior would leave the abstraction unimplementable elsewhere.
9
+ * `setSecret` therefore destroys the versions it supersedes (architecture GD4). Secrets created here
10
+ * keep a destroyed version disabled for seven days before it is gone, so a bad overwrite can be undone
11
+ * by hand.
12
+ */
13
+ declare const _default: import("@genoacms/contracts").AdapterRuntime<GcpSecretsOptions, Adapter>;
14
+ export default _default;