Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,11 @@
"@angular/platform-browser-dynamic": "~18.2.5",
"@angular/router": "~18.2.5",
"@capacitor-community/file-opener": "^7.0.1",
"@capacitor-firebase/app-check": "^7.3.0",
"@capacitor-firebase/authentication": "^7.2.0",
"@capacitor-firebase/crashlytics": "^7.2.0",
"@capacitor-firebase/firestore": "^7.2.0",
"@capacitor-firebase/functions": "^7.3.0",
"@capacitor-firebase/performance": "^7.2.0",
"@capacitor/android": "^7.0.0",
"@capacitor/app": "^7.0.0",
Expand Down
21 changes: 20 additions & 1 deletion packages/data-models/deployment.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { IGdriveEntry } from "../@idemsInternational/gdrive-tools";
import type { IAppConfig, IAppConfigOverride } from "./appConfig";

/** Update version to force recompile next time deployment set (e.g. after default config update) */
export const DEPLOYMENT_CONFIG_VERSION = 20250407.1;
export const DEPLOYMENT_CONFIG_VERSION = 20250818.0;

/** Configuration settings available to runtime application */
export interface IDeploymentRuntimeConfig {
Expand Down Expand Up @@ -71,10 +71,26 @@ export interface IDeploymentRuntimeConfig {
crashlytics?: {
enabled: boolean;
};
appCheck?: {
/**
* Site key used to validate appCheck against recaptcha v3 enterprise
* https://firebase.google.com/docs/app-check/web/recaptcha-provider
*/
recaptchaEnterpriseSiteKey?: string;
};
functions?: {
/** Region where functions are deployed to. If not specified assumes "us-central1" */
region?: string;
};
};
/** Friendly name used to identify the deployment name */
name: string;

/** 3rd party integration for remote functions. Default enabled with firebase provider */
remote_functions?: {
provider: "firebase";
};

/** 3rd party integration for shared data management. Default enabled with firebase provider */
shared_data?: {
provider: "firebase";
Expand Down Expand Up @@ -222,6 +238,9 @@ export const DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS: IDeploymentRuntimeConfig = {
campaigns: {
enabled: true,
},
remote_functions: {
provider: "firebase",
},
shared_data: {
provider: "firebase",
},
Expand Down
1 change: 1 addition & 0 deletions packages/data-models/flowTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ export namespace FlowTypes {
"open_external",
"pop_up",
"process_template",
"remote_function",
"reset_app",
"reset_data",
"save_to_device",
Expand Down
2 changes: 2 additions & 0 deletions packages/scripts/src/tasks/providers/appData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ function generateRuntimeConfig(deploymentConfig: IDeploymentConfigJson): IDeploy
firebase,
git,
name,
remote_functions,
shared_data,
supabase,
web,
Expand All @@ -94,6 +95,7 @@ function generateRuntimeConfig(deploymentConfig: IDeploymentConfigJson): IDeploy
error_logging,
firebase,
name,
remote_functions,
shared_data,
supabase,
web,
Expand Down
4 changes: 4 additions & 0 deletions src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ const sidebarRoutes: Routes = [
path: "shared-data",
loadChildren: () => import("./feature/shared-data").then((m) => m.SHARED_DATA_ROUTES),
},
{
path: "remote-functions",
loadChildren: () => import("./feature/remote-function").then((m) => m.REMOTE_FUNCTION_ROUTES),
},
{
path: "template",
loadChildren: () =>
Expand Down
90 changes: 90 additions & 0 deletions src/app/feature/remote-function/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
## Overview


## Setup (Firebase Provider)
1. Deploy test functions from [Open App Builder Functions](https://github.com/IDEMSInternational/open-app-builder-functions) Repo

2a. Update deployment config to use firebase remote_functions provider
```ts
config.remote_functions={
provider:'firebase'
}
```

2b. If functions are deployed to any region other than `us-central1`
```ts
config.firebase={
functions:{
region: 'europe-west1'
}
}
```

3. Follow steps below to enable App Check which is used to ensure functions can only be invocated from trusted sources

### App Check - Web

Follow instructions in docs to create to enable RecaptchaV3 Enterprise API on google cloud project that corresponds to firebase project, and generate a key for use on the project

https://firebase.google.com/docs/app-check/web/recaptcha-provider

Include any domains you wish to accept requests from.

Set the generated site key both in the firebase console for the web-app to be used, and within the deployment config. This key is public-facing, so fine to commit to source control

```ts
config.firebase = {
appCheck:{recaptchaEnterpriseSiteKey:'myKey'}
}
```
Whilst a single key can be used across multiple apps, it is recommended to create per-app keys

By default only firebase functions deployed specifying appCheck will have their usage restricted to allowed sources. Additional resources such as Firestore DB can be restricted from the dashboard console.

Usage can be monitored both from the [Firebase Console](https://console.firebase.google.com) App Check page, and [Google Cloud Console Recaptcha](https://console.cloud.google.com/security/recaptcha/)

### App Check - Android
Follow instructions to link play store project with firebase app check
https://firebase.google.com/docs/app-check/android/play-integrity-provider

Additional platform setup not required as integrated with `capacitor-firebase/app-check`

### App Check - IOS
Follow instructions to integrate `DeviceCheck` on older devices (ios 11+)
https://firebase.google.com/docs/app-check/ios/devicecheck-provider

Follow instructions to integrate `App Attest` on newer devices (ios 14+)
https://firebase.google.com/docs/app-check/ios/app-attest-provider

## Testing
Remote functions can be tested locally from a debug page, with support for whitelisted device tokens.
See review notes in https://github.com/IDEMSInternational/open-app-builder/pull/3097

## TODO

### Functions
- [ ] Create and deploy specific test function for use in debug

### Tests
- [ ] Functional testing on native
- [ ] Functional testing on web
- [ ] Service and provider unit tests

### Authoring Integration
- [ ] Handle storing response in field/list for access
- [ ] Add actions to trigger
- [ ] Create feat template

## Troubleshooting

### Access to fetch has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

By default firebase enables cors for all functions, so most likely this error is a false identification. Cors errors will also get displayed if no function matching the name has been deployed. This will likely coincide with separate internal error response from firebase

```json
{
"code": "functions/internal",
"message": "internal",
"name": "FirebaseError"
}
```
34 changes: 34 additions & 0 deletions src/app/feature/remote-function/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { inject } from "@angular/core";
import { Routes } from "@angular/router";
import { RemoteFunctionService } from "./remote-function.service";
import { TemplateActionRegistry } from "src/app/shared/components/template/services/instance/template-action.registry";
import { RemoteFunctionActionFactory } from "./remote-function.actions";
import { ENVIRONMENT_INITIALIZER, makeEnvironmentProviders } from "@angular/core";

// Export the routes to allow debug route to be included in main app-routing.module
export const REMOTE_FUNCTION_ROUTES: Routes = [
{
path: "",
loadComponent: () =>
import("./pages/debug/remote-function-debug.page").then((m) => m.RemoteFunctionDebugPage),
},
];

// Use initializer to register actions used within global templates
export function initialiseRemoteFunctions() {
return () => {
const service = inject(RemoteFunctionService);
const templateActionRegistry = inject(TemplateActionRegistry);
const { remote_function } = new RemoteFunctionActionFactory(service);
templateActionRegistry.register({ remote_function });
};
}

// use environment initializer to ensure init only called once (sync/non-blocking)
// if needing async logic an APP_INITIALIZER can be used instead
export function provideRemoteFunctions() {
return makeEnvironmentProviders([
RemoteFunctionService,
{ provide: ENVIRONMENT_INITIALIZER, useFactory: initialiseRemoteFunctions, multi: true },
]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<ion-content class="ion-padding">
<h2>Remote Functions</h2>

<p>Trigger remote function with data parameters</p>
<div style="display: flex; gap: 16px">
<div>
<h3>Target Function</h3>
<ion-input
fill="outline"
placeholder="Function Name"
[(ngModel)]="targetFunctionName"
></ion-input>
</div>
<div>
<h3>Parameters</h3>
<div style="display: flex; flex-direction: column; gap: 12px">
@for (pair of paramPairs(); track $index) {
<div style="display: flex; gap: 8px; align-items: center">
<ion-input
fill="outline"
placeholder="Key"
[value]="pair.key()"
(ionInput)="pair.key.set($any($event.target).value)"
></ion-input>
<ion-input
fill="outline"
placeholder="Value"
[value]="pair.value()"
(ionInput)="pair.value.set($any($event.target).value)"
></ion-input>
<ion-button fill="clear" (click)="removeParam($index)">
<ion-icon name="close-outline"></ion-icon>
</ion-button>
</div>
}
<ion-button fill="outline" (click)="addParam()">+ Add Param</ion-button>
</div>
</div>
</div>

<ion-button
[disabled]="!targetFunctionName() || requestPending()"
(click)="triggerFunction(targetFunctionName())"
>Run</ion-button
>

@if(response().data; as responseData){
<h4>Data</h4>
<div>{{responseData | json}}</div>
} @if(response().error; as responseError){
<h4>Error</h4>
<div>{{responseError | json}}</div>
}

<hr style="background-color: #cacaca; height: 1px; width: 100%; margin: 2em 0" />

@switch (config.provider) { @case('firebase'){

<!-- Firebase Provider-->

<h2>Firebase Provider</h2>
<h3>AppCheck Token</h3>
<p>Firebase uses appcheck to ensure functions only invocated from trusted source</p>

@if(firebaseToken(); as token){
<div>✅ Token Available</div>
<div style="font-size: small">
@if(token.expireTimeMillis; as expiry){ Expires: {{expiry}} } @else{ Expiry not Available on Web
}
</div>
<ion-button style="margin-top: 1em">Refresh</ion-button>
} @if(firebaseTokenError(); as tokenError){
<div>⚠️ Could Not Retrieve Token</div>
<div style="margin-top: 1em; font-size: small">{{tokenError}}<br /></div>
<div style="margin-top: 1em">
Ensure the current app, site or debug token is registered in the firebase console
</div>

} }

<!-- Default Provider -->
@default {
<p>Debug page not configured for provider: {{config.provider}}</p>
} }

<hr style="background-color: #cacaca; height: 1px; width: 100%; margin: 2em 0" />
<h3>Deployment Config</h3>
<div>{{config | json}}</div>
</ion-content>
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Component, computed, inject, model, OnInit, signal } from "@angular/core";
import { RemoteFunctionService } from "../../remote-function.service";
import { IonicModule } from "@ionic/angular";
import { JsonPipe } from "@angular/common";
import { FirebaseFunctionProvider } from "../../providers/firebase";
import { FormsModule } from "@angular/forms";

interface ParamSignal {
key: ReturnType<typeof signal<string>>;
value: ReturnType<typeof signal<string>>;
}

@Component({
templateUrl: "remote-function-debug.page.html",
styleUrl: "remote-function-debug.page.scss",
standalone: true,
imports: [IonicModule, JsonPipe, FormsModule],
})
export class RemoteFunctionDebugPage implements OnInit {
public service = inject(RemoteFunctionService);

public targetFunctionName = model<string>("");

public paramPairs = signal<ParamSignal[]>([]);

public response = signal({ error: undefined, data: undefined });

public requestPending = signal(false);

private firebaseProvider = computed(() => {
if (this.config.provider === "firebase") {
return this.service.provider as FirebaseFunctionProvider;
}
return undefined;
});

public firebaseToken = computed(() => this.firebaseProvider()?.["appCheckToken"]());
public firebaseTokenError = computed(() => this.firebaseProvider()?.["appCheckTokenError"]());

public config = this.service["config"];

async ngOnInit() {
await this.service.ready();
}

// --- Param management ---
public addParam(key: string = "", value: string = "") {
this.paramPairs.update((pairs) => [...pairs, { key: signal(key), value: signal(value) }]);
}
public removeParam(index: number) {
this.paramPairs.update((pairs) => pairs.filter((_, i) => i !== index));
}

private buildParams(): Record<string, any> {
const params: Record<string, any> = {};
for (const { key, value } of this.paramPairs()) {
const k = key().trim();
const v = value().trim();
if (k) {
params[k] = v;
}
}
return params;
}

// --- Function ---
public async triggerFunction(name: string) {
this.requestPending.set(true);
this.response.set({ error: undefined, data: undefined });
const params = this.buildParams();
console.log("[Remote Function] Run", { name, params });
const res = await this.service.provider.invoke(name, params);
this.response.set(res);
this.requestPending.set(false);
}
}
Loading