Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Combining API KEY authorization "read" and "listen" access doesn't work #2770

Open
3 tasks done
gpavlov2016 opened this issue Apr 5, 2024 · 10 comments
Open
3 tasks done
Assignees
Labels
auth Related to the Auth category/plugins bug Something isn't working transferred This issue was transferred from another Amplify project

Comments

@gpavlov2016
Copy link

Before opening, please confirm:

JavaScript Framework

Next.js

Amplify APIs

Authentication, GraphQL API

Amplify Version

v6

Amplify Categories

auth

Backend

Amplify Gen 2 (Preview)

Environment information

# Put output below this line
System:
    OS: Windows 11 10.0.22631
    CPU: (20) x64 13th Gen Intel(R) Core(TM) i9-13900H
    Memory: 3.07 GB / 31.68 GB
  Binaries:
    Node: 18.19.1 - C:\Program Files\nodejs\node.EXE
    Yarn: 1.22.19 - ~\AppData\Roaming\npm\yarn.CMD
    npm: 10.2.4 - C:\Program Files\nodejs\npm.CMD
    pnpm: 8.15.4 - ~\AppData\Local\pnpm\pnpm.EXE
  Browsers:
    Edge: Chromium (122.0.2365.92)
    Internet Explorer: 11.0.22621.1
  npmPackages:
    %name%:  0.1.0
    @ampproject/toolbox-optimizer:  undefined ()
    @aws-amplify/backend: ^0.13.0-beta.13 => 0.13.0-beta.13
    @aws-amplify/backend-cli: ^0.12.0-beta.15 => 0.12.0-beta.15
    @aws-amplify/ui-react: ^6.1.6 => 6.1.6
    @aws-amplify/ui-react-internal:  undefined ()
    @aws-sdk/client-s3: ^3.540.0 => 3.540.0
    @aws-sdk/s3-presigned-post: ^3.540.0 => 3.540.0
    @aws-sdk/signature-v4-crt: ^3.535.0 => 3.535.0
    @babel/core:  undefined ()
    @babel/runtime:  7.22.5
    @edge-runtime/cookies:  4.1.0
    @edge-runtime/ponyfill:  2.4.2
    @edge-runtime/primitives:  4.1.0
    @hapi/accept:  undefined ()
    @heroicons/react: ^1.0.6 => 1.0.6
    @mswjs/interceptors:  undefined ()
    @napi-rs/triples:  undefined ()
    @next/font:  undefined ()
    @next/react-dev-overlay:  undefined ()
    @opentelemetry/api:  undefined ()
    @types/node: ^20 => 20.11.30
    @types/react: ^18 => 18.2.73
    @types/react-dom: ^18 => 18.2.23
    @vercel/nft:  undefined ()
    @vercel/og:  0.6.2
    acorn:  undefined ()
    amphtml-validator:  undefined ()
    anser:  undefined ()
    arg:  undefined ()
    assert:  undefined ()
    async-retry:  undefined ()
    async-sema:  undefined ()
    autoprefixer: ^10.4.19 => 10.4.19
    aws-amplify: ^6.0.24 => 6.0.24
    aws-amplify/adapter-core:  undefined ()
    aws-amplify/analytics:  undefined ()
    aws-amplify/analytics/kinesis:  undefined ()
    aws-amplify/analytics/kinesis-firehose:  undefined ()
    aws-amplify/analytics/personalize:  undefined ()
    aws-amplify/analytics/pinpoint:  undefined ()
    aws-amplify/api:  undefined ()
    aws-amplify/api/server:  undefined ()
    aws-amplify/auth:  undefined ()
    aws-amplify/auth/cognito:  undefined ()
    aws-amplify/auth/cognito/server:  undefined ()
    aws-amplify/auth/enable-oauth-listener:  undefined ()
    aws-amplify/auth/server:  undefined ()
    aws-amplify/data:  undefined ()
    aws-amplify/data/server:  undefined ()
    aws-amplify/datastore:  undefined ()
    aws-amplify/in-app-messaging:  undefined ()
    aws-amplify/in-app-messaging/pinpoint:  undefined ()
    aws-amplify/push-notifications:  undefined ()
    aws-amplify/push-notifications/pinpoint:  undefined ()
    aws-amplify/storage:  undefined ()
    aws-amplify/storage/s3:  undefined ()
    aws-amplify/storage/s3/server:  undefined ()
    aws-amplify/storage/server:  undefined ()
    aws-amplify/utils:  undefined ()
    aws-cdk: ^2.134.0 => 2.134.0
    aws-cdk-lib: ^2.134.0 => 2.134.0
    babel-packages:  undefined ()
    browserify-zlib:  undefined ()
    browserslist:  undefined ()
    buffer:  undefined ()
    bytes:  undefined ()
    ci-info:  undefined ()
    cli-select:  undefined ()
    client-only:  0.0.1
    comment-json:  undefined ()
    compression:  undefined ()
    conf:  undefined ()
    constants-browserify:  undefined ()
    constructs: ^10.3.0 => 10.3.0
    content-disposition:  undefined ()
    content-type:  undefined ()
    cookie:  undefined ()
    cross-spawn:  undefined ()
    crypto-browserify:  undefined ()
    css.escape:  undefined ()
    data-uri-to-buffer:  undefined ()
    debug:  undefined ()
    devalue:  undefined ()
    domain-browser:  undefined ()
    edge-runtime:  undefined ()
    esbuild: ^0.20.2 => 0.20.2 (0.19.12)
    eslint: ^8 => 8.57.0
    eslint-config-next: 14.1.4 => 14.1.4
    events:  undefined ()
    find-cache-dir:  undefined ()
    find-up:  undefined ()
    fresh:  undefined ()
    get-orientation:  undefined ()
    glob:  undefined ()
    gzip-size:  undefined ()
    http-proxy:  undefined ()
    http-proxy-agent:  undefined ()
    https-browserify:  undefined ()
    https-proxy-agent:  undefined ()
    icss-utils:  undefined ()
    ignore-loader:  undefined ()
    image-size:  undefined ()
    is-animated:  undefined ()
    is-docker:  undefined ()
    is-wsl:  undefined ()
    jest-worker:  undefined ()
    json5:  undefined ()
    jsonwebtoken:  undefined ()
    loader-runner:  undefined ()
    loader-utils:  undefined ()
    lodash.curry:  undefined ()
    lru-cache:  undefined ()
    micromatch:  undefined ()
    mini-css-extract-plugin:  undefined ()
    nanoid:  undefined ()
    native-url:  undefined ()
    neo-async:  undefined ()
    next: 14.1.4 => 14.1.4
    node-fetch:  undefined ()
    node-html-parser:  undefined ()
    ora:  undefined ()
    os-browserify:  undefined ()
    p-limit:  undefined ()
    path-browserify:  undefined ()
    platform:  undefined ()
    postcss: ^8.4.38 => 8.4.38 (8.4.31)
    postcss-flexbugs-fixes:  undefined ()
    postcss-modules-extract-imports:  undefined ()
    postcss-modules-local-by-default:  undefined ()
    postcss-modules-scope:  undefined ()
    postcss-modules-values:  undefined ()
    postcss-preset-env:  undefined ()
    postcss-safe-parser:  undefined ()
    postcss-scss:  undefined ()
    postcss-value-parser:  undefined ()
    process:  undefined ()
    punycode:  undefined ()
    querystring-es3:  undefined ()
    raw-body:  undefined ()
    react: ^18 => 18.2.0
    react-builtin:  undefined ()
    react-dom: ^18 => 18.2.0
    react-dom-builtin:  undefined ()
    react-dom-experimental-builtin:  undefined ()
    react-experimental-builtin:  undefined ()
    react-is:  18.2.0
    react-refresh:  0.12.0
    react-server-dom-turbopack-builtin:  undefined ()
    react-server-dom-turbopack-experimental-builtin:  undefined ()
    react-server-dom-webpack-builtin:  undefined ()
    react-server-dom-webpack-experimental-builtin:  undefined ()
    regenerator-runtime:  0.13.4
    sass-loader:  undefined ()
    scheduler-builtin:  undefined ()
    scheduler-experimental-builtin:  undefined ()
    schema-utils:  undefined ()
    semver:  undefined ()
    send:  undefined ()
    server-only:  0.0.1
    setimmediate:  undefined ()
    shell-quote:  undefined ()
    source-map:  undefined ()
    stacktrace-parser:  undefined ()
    stream-browserify:  undefined ()
    stream-http:  undefined ()
    string-hash:  undefined ()
    string_decoder:  undefined ()
    strip-ansi:  undefined ()
    superstruct:  undefined ()
    tailwindcss: ^3.4.3 => 3.4.3
    tar:  undefined ()
    terser:  undefined ()
    text-table:  undefined ()
    timers-browserify:  undefined ()
    tsx: ^4.7.1 => 4.7.1
    tty-browserify:  undefined ()
    typescript: ^5.4.3 => 5.4.3 (4.4.4, 4.9.5)
    ua-parser-js:  undefined ()
    unistore:  undefined ()
    util:  undefined ()
    vm-browserify:  undefined ()
    watchpack:  undefined ()
    web-vitals:  undefined ()
    webpack:  undefined ()
    webpack-sources:  undefined ()
    ws:  undefined ()
    zod:  undefined ()
  npmGlobalPackages:
    @aws-amplify/cli: 12.10.3
    corepack: 0.22.0
    npm: 10.2.4

