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
15 changes: 14 additions & 1 deletion cmd/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ var (
},
}

authAccessMode = utils.EnumFlag{
Allowed: []string{
string(new_.AuthAccessModeAlways),
string(new_.AuthAccessModeApiKey),
string(new_.AuthAccessModeUser),
},
}
functionsNewCmd = &cobra.Command{
Use: "new <Function name>",
Short: "Create a new Function locally",
Expand All @@ -94,7 +101,12 @@ var (
return cmd.Root().PersistentPreRunE(cmd, args)
},
RunE: func(cmd *cobra.Command, args []string) error {
return new_.Run(cmd.Context(), args[0], afero.NewOsFs())
authMode := new_.AuthAccessModeAlways
if len(authAccessMode.Value) > 0 {
authMode = new_.AuthAccessMode(authAccessMode.Value)
}

return new_.Run(cmd.Context(), args[0], authMode, afero.NewOsFs())
},
}

Expand Down Expand Up @@ -168,6 +180,7 @@ func init() {
functionsDownloadCmd.MarkFlagsMutuallyExclusive("use-api", "use-docker", "legacy-bundle")
cobra.CheckErr(downloadFlags.MarkHidden("legacy-bundle"))
cobra.CheckErr(downloadFlags.MarkHidden("use-docker"))
functionsNewCmd.Flags().Var(&authAccessMode, "auth", "use a specific auth access (default always)")
functionsCmd.AddCommand(functionsListCmd)
functionsCmd.AddCommand(functionsDeleteCmd)
functionsCmd.AddCommand(functionsDeployCmd)
Expand Down
65 changes: 48 additions & 17 deletions internal/functions/new/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,49 @@ import (
"github.com/supabase/cli/internal/utils/flags"
)

type AuthAccessMode string

const (
AuthAccessModeAlways AuthAccessMode = "always"
AuthAccessModeApiKey AuthAccessMode = "apikey"
AuthAccessModeUser AuthAccessMode = "user"
)

var (
//go:embed templates/index.ts
indexEmbed string
//go:embed templates/index_always_access.ts
indexAuthAlwaysEmbed string
//go:embed templates/index_apikey_access.ts
indexAuthApiKeyEmbed string
//go:embed templates/index_user_access.ts
indexAuthUserEmbed string

//go:embed templates/deno.json
denoEmbed string
//go:embed templates/.npmrc
npmrcEmbed string
//go:embed templates/config.toml
configEmbed string

indexTemplate = template.Must(template.New("index").Parse(indexEmbed))
indexAuthTemplates = map[AuthAccessMode]*template.Template{
AuthAccessModeAlways: template.Must(template.New("index").Parse(indexAuthAlwaysEmbed)),
AuthAccessModeApiKey: template.Must(template.New("index").Parse(indexAuthApiKeyEmbed)),
AuthAccessModeUser: template.Must(template.New("index").Parse(indexAuthUserEmbed)),
}

configTemplate = template.Must(template.New("config").Parse(configEmbed))
)

type indexConfig struct {
URL string
Token string
URL string
PublishableKey string
}

type functionConfig struct {
Slug string
VerifyJWT bool
}

func Run(ctx context.Context, slug string, fsys afero.Fs) error {
func Run(ctx context.Context, slug string, authMode AuthAccessMode, fsys afero.Fs) error {
// 1. Sanity checks.
if err := utils.ValidateFunctionSlug(slug); err != nil {
return err
Expand All @@ -56,17 +79,18 @@ func Run(ctx context.Context, slug string, fsys afero.Fs) error {
if err := flags.LoadConfig(fsys); err != nil {
fmt.Fprintln(utils.GetDebugLogger(), err)
}
if err := createEntrypointFile(slug, fsys); err != nil {
if err := createEntrypointFile(slug, authMode, fsys); err != nil {
return err
}
if err := appendConfigFile(slug, fsys); err != nil {
verifyJWT := authMode == AuthAccessModeUser
if err := appendConfigFile(slug, verifyJWT, fsys); err != nil {
return err
}
// 3. Create optional files
if err := afero.WriteFile(fsys, filepath.Join(funcDir, "deno.json"), []byte(denoEmbed), 0644); err != nil {
if err := afero.WriteFile(fsys, filepath.Join(funcDir, "deno.json"), []byte(denoEmbed), 0o644); err != nil {
return errors.Errorf("failed to create deno.json config: %w", err)
}
if err := afero.WriteFile(fsys, filepath.Join(funcDir, ".npmrc"), []byte(npmrcEmbed), 0644); err != nil {
if err := afero.WriteFile(fsys, filepath.Join(funcDir, ".npmrc"), []byte(npmrcEmbed), 0o644); err != nil {
return errors.Errorf("failed to create .npmrc config: %w", err)
}
fmt.Println("Created new Function at " + utils.Bold(funcDir))
Expand All @@ -79,33 +103,40 @@ func Run(ctx context.Context, slug string, fsys afero.Fs) error {
return nil
}

func createEntrypointFile(slug string, fsys afero.Fs) error {
func createEntrypointFile(slug string, authMode AuthAccessMode, fsys afero.Fs) error {
entrypointPath := filepath.Join(utils.FunctionsDir, slug, "index.ts")
f, err := fsys.OpenFile(entrypointPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
f, err := fsys.OpenFile(entrypointPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return errors.Errorf("failed to create entrypoint: %w", err)
}
defer f.Close()
indexTemplate, hasTemplate := indexAuthTemplates[authMode]
if !hasTemplate {
return errors.Errorf("failed to write entrypoint: '%v' is not a valid template", authMode)
}
if err := indexTemplate.Option("missingkey=error").Execute(f, indexConfig{
URL: utils.GetApiUrl("/functions/v1/" + slug),
Token: utils.Config.Auth.AnonKey.Value,
URL: utils.GetApiUrl("/functions/v1/" + slug),
PublishableKey: utils.Config.Auth.PublishableKey.Value,
}); err != nil {
return errors.Errorf("failed to write entrypoint: %w", err)
}
return nil
}

func appendConfigFile(slug string, fsys afero.Fs) error {
func appendConfigFile(slug string, verifyJWT bool, fsys afero.Fs) error {
if _, exists := utils.Config.Functions[slug]; exists {
fmt.Fprintf(os.Stderr, "[functions.%s] is already declared in %s\n", slug, utils.Bold(utils.ConfigPath))
return nil
}
f, err := fsys.OpenFile(utils.ConfigPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
f, err := fsys.OpenFile(utils.ConfigPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
if err != nil {
return errors.Errorf("failed to append config: %w", err)
}
defer f.Close()
if err := configTemplate.Option("missingkey=error").Execute(f, slug); err != nil {
if err := configTemplate.Option("missingkey=error").Execute(f, functionConfig{
Slug: slug,
VerifyJWT: verifyJWT,
}); err != nil {
return errors.Errorf("failed to append template: %w", err)
}
return nil
Expand Down
47 changes: 41 additions & 6 deletions internal/functions/new/new_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package new

import (
"context"
"fmt"
"path/filepath"
"testing"

Expand All @@ -16,7 +17,7 @@ func TestNewCommand(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Run test
assert.NoError(t, Run(context.Background(), "test-func", fsys))
assert.NoError(t, Run(context.Background(), "test-func", AuthAccessModeAlways, fsys))
// Validate output
funcPath := filepath.Join(utils.FunctionsDir, "test-func", "index.ts")
content, err := afero.ReadFile(fsys, funcPath)
Expand All @@ -26,8 +27,11 @@ func TestNewCommand(t *testing.T) {
)

// Verify config.toml is updated
_, err = afero.ReadFile(fsys, utils.ConfigPath)
content, err = afero.ReadFile(fsys, utils.ConfigPath)
assert.NoError(t, err, "config.toml should be created")
assert.Contains(t, string(content), "[functions.test-func]")
// Always access mode should not verify jwt
assert.Contains(t, string(content), "verify_jwt = false")

// Verify deno.json exists
denoPath := filepath.Join(utils.FunctionsDir, "test-func", "deno.json")
Expand All @@ -40,23 +44,54 @@ func TestNewCommand(t *testing.T) {
assert.NoError(t, err, ".npmrc should be created")
})

t.Run("creates new function with apikey access", func(t *testing.T) {
fsys := afero.NewMemMapFs()
assert.NoError(t, Run(context.Background(), "test-func", AuthAccessModeApiKey, fsys))

// Validate output
funcPath := filepath.Join(utils.FunctionsDir, "test-func", "index.ts")
content, _ := afero.ReadFile(fsys, funcPath)
// Should contain the PublishableKey as example
assert.Contains(t, string(content), fmt.Sprintf("--header 'apiKey: %v'", utils.Config.Auth.PublishableKey.Value))

// Verify config.toml is updated to not verify jwt
content, _ = afero.ReadFile(fsys, utils.ConfigPath)
assert.Contains(t, string(content), "verify_jwt = false")
})

t.Run("creates new function with user access", func(t *testing.T) {
fsys := afero.NewMemMapFs()
assert.NoError(t, Run(context.Background(), "test-func", AuthAccessModeUser, fsys))

// Validate output
funcPath := filepath.Join(utils.FunctionsDir, "test-func", "index.ts")
content, _ := afero.ReadFile(fsys, funcPath)
// Should contain the PublishableKey as example as well placeholder for UserToken
assert.Contains(t, string(content), fmt.Sprintf("--header 'apiKey: %v'", utils.Config.Auth.PublishableKey.Value))
assert.Contains(t, string(content), "--header 'Authorization: Bearer <UserToken>'")

// Verify config.toml is updated and verify jwt enabled
content, _ = afero.ReadFile(fsys, utils.ConfigPath)
assert.Contains(t, string(content), "verify_jwt = true")
})

t.Run("throws error on malformed slug", func(t *testing.T) {
assert.Error(t, Run(context.Background(), "@", afero.NewMemMapFs()))
assert.Error(t, Run(context.Background(), "@", AuthAccessModeAlways, afero.NewMemMapFs()))
})

t.Run("throws error on duplicate slug", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
funcPath := filepath.Join(utils.FunctionsDir, "test-func", "index.ts")
require.NoError(t, afero.WriteFile(fsys, funcPath, []byte{}, 0644))
require.NoError(t, afero.WriteFile(fsys, funcPath, []byte{}, 0o644))
// Run test
assert.Error(t, Run(context.Background(), "test-func", fsys))
assert.Error(t, Run(context.Background(), "test-func", AuthAccessModeAlways, fsys))
})

t.Run("throws error on permission denied", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewReadOnlyFs(afero.NewMemMapFs())
// Run test
assert.Error(t, Run(context.Background(), "test-func", fsys))
assert.Error(t, Run(context.Background(), "test-func", AuthAccessModeAlways, fsys))
})
}
10 changes: 5 additions & 5 deletions internal/functions/new/templates/config.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@

[functions.{{ . }}]
[functions.{{ .Slug }}]
enabled = true
verify_jwt = true
import_map = "./functions/{{ . }}/deno.json"
verify_jwt = {{ .VerifyJWT }}
import_map = "./functions/{{ .Slug }}/deno.json"
# Uncomment to specify a custom file path to the entrypoint.
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
entrypoint = "./functions/{{ . }}/index.ts"
entrypoint = "./functions/{{ .Slug }}/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns.
# For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/{{ . }}/*.html" ]
# static_files = [ "./functions/{{ .Slug }}/*.html" ]
3 changes: 2 additions & 1 deletion internal/functions/new/templates/deno.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"imports": {
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
"@supabase/functions-js": "jsr:@supabase/functions-js@^2",
"@supabase/server": "npm:@supabase/server"
}
}
32 changes: 32 additions & 0 deletions internal/functions/new/templates/index_always_access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Follow this setup guide to integrate the Deno language server with your editor:
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.

// Setup type definitions for built-in Supabase Runtime APIs
import "@supabase/functions-js/edge-runtime.d.ts";
import { withSupabase } from "@supabase/server";

console.log("Hello from Functions!");

// This endpoint uses 'always' access, no credentials required, every request is accepted.
// Use it for health checks, public APIs, or when you handle auth yourself inside the handler.
export default {
fetch: withSupabase({ allow: "always" }, async (req, ctx) => {
const { name } = await req.json();

return Response.json({
message: `Hello ${name}!`,
});
}),
};

/* To invoke locally:

1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start)
2. Make an HTTP request:

curl -i --location --request POST '{{ .URL }}' \
--header 'Content-Type: application/json' \
--data '{"name":"Functions"}'

*/
46 changes: 46 additions & 0 deletions internal/functions/new/templates/index_apikey_access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Follow this setup guide to integrate the Deno language server with your editor:
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.

// Setup type definitions for built-in Supabase Runtime APIs
import "@supabase/functions-js/edge-runtime.d.ts";
import { withSupabase } from "@supabase/server";

console.log("Hello from Functions!");

// This endpoint uses 'public' | 'secret' access, apiKey is required.
// Use public for Client-facing, key-validated endpoints
// Use secret for Server-to-server, internal calls
export default {
fetch: withSupabase({ allow: ["public", "secret"] }, async (req, ctx) => {
// Called by another service with a secret key
// ctx.supabaseAdmin bypasses RLS — use for privileged operations
/*
if (ctx.authType === "secret") {
const { user_id } = await req.json();
const { data } = await ctx.supabaseAdmin.auth.admin.getUserById(user_id);

return Response.json({
email: data?.user?.email,
});
}
*/

const { name } = await req.json();

return Response.json({
message: `Hello ${name}!`,
});
}),
};

/* To invoke locally:

1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start)
2. Make an HTTP request:

curl -i --location --request POST '{{ .URL }}' \
--header 'apiKey: {{ .PublishableKey }}' \
--data '{"name":"Functions"}'

*/
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,27 @@

// Setup type definitions for built-in Supabase Runtime APIs
import "@supabase/functions-js/edge-runtime.d.ts"
import { withSupabase } from '@supabase/server'

console.log("Hello from Functions!")

Deno.serve(async (req) => {
const { name } = await req.json()
const data = {
message: `Hello ${name}!`,
}
// This endpoint uses 'user' access, credentials is required.
export default {
fetch: withSupabase({ allow: 'user' }, async (_req, ctx) => {
const email = ctx.userClaims?.email;

return new Response(
JSON.stringify(data),
{ headers: { "Content-Type": "application/json" } },
)
})
return Response.json({
message: `Hello ${email}!`,
})
}),
}

/* To invoke locally:

1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start)
2. Make an HTTP request:

curl -i --location --request POST '{{ .URL }}' \
--header 'Authorization: Bearer {{ .Token }}' \
--header 'Content-Type: application/json' \
--data '{"name":"Functions"}'

--header 'apiKey: {{ .PublishableKey }}'
--header 'Authorization: Bearer <UserToken>'
*/
Loading