Skip to content
Closed
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
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: CI

on:
push:
branches: [main]
pull_request:

# The workflow only checks out code and runs tests; it never needs write access.
permissions:
contents: read

jobs:
ci:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: '.nvmrc'
cache: 'npm'

- name: Set up PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: '8.3'

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm test

- name: Build production bundle
run: npm run build

- name: Run deploy guard
run: npm run test:deploy-guard

- name: Run proxy allowlist tests
run: npm run test:proxies

- name: Scan production bundle for API key/endpoint leaks
run: python3 scripts/scan_bundle_for_leaks.py
46 changes: 33 additions & 13 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,34 +79,54 @@ in `.dart` untouched so a re-deploy is a no-op and an editor rename survives.

Before writing anything to the project the runner compiles the generated
classes in a throwaway Flutter package built from the project's own dependency
versions and refuses the deploy (HTTP 422) if the analyzer reports an error.
versions, and refuses the deploy (HTTP 422) if the analyzer reports an error.
The FlutterFlow DSL only checks that code is formattable, which accepts a call
to a named argument the package never declared. This needs the Flutter SDK in
the image, so the runner takes longer to build and to cold-start, and the
deploy script raises Cloud Run's request timeout to 900s.

A class that imports FlutterFlow scaffolding (`/backend/schema/structs/index.dart`)
is compiled too: the deploy gathers the transitive closure of each class's
project-relative imports from the export and ships it alongside, bounded by
file and byte caps. The class is written to `lib/custom_code/<stem>.dart` -
where FlutterFlow files it, so a relative import resolves from the right
directory - and the project's generated Dart under `lib/`, which is what the
leading slash resolves against. Analysis is scoped to the deployed class files
with `--no-fatal-warnings`, so an error inside the project's own scaffolding,
or a warning-only class, cannot refuse code that compiles.
A class is compiled when every package it imports resolves the same way the
project resolves it. Two cases are left out of the compile set, and each is
reported rather than silently dropped:

* It imports something FlutterFlow only generates once the app is built -
`/backend/schema/structs/index.dart`, `../flutter_flow/lat_lng.dart`. Those
files do not exist until after the deploy.
* It imports a package the project declares from a source this manifest cannot
express: a `git:` or `path:` source, or a nested `hosted:` block. The scratch
package would resolve different code, and a check against the wrong version
reports a result that does not describe what ships.

The second case is decided **per class**, from that class's own imports. A
`git:` dependency stops only the classes that import it; classes that do not are
still compiled. Deciding it once for the whole project meant one such entry left
an entire deploy uncompiled.

**Which packages the Flutter SDK supplies is read from the project's own
pubspec** - a dependency written in block form under `sdk:` is an SDK package by
definition. Nothing keeps a list of SDK package names, because a list goes stale
the moment the SDK ships one it has not heard of, and a stale list reads a
perfectly valid package as unreproducible. That is not hypothetical: a list
missing `flutter_web_plugins` refused whole deploys for projects that declared
it.

The manifest is sent as package names and version constraints, never as
pubspec.yaml text, and the runner builds the document itself - it runs
`pub get` against it on a publicly reachable route, so a caller must not be
able to name a git or path source. `dependency_overrides` are carried over,
because a scratch package resolving different versions than the project would
approve code the project rejects.
approve code the project rejects. The runner validates each name against a
package-name pattern rather than against a list of SDK packages, and writes
`sdk: flutter` itself, so that value never comes from a caller.

A client still sending the older `pubspec` format is recognized, never
executed, and its deploy proceeds with the check reported as unavailable.

**Nothing unverified is deployed.** The web app refuses the deploy on any class
it cannot compile, naming each one and why, before the runner is called.
**What could not be verified still deploys, and is reported.** A class with a
real analyzer error refuses the deploy - HTTP 422, naming the error. A class the
deploy could not compile, for either reason above, does not: it is pushed, and
the warning naming it is logged *before* the provisioning request, so it
describes the mutation before it happens rather than after.