Describe the bug

Combining both read and listen permissions doesn't work.

const schema = a.schema({
  Video: a
    .model({
    
    })
    .authorization([
      a.allow.public().to(['read', 'listen']),
      a.allow.owner()
    ]),
});

Either listen or read on their own do work

Expected behavior

After saving the file with sandbox running the model is deployed

Reproduction steps

  1. Create a basic model (from sample app) and add authorization a.allow.public().to(['read', 'listen']) to amplify
    data\resource.ts
  2. npx amplify sandbox

Code Snippet

// Put your code below this line.
const schema = a.schema({
  Video: a
    .model({
      title: a.string()
    })
    .authorization([
      a.allow.public().to(['read', 'listen']),
      a.allow.owner()
    ]),
});

export type Schema = ClientSchema<typeof schema>;

export const data = defineData({
  schema,
  authorizationModes: {
    defaultAuthorizationMode: 'userPool',
    apiKeyAuthorizationMode: {}
  },
});

Log output

// Put your logs below this line
Failed to instantiate data construct

aws-exports.js

No response

Manual configuration

No response

Additional configuration

No response

Mobile Device

No response

Mobile Operating System

No response

Mobile Browser

No response

Mobile Browser Version

No response

Additional information and screenshots

No response

@gpavlov2016 gpavlov2016 added the pending-triage Issue is pending triage label Apr 5, 2024
@chrisbonifacio
Copy link

