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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@ All notable changes to the InsForge Dart/Flutter SDK are documented here. This
project follows [Semantic Versioning](https://semver.org) and
[Keep a Changelog](https://keepachangelog.com).

## 0.2.0

Sync with [InsForge-sdk-js v1.5.1](https://github.com/InsForge/InsForge-sdk-js/releases/tag/v1.5.1).

### Added

- **Auth — passwordless email OTP sign-in** (`insforge`):
- `AuthClient.signInWithOtp(email: ...)` — requests a 6-digit sign-in code
via `POST /api/auth/email/send-otp`. The server response is intentionally
generic whether or not an account exists (enumeration-safe).
- `AuthClient.verifyOtp(email: ..., otp: ..., name: ...)` — verifies the
code via `POST /api/auth/sessions` (`method: 'otp'`) and establishes,
persists, and emits a session like every other token-issuing flow. `name`
sets the display name only when a new user is created.
- **Storage** — `DeleteObjectResult` model (`key`,
`status: deleted | notFound | failed`, optional `message`).

### Changed

- **Storage** — `StorageFileApi.deleteAll(paths)` now issues a single batch
request (`DELETE /api/storage/buckets/{bucket}/objects` with `{keys}`,
maximum 1000 keys) instead of one DELETE per path, and returns
`List<DeleteObjectResult>` (one result per key) instead of `void`.

## 0.1.0

Initial release.
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ if (!res.hasSession) {
}
await client.auth.signIn(email: email, password: password);

// Passwordless email OTP sign-in
await client.auth.signInWithOtp(email: email); // send a 6-digit code
await client.auth.verifyOtp(email: email, otp: '123456'); // establishes a session

// React to auth changes
client.auth.onAuthStateChange.listen((AuthState s) => print(s.event));

Expand Down Expand Up @@ -129,6 +133,12 @@ final stored = await client.storage
final url = client.storage.from('avatars').getPublicUrl(stored.key);
final files = await client.storage.from('avatars').list(prefix: 'users/');
final data = await client.storage.from('avatars').download('users/me.png');
await client.storage.from('avatars').delete('users/me.png');
// Batch delete (max 1000 keys, one result per key)
final results = await client.storage
.from('avatars')
.deleteAll(<String>['a.png', 'b.png']);
// results: [DeleteObjectResult(key, status: deleted|notFound|failed, message?)]
```

### Functions — edge functions
Expand Down
2 changes: 1 addition & 1 deletion integration_tests/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ environment:

dependencies:
dio: ^5.7.0
insforge: ^0.1.0
insforge: ^0.2.0

dev_dependencies:
lints: ^4.0.0
Expand Down
10 changes: 10 additions & 0 deletions packages/insforge/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## 0.2.0

Sync with InsForge-sdk-js v1.5.1.

- Auth: passwordless email OTP sign-in — `signInWithOtp` (send a 6-digit code,
enumeration-safe) and `verifyOtp` (verify the code and establish a session).
- Storage: `deleteAll` now uses the batch-delete endpoint (one request, max
1000 keys) and returns a `List<DeleteObjectResult>` with a per-key
`deleted | notFound | failed` status. New `DeleteObjectResult` model.

## 0.1.0

Initial release.
42 changes: 42 additions & 0 deletions packages/insforge/lib/src/auth/auth_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,48 @@ class AuthClient {
return response;
}

// ---------------------------------------------------------------------------
// Email OTP sign-in (passwordless)
// ---------------------------------------------------------------------------

/// Sends a one-time 6-digit sign-in code to [email].
///
/// The server response is intentionally generic whether or not an account
/// exists, to avoid account enumeration. Complete the flow with [verifyOtp].
Future<void> signInWithOtp({required String email}) async {
await _http.request<dynamic>(
'POST',
'/api/auth/email/send-otp',
data: <String, dynamic>{'email': email},
);
}

/// Verifies an email sign-in [otp] (from [signInWithOtp]) and establishes a
/// session. Persists and emits on success.
///
/// If the email is new, a verified passwordless user is created; [name] sets
/// the display name only on that first-time creation.
Future<AuthResponse> verifyOtp({
required String email,
required String otp,
String? name,
}) async {
final res = await _http.request<Map<String, dynamic>>(
'POST',
'/api/auth/sessions',
data: <String, dynamic>{
'method': 'otp',
'email': email,
'otp': otp,
if (name != null) 'name': name,
},
queryParameters: _clientTypeQuery,
);
final response = AuthResponse.fromJson(res.data!);
await _applySession(response.toSession(), AuthChangeEvent.signedIn);
return response;
}

// ---------------------------------------------------------------------------
// Password reset
// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion packages/insforge/lib/src/core/version.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// both packages in lockstep) with: dart run tool/set_version.dart <version>

/// The current InsForge Dart SDK version (mirrors `pubspec.yaml`).
const String insforgeSdkVersion = '0.1.0';
const String insforgeSdkVersion = '0.2.0';

/// `User-Agent` sent on every request from this SDK: `InsForge-Dart/<version>`.
const String insforgeUserAgent = 'InsForge-Dart/$insforgeSdkVersion';
38 changes: 38 additions & 0 deletions packages/insforge/lib/src/storage/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,44 @@ class DownloadStrategy {
};
}

/// Per-key outcome of a batch delete ([StorageFileApi.deleteAll]).
///
/// [status] is `deleted`, `notFound`, or `failed`; [message] carries the
/// server's explanation when the delete failed.
class DeleteObjectResult {
const DeleteObjectResult({
required this.key,
required this.status,
this.message,
});

/// The object key this result refers to.
final String key;

/// `deleted`, `notFound`, or `failed`.
final String status;

/// Server-provided detail, typically present when [status] is `failed`.
final String? message;

/// Whether the object was actually deleted.
bool get deleted => status == 'deleted';

factory DeleteObjectResult.fromJson(Map<String, dynamic> json) {
return DeleteObjectResult(
key: (json['key'] ?? '').toString(),
status: (json['status'] ?? '').toString(),
message: json['message']?.toString(),
);
}

Map<String, dynamic> toJson() => <String, dynamic>{
'key': key,
'status': status,
if (message != null) 'message': message,
};
}

/// Options for an upload: an explicit [contentType] (otherwise inferred from
/// the filename extension), whether to [upsert] over an existing object, and
/// optional [metadata].
Expand Down
26 changes: 22 additions & 4 deletions packages/insforge/lib/src/storage/storage_file_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,29 @@ class StorageFileApi {
await _http.request<dynamic>('DELETE', '$_bucketPath/objects/$path');
}

/// Deletes every object in [paths] (the API has no batch-delete endpoint).
Future<void> deleteAll(List<String> paths) async {
for (final path in paths) {
await delete(path);
/// Deletes every object in [paths] in a single batch request.
///
/// The endpoint accepts at most 1000 keys per call and returns one
/// [DeleteObjectResult] per key (`deleted`, `notFound`, or `failed`); the
/// list is not split client-side.
Future<List<DeleteObjectResult>> deleteAll(List<String> paths) async {
final response = await _http.request<dynamic>(
'DELETE',
'$_bucketPath/objects',
data: <String, dynamic>{'keys': paths},
);
final data = response.data;
final raw = data is Map<String, dynamic> ? data['results'] : data;
if (raw is List) {
return raw
.whereType<Map<dynamic, dynamic>>()
.map(
(Map<dynamic, dynamic> e) =>
DeleteObjectResult.fromJson(Map<String, dynamic>.from(e)),
)
.toList();
}
return <DeleteObjectResult>[];
}

/// Builds the public download URL for [path] from the configured base URL.
Expand Down
2 changes: 1 addition & 1 deletion packages/insforge/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# packages/insforge/pubspec.yaml
name: insforge
description: Pure-Dart SDK for InsForge — auth, database, storage, functions, and AI.
version: 0.1.0
version: 0.2.0
repository: https://github.com/InsForge/insforge-flutter
homepage: https://insforge.dev
resolution: workspace
Expand Down
127 changes: 127 additions & 0 deletions packages/insforge/test/auth/auth_client_otp_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// packages/insforge_auth/test/auth_client_otp_test.dart
import 'package:http_mock_adapter/http_mock_adapter.dart';
import 'package:insforge/insforge.dart';
import 'package:test/test.dart';

void main() {
late InsforgeHttpClient http;
late DioAdapter adapter;
late InMemorySessionStorage storage;
late AuthClient auth;

setUp(() {
http = InsforgeHttpClient(
baseUrl: 'https://x.insforge.app',
anonKey: 'anon',
);
adapter = DioAdapter(dio: http.dio);
storage = InMemorySessionStorage();
auth = AuthClient(http, storage);
});

test('signInWithOtp posts the email to the send-otp endpoint', () async {
adapter.onPost(
'/api/auth/email/send-otp',
(server) => server.reply(202, <String, dynamic>{
'success': true,
'message':
'If sign-in is available for this email, we have sent a '
'verification code.',
}),
data: <String, dynamic>{'email': 'a@b.com'},
);

await auth.signInWithOtp(email: 'a@b.com');

// Requesting a code must not touch the session.
expect(auth.currentSession, isNull);
expect(http.accessToken, isNull);
});

test('verifyOtp posts method "otp", establishes and persists a session',
() async {
adapter.onPost(
'/api/auth/sessions',
(server) => server.reply(200, <String, dynamic>{
'user': <String, dynamic>{
'id': 'u-1',
'email': 'a@b.com',
'emailVerified': true,
},
'accessToken': 'access-1',
'refreshToken': 'refresh-1',
}),
data: <String, dynamic>{
'method': 'otp',
'email': 'a@b.com',
'otp': '123456',
'name': 'Ada Lovelace',
},
queryParameters: <String, dynamic>{'client_type': 'mobile'},
);

final states = <AuthState>[];
final sub = auth.onAuthStateChange.listen(states.add);

final response = await auth.verifyOtp(
email: 'a@b.com',
otp: '123456',
name: 'Ada Lovelace',
);

expect(response.accessToken, 'access-1');
expect(response.user.id, 'u-1');

// Session applied and persisted like every other token-issuing flow.
expect(http.accessToken, 'access-1');
expect(await storage.read('insforge_access_token'), 'access-1');
expect(await storage.read('insforge_refresh_token'), 'refresh-1');

await Future<void>.delayed(Duration.zero);
expect(states.single.event, AuthChangeEvent.signedIn);

await sub.cancel();
});

test('verifyOtp omits name when not provided', () async {
adapter.onPost(
'/api/auth/sessions',
(server) => server.reply(200, <String, dynamic>{
'user': <String, dynamic>{'id': 'u-1', 'email': 'a@b.com'},
'accessToken': 'access-1',
'refreshToken': 'refresh-1',
}),
data: <String, dynamic>{
'method': 'otp',
'email': 'a@b.com',
'otp': '123456',
},
queryParameters: <String, dynamic>{'client_type': 'mobile'},
);

final response = await auth.verifyOtp(email: 'a@b.com', otp: '123456');
expect(response.accessToken, 'access-1');
});

test('verifyOtp throws InsforgeHttpException on an invalid code', () async {
adapter.onPost(
'/api/auth/sessions',
(server) => server.reply(401, <String, dynamic>{
'error': 'AUTH_INVALID_CREDENTIALS',
'message': 'Invalid or expired verification code',
'statusCode': 401,
}),
data: Matchers.any,
queryParameters: <String, dynamic>{'client_type': 'mobile'},
);

expect(
() => auth.verifyOtp(email: 'a@b.com', otp: '000000'),
throwsA(
isA<InsforgeHttpException>()
.having((e) => e.statusCode, 'statusCode', 401),
),
);
expect(auth.currentSession, isNull);
});
}
Loading
Loading