The runner streams its progress. When the request body sets `"stream": true`
the response is NDJSON — one `{"event":"phase"|"log"|"result"}` object per
Expand Down
53 changes: 44 additions & 9 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ import {
} from "./src/flutterFlowCodeSanitizer.js";
import { formatFlutterFlowFileError } from "./src/flutterFlowFileErrors.js";
import { extractPackageImports } from "./src/dartPackageImports.js";
import {
explainPlusAliasRule,
getMagicLinkResultMessage,
isKnownProviderPlusAlias,
PLUS_ALIAS_REJECTED_CODE,
trimEmail,
} from "./src/authMagicLink.js";
import { planCustomCodeVerification } from "./src/customCodeVerification.js";
import { readProvisionResponse } from "./src/provisionStream.js";
import { buildFlutterFlowSyncMetadata } from "./src/flutterFlowSyncMetadata.js";
Expand Down Expand Up @@ -2113,7 +2120,7 @@ async function provisionMissingCodeFiles(
) {
const missingCodeFiles = findMissingCodeFiles(fileMap, remoteFiles);
if (missingCodeFiles.length === 0) {
return { remoteFiles, syncFileMap: fileMap, unverified: [] };
return { remoteFiles, syncFileMap: fileMap, unverified: [], approximate: [] };
}

// The runner compiles these against the project's own package versions
Expand All @@ -2137,6 +2144,14 @@ async function provisionMissingCodeFiles(
const unverified = verificationPlan.skipped.map((entry) => entry.reason);
unverified.forEach((reason) => console.warn(`[custom class deploy] ${reason}`));

// Classes that do not import an unrepresentable package directly are still
// checked, but against a scratch graph that omits it. That approximation is
// disclosed here - before the push - and returned alongside `unverified`,
// so it reaches the deploy result and is never mistaken for a full-graph
// check.
const approximate = verificationPlan.approximate;
approximate.forEach((notice) => console.warn(`[custom class deploy] ${notice}`));

console.log(
`Provisioning ${missingCodeFiles.length} new FlutterFlow custom code file(s) before sync.`,
);
Expand Down Expand Up @@ -2178,6 +2193,7 @@ async function provisionMissingCodeFiles(
remoteFiles,
syncFileMap: excludeProvisionedCodeFiles(fileMap, missingCodeFiles),
unverified,
approximate,
};
}

Expand Down Expand Up @@ -2870,6 +2886,7 @@ async function commitToFlutterFlow(dartCode, fileName, options = {}) {
message: `Successfully committed ${fileName} to FlutterFlow project ${projectId}`,
addedDependencies: pubspecMerge.added,
unverified: provisioning.unverified,
approximate: provisioning.approximate,
warnings: result.errorMap ? Array.from(result.errorMap.entries()) : [],
};
} catch (error) {
Expand Down Expand Up @@ -3043,6 +3060,7 @@ async function executeCommit(code, options = {}) {
metadata,
addedDependencies: pubspecMerge.added,
unverified: provisioning.unverified,
approximate: provisioning.approximate,
warnings: result.errorMap ? Array.from(result.errorMap.entries()) : [],
elapsedTime: commitState.getElapsedTime(),
};
Expand Down Expand Up @@ -3207,6 +3225,7 @@ async function executeBundleCommit(bundlePlan, options = {}) {
metadata,
addedDependencies: pubspecMerge.added,
unverified: provisioning.unverified,
approximate: provisioning.approximate,
warnings: result.errorMap ? Array.from(result.errorMap.entries()) : [],
elapsedTime: commitState.getElapsedTime(),
};
Expand Down Expand Up @@ -4272,7 +4291,7 @@ async function handleMagicLinkRequest() {
const input = document.getElementById('signin-email-input')
const btn = document.getElementById('signin-submit-btn')
const msg = document.getElementById('signin-message')
const email = input?.value?.trim()
const email = trimEmail(input?.value)

const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
if (!email || !emailRegex.test(email) || email.length > 254) {
Expand All @@ -4281,13 +4300,19 @@ async function handleMagicLinkRequest() {
}

if (btn) { btn.disabled = true; btn.textContent = 'Sending…' }
if (msg) msg.textContent = ''

// Explain the rule for known providers, but do not block submission here.
// Existing accounts on plus-tagged addresses are still allowed to recover
// because the server performs the authoritative check.
const plusAliasHint = explainPlusAliasRule(email)
if (msg && plusAliasHint) msg.textContent = plusAliasHint

try {
await sendMagicLink(email)
if (input) input.value = ''
if (msg) msg.textContent = `Check your email — we sent a link to ${email}`
if (btn) btn.textContent = 'Sent!'
const data = await sendMagicLink(email)
if (input && data?.code !== PLUS_ALIAS_REJECTED_CODE) input.value = ''
if (msg) msg.textContent = getMagicLinkResultMessage(data, email)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Rejection Message Is Not Announced

The asynchronous rejection updates a plain paragraph while keyboard focus remains on the submit button. Because the message element has no live-region semantics, screen-reader users may not be notified when PLUS_ALIAS_REJECTED arrives. The inline validation should use an appropriate status or alert live region. Greptile automatically discovered a related ticket stating that the inline rejection message must be accessible, which informed this comment.

Knowledge Base Used: FlutterFlow custom code experience

Source Used: Linear — Reject known provider plus aliases when creating new magic-link accounts

Prompt To Fix With AI
This is a comment left during a code review.
Path: app.js
Line: 4300

Comment:
**Rejection Message Is Not Announced**

The asynchronous rejection updates a plain paragraph while keyboard focus remains on the submit button. Because the message element has no live-region semantics, screen-reader users may not be notified when `PLUS_ALIAS_REJECTED` arrives. The inline validation should use an appropriate status or alert live region. Greptile automatically discovered a related ticket stating that the inline rejection message must be accessible, which informed this comment.

**Knowledge Base Used:** [FlutterFlow custom code experience](https://app.greptile.com/connect-i-o/-/custom-context/knowledge-base/sgardoll/customcodeconnectforflutterflow/-/docs/flutterflow-custom-code-experience.md)

**Source Used:** Linear — [Reject known provider plus aliases when creating new magic-link accounts](https://linear.app/stuart-gardoll/issue/STU-149/reject-known-provider-plus-aliases-when-creating-new-magic-link)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex Fix in Devin

if (btn) btn.textContent = data?.code === PLUS_ALIAS_REJECTED_CODE ? 'Send Link' : 'Sent!'
if (btn && data?.code === PLUS_ALIAS_REJECTED_CODE) btn.disabled = false
} catch (err) {
console.error('handleMagicLinkRequest: sendMagicLink failed', { email, err })
if (msg) msg.textContent = 'Something went wrong. Please try again.'
Expand Down Expand Up @@ -5347,14 +5372,24 @@ function showCommitSuccessModal(result) {
const warningsSection = document.getElementById("success-warnings-section");
const warningsList = document.getElementById("success-warnings-list");
// A class that could not be compiled before the push is reported here rather
// than left implicit, so "deployed" never reads as "checked".
// than left implicit, so "deployed" never reads as "checked". A class that
// was compiled against a reduced package graph is reported beside it: the
// check ran, but approximately, and that has to stay visible too.
const unverified = result.unverified || [];
const approximate = result.approximate || [];
const fileWarnings = result.warnings || [];
if ((fileWarnings.length > 0 || unverified.length > 0) && warningsSection && warningsList) {
if (
(fileWarnings.length > 0 || unverified.length > 0 || approximate.length > 0) &&
warningsSection &&
warningsList
) {
warningsList.innerHTML = [
...unverified.map((reason) =>
`<li><span class="font-medium">Not verified before deploying:</span> ${escapeHtml(String(reason))}</li>`
),
...approximate.map((notice) =>
`<li><span class="font-medium">Verified approximately:</span> ${escapeHtml(String(notice))}</li>`
),
...fileWarnings.map(([file, errs]) =>
`<li><span class="font-medium">${escapeHtml(file)}:</span> ${escapeHtml(String(errs))}</li>`
),
Expand Down
29 changes: 16 additions & 13 deletions cloud-run/ffai-runner/bin/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,13 @@ final _packageNamePattern = RegExp(r'^[a-z_][a-z0-9_]*$');
// unrepresentable rather than merely discouraged.
final _constraintPattern = RegExp(r'^[A-Za-z0-9^~<>=*+._ -]+$');

// The packages the Flutter SDK supplies, i.e. the only valid `sdk: flutter`
// entries besides `flutter` itself.
const allowedSdkPackages = <String>{
'flutter',
'flutter_test',
'flutter_driver',
'flutter_localizations',
'integration_test',
};
// Which packages the Flutter SDK supplies is not decided here. A caller names
// the packages its project declares as `sdk: flutter`, and this runner emits
// them as `name: {sdk: flutter}` - a value it writes itself, never one a caller
// sent. There was an allowlist of SDK package names here, and it refused good
// deploys: the client classified a package correctly by its pubspec source, and
// the runner rejected it for not appearing on a list that had gone stale.
// `_packageNamePattern` is what actually keeps a key from carrying YAML.

Future<void> main() async {
final port = int.tryParse(Platform.environment['PORT'] ?? '') ?? 8080;
Expand Down Expand Up @@ -538,8 +536,13 @@ Map<String, String> _normalizeDependencyMap(Object? value, String field) {
return result;
}

/// Reads the SDK-supplied package names. Restricted to the packages the Flutter
/// SDK actually provides, so a caller cannot request an arbitrary `sdk:` value.
/// Reads the SDK-supplied package names a caller's project declares.
///
/// Validated as package names rather than against a list of the packages the
/// Flutter SDK happens to ship: a name the SDK does not provide fails
/// `pub get` with an honest error, whereas a list refuses correct deploys the
/// moment it falls behind the SDK. The pattern is the load-bearing check - each
/// name becomes a key in the generated pubspec.
List<String> _normalizeSdkPackages(Object? value) {
if (value == null) return const <String>[];
if (value is! List) {
Expand All @@ -549,8 +552,8 @@ List<String> _normalizeSdkPackages(Object? value) {
final result = <String>[];
for (final raw in value) {
final name = '$raw';
if (!allowedSdkPackages.contains(name)) {
throw FormatException('Unsupported SDK package: $name.');
if (!_packageNamePattern.hasMatch(name)) {
throw FormatException('Invalid SDK package name: $name.');
}
if (!result.contains(name)) result.add(name);
}
Expand Down
320 changes: 0 additions & 320 deletions dist/assets/index-BAqMCFqg.js

This file was deleted.

320 changes: 320 additions & 0 deletions dist/assets/index-DxQSKill.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2420,7 +2420,7 @@
.pm-cards { padding: 0 16px; }
}
</style>
<script type="module" crossorigin src="/assets/index-BAqMCFqg.js"></script>
<script type="module" crossorigin src="/assets/index-DxQSKill.js"></script>
</head>
<body>
<div class="app-shell">
Expand Down Expand Up @@ -3116,7 +3116,7 @@ <h2 style="font-size:18px;font-weight:600;color:#131820;margin-bottom:4px;">Sign
</div>
<input id="signin-email-input" type="email" placeholder="you@example.com" class="api-key-input mb-3" style="font-family:'Delight',sans-serif;font-size:14px;padding:11px 14px;" onkeydown="if(event.key==='Enter') handleMagicLinkRequest()" />
<button id="signin-submit-btn" onclick="handleMagicLinkRequest()" class="btn-action btn-run" style="font-size:14px;">Send Sign-in Link</button>
<p id="signin-message" class="text-sm text-gray-400 mt-3 text-center min-h-[1.25rem]"></p>
<p id="signin-message" role="status" class="text-sm text-gray-400 mt-3 text-center min-h-[1.25rem]"></p>
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3115,7 +3115,7 @@ <h2 style="font-size:18px;font-weight:600;color:#131820;margin-bottom:4px;">Sign
</div>
<input id="signin-email-input" type="email" placeholder="you@example.com" class="api-key-input mb-3" style="font-family:'Delight',sans-serif;font-size:14px;padding:11px 14px;" onkeydown="if(event.key==='Enter') handleMagicLinkRequest()" />
<button id="signin-submit-btn" onclick="handleMagicLinkRequest()" class="btn-action btn-run" style="font-size:14px;">Send Sign-in Link</button>
<p id="signin-message" class="text-sm text-gray-400 mt-3 text-center min-h-[1.25rem]"></p>
<p id="signin-message" role="status" class="text-sm text-gray-400 mt-3 text-center min-h-[1.25rem]"></p>
</div>
</div>
</div>
Expand Down
Loading
Loading