Hi @gpavlov2016 👋 it seems that this may be intended behavior. When I try to deploy the same schema, I get this error:

InvalidDirectiveError: 'listen' operations are specified in addition to 'read'. Either remove 'read' to limit access only to 'listen' or only keep 'read' to grant all get,list,search,listen,sync access.

read encompasses listen/subscribe permissions. Can you let us know what your use case is?
For example, do you want the user to be able to subscribe without being able to perform list queries?

@chrisbonifacio chrisbonifacio added question General question pending-response Issue is pending response from the issue requestor and removed pending-triage Issue is pending triage labels Apr 8, 2024
@gpavlov2016
Copy link
Author

Thanks for looking into this @chrisbonifacio. My scenario is:

  • React client that uses Cognito authentication with owner authorization to allow all operations on users' own data
  • Android client with API Key auth with read access only to all data (from all users). Read access includes queries and subscriptions.

When I try with read access only for public auth, the queries on Android work but the subscriptions fail.
I did some digging into the Android amplify library and it seems like it is related to this comment

@github-actions github-actions bot removed the pending-response Issue is pending response from the issue requestor label Apr 8, 2024
@renebrandel
Copy link

hi @gpavlov2016 the read operation includes listen access. So the listen operation in the list here is a no-op. So this might be a red herring.

Another common reason we see why subscription "come across as failing" is because the GraphQL selection set of the mutation must include all the fields that the subscriber is looking for. Can you share the code where you trigger the mutation and the Android code where you listen to the subscription?

@gpavlov2016
Copy link
Author

The subscription fails at the authentication stage before I even try to do any mutation, and based on the logs it's trying to use Cognito for authentication. Query operation with the same code succeeds.
Here is how the model is defined in the React client:

const schema = a.schema({
  Video: a
    .model({
      title: a.string(),
      timeOfDayStart: a.time(),
      timeOfDayEnd: a.time(),
      dateStart: a.date(),
      dateEnd: a.date(),
      impressionsTarget: a.integer(),
      zipCode: a.string(),
      s3Key: a.string(),
      thumbnail: a.string(),
      isRunning: a.boolean(),
    })
    .authorization([
      a.allow.public().to(['read']),
      a.allow.owner().to(['create', 'read', 'update', 'delete']),
    ]),
});

export type Schema = ClientSchema<typeof schema>;

export const data = defineData({
  schema,
  authorizationModes: {
    defaultAuthorizationMode: 'userPool',
    apiKeyAuthorizationMode: {}
  },
});

And here is how the model is defined in the Android client (autogenerated from using amplify config file).

@ModelConfig(pluralName = "Videos", type = Model.Type.USER, version = 1, authRules = {
  @AuthRule(allow = AuthStrategy.PUBLIC, operations = { ModelOperation.READ }),
  @AuthRule(allow = AuthStrategy.OWNER, ownerField = "owner", identityClaim = "cognito:username", provider = "userPools", operations = { ModelOperation.CREATE, ModelOperation.READ, ModelOperation.UPDATE, ModelOperation.DELETE })
}, hasLazySupport = true)
public final class Video implements Model {
...
}

And this is the code that calls the subscribe method:

val onCreateSubscription = Amplify.API.subscribe(
            ModelSubscription.onCreate(Video::class.java),
            { Log.i("ApiQuickStart", "Subscription established - onCreate") },
            {
                Log.i("ApiQuickStart", "Video create subscription received: ${(it.data as Video).title}")
                addVideoToPlaylist(it.data)
            },
            { Log.e("ApiQuickStart", "Subscription failed - onCreate", it) },
            { Log.i("ApiQuickStart", "Subscription completed - onCreate") }
        )

For reference, this is the query call that works with the same settings:

Amplify.API.query(
            ModelQuery.list(Video::class.java, Video.IS_RUNNING.eq(true)),
            { response ->
                val page = response.data
                initVideoUris(page.items.toList())
                Log.d("refreshItems", page.toString())
                Log.i("MyAmplifyApp", "Queried items: $page")
            },
            { Log.e("MyAmplifyApp", "Query failure", it) }
        )

Error message from logcat:

Subscription failed - onCreate
ApiAuthException{message=Token is null, cause=null, recoverySuggestion=Sorry, we don't have a suggested fix for this error yet.}
at com.amplifyframework.api.aws.sigv4.DefaultCognitoUserPoolsAuthProvider.fetchToken(DefaultCognitoUserPoolsAuthProvider.java:81)
at com.amplifyframework.api.aws.sigv4.DefaultCognitoUserPoolsAuthProvider.getLatestAuthToken(DefaultCognitoUserPoolsAuthProvider.java:87)
at com.amplifyframework.api.aws.auth.AuthRuleRequestDecorator.getAuthToken(AuthRuleRequestDecorator.java:226)
at com.amplifyframework.api.aws.auth.AuthRuleRequestDecorator.getIdentityValue(AuthRuleRequestDecorator.java:152)
at com.amplifyframework.api.aws.auth.AuthRuleRequestDecorator.decorate(AuthRuleRequestDecorator.java:121)
at com.amplifyframework.api.aws.AWSApiPlugin.buildSubscriptionOperation(AWSApiPlugin.java:636)
at com.amplifyframework.api.aws.AWSApiPlugin.subscribe(AWSApiPlugin.java:315)
at com.amplifyframework.api.aws.AWSApiPlugin.subscribe(AWSApiPlugin.java:295)
at com.amplifyframework.api.ApiCategory.subscribe(ApiCategory.java:91)
at com.example.androidamplifygen2.MainActivity.subscribe(MainActivity.kt:208)
at com.example.androidamplifygen2.MainActivity.onCreate(MainActivity.kt:106)
at android.app.Activity.performCreate(Activity.java:8000)
at android.app.Activity.performCreate(Activity.java:7984)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1309)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3422)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:223)
at android.app.ActivityThread.main(ActivityThread.java:7656)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
2024-04-08 18:24:07.756 6648-6700 MyAmplifyApp com.example.androidamplifygen2 E Query failure
ApiException{message=OkHttp client failed to make a successful request., cause=ApiAuthException{message=Failed to retrieve auth token from Cognito provider., cause=ApiAuthException{message=Token is null, cause=null, recoverySuggestion=Sorry, we don't have a suggested fix for this error yet.}, recoverySuggestion=Check the application logs for details.}, recoverySuggestion=Sorry, we don't have a suggested fix for this error yet.}
at com.amplifyframework.api.aws.AppSyncGraphQLOperation.dispatchRequest(AppSyncGraphQLOperation.java:109)
at com.amplifyframework.api.aws.AppSyncGraphQLOperation.$r8$lambda$s0tPt9Vu7puSi2-I-7S0nxLOkUY(Unknown Source:0)
at com.amplifyframework.api.aws.AppSyncGraphQLOperation$$ExternalSyntheticLambda0.run(D8$$SyntheticClass:0)
at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:462)
at java.util.concurrent.FutureTask.run(FutureTask.java:266)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
at java.lang.Thread.run(Thread.java:923)
Caused by: ApiAuthException{message=Failed to retrieve auth token from Cognito provider., cause=ApiAuthException{message=Token is null, cause=null, recoverySuggestion=Sorry, we don't have a suggested fix for this error yet.}, recoverySuggestion=Check the application logs for details.}
at com.amplifyframework.api.aws.auth.ApiRequestDecoratorFactory.forAuthType(ApiRequestDecoratorFactory.java:127)
at com.amplifyframework.api.aws.auth.ApiRequestDecoratorFactory.fromGraphQLRequest(ApiRequestDecoratorFactory.java:100)
at com.amplifyframework.api.aws.AppSyncGraphQLOperation.dispatchRequest(AppSyncGraphQLOperation.java:93)
at com.amplifyframework.api.aws.AppSyncGraphQLOperation.$r8$lambda$s0tPt9Vu7puSi2-I-7S0nxLOkUY(Unknown Source:0) 
at com.amplifyframework.api.aws.AppSyncGraphQLOperation$$ExternalSyntheticLambda0.run(D8$$SyntheticClass:0) 
at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:462) 
at java.util.concurrent.FutureTask.run(FutureTask.java:266) 
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167) 
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641) 
at java.lang.Thread.run(Thread.java:923) 
Caused by: ApiAuthException{message=Token is null, cause=null, recoverySuggestion=Sorry, we don't have a suggested fix for this error yet.}
at com.amplifyframework.api.aws.sigv4.DefaultCognitoUserPoolsAuthProvider.fetchToken(DefaultCognitoUserPoolsAuthProvider.java:81)
at com.amplifyframework.api.aws.sigv4.DefaultCognitoUserPoolsAuthProvider.getLatestAuthToken(DefaultCognitoUserPoolsAuthProvider.java:87)
at com.amplifyframework.api.aws.auth.ApiRequestDecoratorFactory.forAuthType(ApiRequestDecoratorFactory.java:125)
at com.amplifyframework.api.aws.auth.ApiRequestDecoratorFactory.fromGraphQLRequest(ApiRequestDecoratorFactory.java:100) 
at com.amplifyframework.api.aws.AppSyncGraphQLOperation.dispatchRequest(AppSyncGraphQLOperation.java:93) 
at com.amplifyframework.api.aws.AppSyncGraphQLOperation.$r8$lambda$s0tPt9Vu7puSi2-I-7S0nxLOkUY(Unknown Source:0) 
at com.amplifyframework.api.aws.AppSyncGraphQLOperation$$ExternalSyntheticLambda0.run(D8$$SyntheticClass:0) 
at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:462) 
at java.util.concurrent.FutureTask.run(FutureTask.java:266) 
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167) 
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641) 
at java.lang.Thread.run(Thread.java:923) 

@chrisbonifacio chrisbonifacio transferred this issue from aws-amplify/amplify-js Apr 15, 2024
@github-actions github-actions bot added the pending-triage Issue is pending triage label Apr 15, 2024
@chrisbonifacio
Copy link

Hi @gpavlov2016 because the issue seems to be with auth in the android sdk, I am transferring this issue over to the amplify/android repo for better support.

@chrisbonifacio chrisbonifacio added the transferred This issue was transferred from another Amplify project label Apr 15, 2024
@joon-won
Copy link
Contributor

Hi @gpavlov2016, this is amplify android, our team will take a look into the issue

@joon-won joon-won added bug Something isn't working auth Related to the Auth category/plugins and removed question General question labels Apr 15, 2024
@github-actions github-actions bot removed the pending-triage Issue is pending triage label Apr 15, 2024
@mattcreaser
Copy link
Contributor

Hey @gpavlov2016 thanks for your patience. I'll take a look into this.

@gpavlov2016
Copy link
Author

Thanks @mattcreaser ! The title is probably need to change since it's not about the access permissions in amplify-js but about support of api key auth in android based on the triage of the js team

@mattcreaser
Copy link
Contributor

Definitely looks like the issue would be in the Multi-auth subscription operation, but we will know more after we investigate.

@mattcreaser
Copy link
Contributor

Hi @gpavlov2016. I've been looking into this issue and have some updates to share.

The reason Amplify is trying to use the user pool is because that is the default authorization mode for your API. Normally you would get around this by choosing a new auth mode for your request. The current way to do this for Gen2 is using this builder API (we'll be adding a more convenient way to set this soon):

    val request = ModelSubscription.onCreate(Video::class.java) as AppSyncGraphQLRequest
    val apiKeyRequest = request.newBuilder()
        .authorizationType(AuthorizationType.API_KEY)
        .build<Video>()

   val onCreateSubscription = Amplify.API.subscribe(apiKeyRequest, ...)

However, while testing this out I actually found a bug in Amplify's handling of multiple auth rules for subscriptions, so the above is not working as expected. I'll work on a fix for that, but in the meantime you can actually get the desired behaviour by requesting a multi-auth subscription:

    val request = ModelSubscription.onCreate(Video::class.java) as AppSyncGraphQLRequest
    val multiAuthRequest = request.newBuilder()
        .requestAuthorizationStrategyType(AuthModeStrategyType.MULTIAUTH)
        .build<Video>()

  val onCreateSubscription - Amplify.API.subscribe(multiAuthRequest, ...)

That should allow you to subscribe to the video model without logging in. I'll update this issue again once the fix to directly use API_KEY in this situation becomes available.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
auth Related to the Auth category/plugins bug Something isn't working transferred This issue was transferred from another Amplify project
Projects
None yet
Development

No branches or pull requests

5 participants