mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(coderd): add webpush package (#17091)
* Adds `codersdk.ExperimentWebPush` (`web-push`) * Adds a `coderd/webpush` package that allows sending native push notifications via `github.com/SherClockHolmes/webpush-go` * Adds database tables to store push notification subscriptions. * Adds an API endpoint that allows users to subscribe/unsubscribe, and send a test notification (404 without experiment, excluded from API docs) * Adds server CLI command to regenerate VAPID keys (note: regenerating the VAPID keypair requires deleting all existing subscriptions) --------- Co-authored-by: Kyle Carberry <kyle@carberry.com>
This commit is contained in:
+23
-1
@@ -64,6 +64,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/entitlements"
|
||||
"github.com/coder/coder/v2/coderd/notifications/reports"
|
||||
"github.com/coder/coder/v2/coderd/runtimeconfig"
|
||||
"github.com/coder/coder/v2/coderd/webpush"
|
||||
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"github.com/coder/coder/v2/cli/clilog"
|
||||
@@ -775,6 +776,26 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
return xerrors.Errorf("set deployment id: %w", err)
|
||||
}
|
||||
|
||||
// Manage push notifications.
|
||||
experiments := coderd.ReadExperiments(options.Logger, options.DeploymentValues.Experiments.Value())
|
||||
if experiments.Enabled(codersdk.ExperimentWebPush) {
|
||||
webpusher, err := webpush.New(ctx, &options.Logger, options.Database)
|
||||
if err != nil {
|
||||
options.Logger.Error(ctx, "failed to create web push dispatcher", slog.Error(err))
|
||||
options.Logger.Warn(ctx, "web push notifications will not work until the VAPID keys are regenerated")
|
||||
webpusher = &webpush.NoopWebpusher{
|
||||
Msg: "Web Push notifications are disabled due to a system error. Please contact your Coder administrator.",
|
||||
}
|
||||
}
|
||||
options.WebPushDispatcher = webpusher
|
||||
} else {
|
||||
options.WebPushDispatcher = &webpush.NoopWebpusher{
|
||||
// Users will likely not see this message as the endpoints return 404
|
||||
// if not enabled. Just in case...
|
||||
Msg: "Web Push notifications are an experimental feature and are disabled by default. Enable the 'web-push' experiment to use this feature.",
|
||||
}
|
||||
}
|
||||
|
||||
githubOAuth2ConfigParams, err := getGithubOAuth2ConfigParams(ctx, options.Database, vals)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get github oauth2 config params: %w", err)
|
||||
@@ -1255,6 +1276,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
}
|
||||
|
||||
createAdminUserCmd := r.newCreateAdminUserCommand()
|
||||
regenerateVapidKeypairCmd := r.newRegenerateVapidKeypairCommand()
|
||||
|
||||
rawURLOpt := serpent.Option{
|
||||
Flag: "raw-url",
|
||||
@@ -1268,7 +1290,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
|
||||
serverCmd.Children = append(
|
||||
serverCmd.Children,
|
||||
createAdminUserCmd, postgresBuiltinURLCmd, postgresBuiltinServeCmd,
|
||||
createAdminUserCmd, postgresBuiltinURLCmd, postgresBuiltinServeCmd, regenerateVapidKeypairCmd,
|
||||
)
|
||||
|
||||
return serverCmd
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/awsiamrds"
|
||||
"github.com/coder/coder/v2/coderd/webpush"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) newRegenerateVapidKeypairCommand() *serpent.Command {
|
||||
var (
|
||||
regenVapidKeypairDBURL string
|
||||
regenVapidKeypairPgAuth string
|
||||
)
|
||||
regenerateVapidKeypairCommand := &serpent.Command{
|
||||
Use: "regenerate-vapid-keypair",
|
||||
Short: "Regenerate the VAPID keypair used for web push notifications.",
|
||||
Hidden: true, // Hide this command as it's an experimental feature
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
var (
|
||||
ctx, cancel = inv.SignalNotifyContext(inv.Context(), StopSignals...)
|
||||
cfg = r.createConfig()
|
||||
logger = inv.Logger.AppendSinks(sloghuman.Sink(inv.Stderr))
|
||||
)
|
||||
if r.verbose {
|
||||
logger = logger.Leveled(slog.LevelDebug)
|
||||
}
|
||||
|
||||
defer cancel()
|
||||
|
||||
if regenVapidKeypairDBURL == "" {
|
||||
cliui.Infof(inv.Stdout, "Using built-in PostgreSQL (%s)", cfg.PostgresPath())
|
||||
url, closePg, err := startBuiltinPostgres(ctx, cfg, logger, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = closePg()
|
||||
}()
|
||||
regenVapidKeypairDBURL = url
|
||||
}
|
||||
|
||||
sqlDriver := "postgres"
|
||||
var err error
|
||||
if codersdk.PostgresAuth(regenVapidKeypairPgAuth) == codersdk.PostgresAuthAWSIAMRDS {
|
||||
sqlDriver, err = awsiamrds.Register(inv.Context(), sqlDriver)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("register aws rds iam auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, regenVapidKeypairDBURL, nil)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("connect to postgres: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
db := database.New(sqlDB)
|
||||
|
||||
// Confirm that the user really wants to regenerate the VAPID keypair.
|
||||
cliui.Infof(inv.Stdout, "Regenerating VAPID keypair...")
|
||||
cliui.Infof(inv.Stdout, "This will delete all existing webpush subscriptions.")
|
||||
cliui.Infof(inv.Stdout, "Are you sure you want to continue? (y/N)")
|
||||
|
||||
if resp, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
IsConfirm: true,
|
||||
Default: cliui.ConfirmNo,
|
||||
}); err != nil || resp != cliui.ConfirmYes {
|
||||
return xerrors.Errorf("VAPID keypair regeneration failed: %w", err)
|
||||
}
|
||||
|
||||
if _, _, err := webpush.RegenerateVAPIDKeys(ctx, db); err != nil {
|
||||
return xerrors.Errorf("regenerate vapid keypair: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "VAPID keypair regenerated successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
regenerateVapidKeypairCommand.Options.Add(
|
||||
cliui.SkipPromptOption(),
|
||||
serpent.Option{
|
||||
Env: "CODER_PG_CONNECTION_URL",
|
||||
Flag: "postgres-url",
|
||||
Description: "URL of a PostgreSQL database. If empty, the built-in PostgreSQL deployment will be used (Coder must not be already running in this case).",
|
||||
Value: serpent.StringOf(®enVapidKeypairDBURL),
|
||||
},
|
||||
serpent.Option{
|
||||
Name: "Postgres Connection Auth",
|
||||
Description: "Type of auth to use when connecting to postgres.",
|
||||
Flag: "postgres-connection-auth",
|
||||
Env: "CODER_PG_CONNECTION_AUTH",
|
||||
Default: "password",
|
||||
Value: serpent.EnumOf(®enVapidKeypairPgAuth, codersdk.PostgresAuthDrivers...),
|
||||
},
|
||||
)
|
||||
|
||||
return regenerateVapidKeypairCommand
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestRegenerateVapidKeypair(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("this test is only supported on postgres")
|
||||
}
|
||||
|
||||
t.Run("NoExistingVAPIDKeys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
connectionURL, err := dbtestutil.Open(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
sqlDB, err := sql.Open("postgres", connectionURL)
|
||||
require.NoError(t, err)
|
||||
defer sqlDB.Close()
|
||||
|
||||
db := database.New(sqlDB)
|
||||
// Ensure there is no existing VAPID keypair.
|
||||
rows, err := db.GetWebpushVAPIDKeys(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, rows)
|
||||
|
||||
inv, _ := clitest.New(t, "server", "regenerate-vapid-keypair", "--postgres-url", connectionURL, "--yes")
|
||||
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
clitest.Start(t, inv)
|
||||
|
||||
pty.ExpectMatchContext(ctx, "Regenerating VAPID keypair...")
|
||||
pty.ExpectMatchContext(ctx, "This will delete all existing webpush subscriptions.")
|
||||
pty.ExpectMatchContext(ctx, "Are you sure you want to continue? (y/N)")
|
||||
pty.WriteLine("y")
|
||||
pty.ExpectMatchContext(ctx, "VAPID keypair regenerated successfully.")
|
||||
|
||||
// Ensure the VAPID keypair was created.
|
||||
keys, err := db.GetWebpushVAPIDKeys(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, keys.VapidPublicKey)
|
||||
require.NotEmpty(t, keys.VapidPrivateKey)
|
||||
})
|
||||
|
||||
t.Run("ExistingVAPIDKeys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
connectionURL, err := dbtestutil.Open(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
sqlDB, err := sql.Open("postgres", connectionURL)
|
||||
require.NoError(t, err)
|
||||
defer sqlDB.Close()
|
||||
|
||||
db := database.New(sqlDB)
|
||||
for i := 0; i < 10; i++ {
|
||||
// Insert a few fake users.
|
||||
u := dbgen.User(t, db, database.User{})
|
||||
// Insert a few fake push subscriptions for each user.
|
||||
for j := 0; j < 10; j++ {
|
||||
_ = dbgen.WebpushSubscription(t, db, database.InsertWebpushSubscriptionParams{
|
||||
UserID: u.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
inv, _ := clitest.New(t, "server", "regenerate-vapid-keypair", "--postgres-url", connectionURL, "--yes")
|
||||
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
clitest.Start(t, inv)
|
||||
|
||||
pty.ExpectMatchContext(ctx, "Regenerating VAPID keypair...")
|
||||
pty.ExpectMatchContext(ctx, "This will delete all existing webpush subscriptions.")
|
||||
pty.ExpectMatchContext(ctx, "Are you sure you want to continue? (y/N)")
|
||||
pty.WriteLine("y")
|
||||
pty.ExpectMatchContext(ctx, "VAPID keypair regenerated successfully.")
|
||||
|
||||
// Ensure the VAPID keypair was created.
|
||||
keys, err := db.GetWebpushVAPIDKeys(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, keys.VapidPublicKey)
|
||||
require.NotEmpty(t, keys.VapidPrivateKey)
|
||||
|
||||
// Ensure the push subscriptions were deleted.
|
||||
var count int64
|
||||
rows, err := sqlDB.QueryContext(ctx, "SELECT COUNT(*) FROM webpush_subscriptions")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = rows.Close()
|
||||
})
|
||||
require.True(t, rows.Next())
|
||||
require.NoError(t, rows.Scan(&count))
|
||||
require.Equal(t, int64(0), count)
|
||||
})
|
||||
}
|
||||
+6
-6
@@ -6,12 +6,12 @@ USAGE:
|
||||
Start a Coder server
|
||||
|
||||
SUBCOMMANDS:
|
||||
create-admin-user Create a new admin user with the given username,
|
||||
email and password and adds it to every
|
||||
organization.
|
||||
postgres-builtin-serve Run the built-in PostgreSQL deployment.
|
||||
postgres-builtin-url Output the connection URL for the built-in
|
||||
PostgreSQL deployment.
|
||||
create-admin-user Create a new admin user with the given username,
|
||||
email and password and adds it to every
|
||||
organization.
|
||||
postgres-builtin-serve Run the built-in PostgreSQL deployment.
|
||||
postgres-builtin-url Output the connection URL for the built-in
|
||||
PostgreSQL deployment.
|
||||
|
||||
OPTIONS:
|
||||
--allow-workspace-renames bool, $CODER_ALLOW_WORKSPACE_RENAMES (default: false)
|
||||
|
||||
Generated
+148
-2
@@ -7619,6 +7619,121 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/webpush/subscription": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Notifications"
|
||||
],
|
||||
"summary": "Create user webpush subscription",
|
||||
"operationId": "create-user-webpush-subscription",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Webpush subscription",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.WebpushSubscription"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, name, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Notifications"
|
||||
],
|
||||
"summary": "Delete user webpush subscription",
|
||||
"operationId": "delete-user-webpush-subscription",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Webpush subscription",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.DeleteWebpushSubscription"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, name, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/webpush/test": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Notifications"
|
||||
],
|
||||
"summary": "Send a test push notification",
|
||||
"operationId": "send-a-test-push-notification",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, name, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/workspace/{workspacename}": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -10721,6 +10836,10 @@ const docTemplate = `{
|
||||
"description": "Version returns the semantic version of the build.",
|
||||
"type": "string"
|
||||
},
|
||||
"webpush_public_key": {
|
||||
"description": "WebPushPublicKey is the public key for push notifications via Web Push.",
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_proxy": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -11497,6 +11616,14 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DeleteWebpushSubscription": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DeleteWorkspaceAgentPortShareRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -11832,19 +11959,22 @@ const docTemplate = `{
|
||||
"example",
|
||||
"auto-fill-parameters",
|
||||
"notifications",
|
||||
"workspace-usage"
|
||||
"workspace-usage",
|
||||
"web-push"
|
||||
],
|
||||
"x-enum-comments": {
|
||||
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
|
||||
"ExperimentExample": "This isn't used for anything.",
|
||||
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
|
||||
"ExperimentWebPush": "Enables web push notifications through the browser.",
|
||||
"ExperimentWorkspaceUsage": "Enables the new workspace usage tracking."
|
||||
},
|
||||
"x-enum-varnames": [
|
||||
"ExperimentExample",
|
||||
"ExperimentAutoFillParameters",
|
||||
"ExperimentNotifications",
|
||||
"ExperimentWorkspaceUsage"
|
||||
"ExperimentWorkspaceUsage",
|
||||
"ExperimentWebPush"
|
||||
]
|
||||
},
|
||||
"codersdk.ExternalAuth": {
|
||||
@@ -14111,6 +14241,7 @@ const docTemplate = `{
|
||||
"tailnet_coordinator",
|
||||
"template",
|
||||
"user",
|
||||
"webpush_subscription",
|
||||
"workspace",
|
||||
"workspace_agent_devcontainers",
|
||||
"workspace_agent_resource_monitor",
|
||||
@@ -14148,6 +14279,7 @@ const docTemplate = `{
|
||||
"ResourceTailnetCoordinator",
|
||||
"ResourceTemplate",
|
||||
"ResourceUser",
|
||||
"ResourceWebpushSubscription",
|
||||
"ResourceWorkspace",
|
||||
"ResourceWorkspaceAgentDevcontainers",
|
||||
"ResourceWorkspaceAgentResourceMonitor",
|
||||
@@ -15977,6 +16109,20 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WebpushSubscription": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auth_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"p256dh_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.Workspace": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+138
-2
@@ -6734,6 +6734,111 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/webpush/subscription": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"tags": ["Notifications"],
|
||||
"summary": "Create user webpush subscription",
|
||||
"operationId": "create-user-webpush-subscription",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Webpush subscription",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.WebpushSubscription"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, name, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"tags": ["Notifications"],
|
||||
"summary": "Delete user webpush subscription",
|
||||
"operationId": "delete-user-webpush-subscription",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Webpush subscription",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.DeleteWebpushSubscription"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, name, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/webpush/test": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": ["Notifications"],
|
||||
"summary": "Send a test push notification",
|
||||
"operationId": "send-a-test-push-notification",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, name, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/workspace/{workspacename}": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -9543,6 +9648,10 @@
|
||||
"description": "Version returns the semantic version of the build.",
|
||||
"type": "string"
|
||||
},
|
||||
"webpush_public_key": {
|
||||
"description": "WebPushPublicKey is the public key for push notifications via Web Push.",
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_proxy": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -10261,6 +10370,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DeleteWebpushSubscription": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DeleteWorkspaceAgentPortShareRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -10592,19 +10709,22 @@
|
||||
"example",
|
||||
"auto-fill-parameters",
|
||||
"notifications",
|
||||
"workspace-usage"
|
||||
"workspace-usage",
|
||||
"web-push"
|
||||
],
|
||||
"x-enum-comments": {
|
||||
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
|
||||
"ExperimentExample": "This isn't used for anything.",
|
||||
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
|
||||
"ExperimentWebPush": "Enables web push notifications through the browser.",
|
||||
"ExperimentWorkspaceUsage": "Enables the new workspace usage tracking."
|
||||
},
|
||||
"x-enum-varnames": [
|
||||
"ExperimentExample",
|
||||
"ExperimentAutoFillParameters",
|
||||
"ExperimentNotifications",
|
||||
"ExperimentWorkspaceUsage"
|
||||
"ExperimentWorkspaceUsage",
|
||||
"ExperimentWebPush"
|
||||
]
|
||||
},
|
||||
"codersdk.ExternalAuth": {
|
||||
@@ -12775,6 +12895,7 @@
|
||||
"tailnet_coordinator",
|
||||
"template",
|
||||
"user",
|
||||
"webpush_subscription",
|
||||
"workspace",
|
||||
"workspace_agent_devcontainers",
|
||||
"workspace_agent_resource_monitor",
|
||||
@@ -12812,6 +12933,7 @@
|
||||
"ResourceTailnetCoordinator",
|
||||
"ResourceTemplate",
|
||||
"ResourceUser",
|
||||
"ResourceWebpushSubscription",
|
||||
"ResourceWorkspace",
|
||||
"ResourceWorkspaceAgentDevcontainers",
|
||||
"ResourceWorkspaceAgentResourceMonitor",
|
||||
@@ -14548,6 +14670,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WebpushSubscription": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auth_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"p256dh_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.Workspace": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+15
-2
@@ -45,6 +45,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/entitlements"
|
||||
"github.com/coder/coder/v2/coderd/idpsync"
|
||||
"github.com/coder/coder/v2/coderd/runtimeconfig"
|
||||
"github.com/coder/coder/v2/coderd/webpush"
|
||||
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
@@ -260,6 +261,9 @@ type Options struct {
|
||||
AppEncryptionKeyCache cryptokeys.EncryptionKeycache
|
||||
OIDCConvertKeyCache cryptokeys.SigningKeycache
|
||||
Clock quartz.Clock
|
||||
|
||||
// WebPushDispatcher is a way to send notifications over Web Push.
|
||||
WebPushDispatcher webpush.Dispatcher
|
||||
}
|
||||
|
||||
// @title Coder API
|
||||
@@ -546,6 +550,7 @@ func New(options *Options) *API {
|
||||
UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore,
|
||||
AccessControlStore: options.AccessControlStore,
|
||||
Experiments: experiments,
|
||||
WebpushDispatcher: options.WebPushDispatcher,
|
||||
healthCheckGroup: &singleflight.Group[string, *healthsdk.HealthcheckReport]{},
|
||||
Acquirer: provisionerdserver.NewAcquirer(
|
||||
ctx,
|
||||
@@ -580,6 +585,7 @@ func New(options *Options) *API {
|
||||
WorkspaceProxy: false,
|
||||
UpgradeMessage: api.DeploymentValues.CLIUpgradeMessage.String(),
|
||||
DeploymentID: api.DeploymentID,
|
||||
WebPushPublicKey: api.WebpushDispatcher.PublicKey(),
|
||||
Telemetry: api.Telemetry.Enabled(),
|
||||
}
|
||||
api.SiteHandler = site.New(&site.Options{
|
||||
@@ -1195,6 +1201,11 @@ func New(options *Options) *API {
|
||||
r.Put("/", api.putUserNotificationPreferences)
|
||||
})
|
||||
})
|
||||
r.Route("/webpush", func(r chi.Router) {
|
||||
r.Post("/subscription", api.postUserWebpushSubscription)
|
||||
r.Delete("/subscription", api.deleteUserWebpushSubscription)
|
||||
r.Post("/test", api.postUserPushNotificationTest)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1494,8 +1505,10 @@ type API struct {
|
||||
TailnetCoordinator atomic.Pointer[tailnet.Coordinator]
|
||||
NetworkTelemetryBatcher *tailnet.NetworkTelemetryBatcher
|
||||
TailnetClientService *tailnet.ClientService
|
||||
QuotaCommitter atomic.Pointer[proto.QuotaCommitter]
|
||||
AppearanceFetcher atomic.Pointer[appearance.Fetcher]
|
||||
// WebpushDispatcher is a way to send notifications to users via Web Push.
|
||||
WebpushDispatcher webpush.Dispatcher
|
||||
QuotaCommitter atomic.Pointer[proto.QuotaCommitter]
|
||||
AppearanceFetcher atomic.Pointer[appearance.Fetcher]
|
||||
// WorkspaceProxyHostsFn returns the hosts of healthy workspace proxies
|
||||
// for header reasons.
|
||||
WorkspaceProxyHostsFn atomic.Pointer[func() []string]
|
||||
|
||||
@@ -78,6 +78,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/unhanger"
|
||||
"github.com/coder/coder/v2/coderd/updatecheck"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/webpush"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats"
|
||||
@@ -161,6 +162,7 @@ type Options struct {
|
||||
Logger *slog.Logger
|
||||
StatsBatcher workspacestats.Batcher
|
||||
|
||||
WebpushDispatcher webpush.Dispatcher
|
||||
WorkspaceAppsStatsCollectorOptions workspaceapps.StatsCollectorOptions
|
||||
AllowWorkspaceRenames bool
|
||||
NewTicker func(duration time.Duration) (<-chan time.Time, func())
|
||||
@@ -280,6 +282,15 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
|
||||
require.NoError(t, err, "insert a deployment id")
|
||||
}
|
||||
|
||||
if options.WebpushDispatcher == nil {
|
||||
// nolint:gocritic // Gets/sets VAPID keys.
|
||||
pushNotifier, err := webpush.New(dbauthz.AsNotifier(context.Background()), options.Logger, options.Database)
|
||||
if err != nil {
|
||||
panic(xerrors.Errorf("failed to create web push notifier: %w", err))
|
||||
}
|
||||
options.WebpushDispatcher = pushNotifier
|
||||
}
|
||||
|
||||
if options.DeploymentValues == nil {
|
||||
options.DeploymentValues = DeploymentValues(t)
|
||||
}
|
||||
@@ -530,6 +541,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
|
||||
TrialGenerator: options.TrialGenerator,
|
||||
RefreshEntitlements: options.RefreshEntitlements,
|
||||
TailnetCoordinator: options.Coordinator,
|
||||
WebPushDispatcher: options.WebpushDispatcher,
|
||||
BaseDERPMap: derpMap,
|
||||
DERPMapUpdateFrequency: 150 * time.Millisecond,
|
||||
CoordinatorResumeTokenProvider: options.CoordinatorResumeTokenProvider,
|
||||
|
||||
@@ -283,6 +283,8 @@ var (
|
||||
Site: rbac.Permissions(map[string][]policy.Action{
|
||||
rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
|
||||
rbac.ResourceInboxNotification.Type: {policy.ActionCreate},
|
||||
rbac.ResourceWebpushSubscription.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
|
||||
rbac.ResourceDeploymentConfig.Type: {policy.ActionRead, policy.ActionUpdate}, // To read and upsert VAPID keys
|
||||
}),
|
||||
Org: map[string][]rbac.Permission{},
|
||||
User: []rbac.Permission{},
|
||||
@@ -1176,6 +1178,13 @@ func (q *querier) DeleteAllTailnetTunnels(ctx context.Context, arg database.Dele
|
||||
return q.db.DeleteAllTailnetTunnels(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteAllWebpushSubscriptions(ctx context.Context) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceWebpushSubscription); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.DeleteAllWebpushSubscriptions(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error {
|
||||
// TODO: This is not 100% correct because it omits apikey IDs.
|
||||
err := q.authorizeContext(ctx, policy.ActionDelete,
|
||||
@@ -1381,6 +1390,20 @@ func (q *querier) DeleteTailnetTunnel(ctx context.Context, arg database.DeleteTa
|
||||
return q.db.DeleteTailnetTunnel(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceWebpushSubscription.WithOwner(arg.UserID.String())); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.DeleteWebpushSubscriptions(ctx, ids)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error {
|
||||
w, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID)
|
||||
if err != nil {
|
||||
@@ -2663,6 +2686,20 @@ func (q *querier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]databas
|
||||
return q.db.GetUsersByIDs(ctx, ids)
|
||||
}
|
||||
|
||||
func (q *querier) GetWebpushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]database.WebpushSubscription, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWebpushSubscription.WithOwner(userID.String())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetWebpushSubscriptionsByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) GetWebpushVAPIDKeys(ctx context.Context) (database.GetWebpushVAPIDKeysRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return database.GetWebpushVAPIDKeysRow{}, err
|
||||
}
|
||||
return q.db.GetWebpushVAPIDKeys(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) {
|
||||
// This is a system function
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
@@ -3420,6 +3457,13 @@ func (q *querier) InsertVolumeResourceMonitor(ctx context.Context, arg database.
|
||||
return q.db.InsertVolumeResourceMonitor(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertWebpushSubscription(ctx context.Context, arg database.InsertWebpushSubscriptionParams) (database.WebpushSubscription, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceWebpushSubscription.WithOwner(arg.UserID.String())); err != nil {
|
||||
return database.WebpushSubscription{}, err
|
||||
}
|
||||
return q.db.InsertWebpushSubscription(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertWorkspace(ctx context.Context, arg database.InsertWorkspaceParams) (database.WorkspaceTable, error) {
|
||||
obj := rbac.ResourceWorkspace.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID)
|
||||
tpl, err := q.GetTemplateByID(ctx, arg.TemplateID)
|
||||
@@ -4670,6 +4714,13 @@ func (q *querier) UpsertTemplateUsageStats(ctx context.Context) error {
|
||||
return q.db.UpsertTemplateUsageStats(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.UpsertWebpushVAPIDKeys(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpsertWorkspaceAgentPortShare(ctx context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) {
|
||||
workspace, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID)
|
||||
if err != nil {
|
||||
|
||||
@@ -4531,6 +4531,22 @@ func (s *MethodTestSuite) TestSystemFunctions() {
|
||||
s.Run("UpsertOAuth2GithubDefaultEligible", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args(true).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("GetWebpushVAPIDKeys", s.Subtest(func(db database.Store, check *expects) {
|
||||
require.NoError(s.T(), db.UpsertWebpushVAPIDKeys(context.Background(), database.UpsertWebpushVAPIDKeysParams{
|
||||
VapidPublicKey: "test",
|
||||
VapidPrivateKey: "test",
|
||||
}))
|
||||
check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(database.GetWebpushVAPIDKeysRow{
|
||||
VapidPublicKey: "test",
|
||||
VapidPrivateKey: "test",
|
||||
})
|
||||
}))
|
||||
s.Run("UpsertWebpushVAPIDKeys", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args(database.UpsertWebpushVAPIDKeysParams{
|
||||
VapidPublicKey: "test",
|
||||
VapidPrivateKey: "test",
|
||||
}).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *MethodTestSuite) TestNotifications() {
|
||||
@@ -4568,6 +4584,39 @@ func (s *MethodTestSuite) TestNotifications() {
|
||||
}).Asserts(rbac.ResourceNotificationMessage, policy.ActionRead)
|
||||
}))
|
||||
|
||||
// webpush subscriptions
|
||||
s.Run("GetWebpushSubscriptionsByUserID", s.Subtest(func(db database.Store, check *expects) {
|
||||
user := dbgen.User(s.T(), db, database.User{})
|
||||
check.Args(user.ID).Asserts(rbac.ResourceWebpushSubscription.WithOwner(user.ID.String()), policy.ActionRead)
|
||||
}))
|
||||
s.Run("InsertWebpushSubscription", s.Subtest(func(db database.Store, check *expects) {
|
||||
user := dbgen.User(s.T(), db, database.User{})
|
||||
check.Args(database.InsertWebpushSubscriptionParams{
|
||||
UserID: user.ID,
|
||||
}).Asserts(rbac.ResourceWebpushSubscription.WithOwner(user.ID.String()), policy.ActionCreate)
|
||||
}))
|
||||
s.Run("DeleteWebpushSubscriptions", s.Subtest(func(db database.Store, check *expects) {
|
||||
user := dbgen.User(s.T(), db, database.User{})
|
||||
push := dbgen.WebpushSubscription(s.T(), db, database.InsertWebpushSubscriptionParams{
|
||||
UserID: user.ID,
|
||||
})
|
||||
check.Args([]uuid.UUID{push.ID}).Asserts(rbac.ResourceSystem, policy.ActionDelete)
|
||||
}))
|
||||
s.Run("DeleteWebpushSubscriptionByUserIDAndEndpoint", s.Subtest(func(db database.Store, check *expects) {
|
||||
user := dbgen.User(s.T(), db, database.User{})
|
||||
push := dbgen.WebpushSubscription(s.T(), db, database.InsertWebpushSubscriptionParams{
|
||||
UserID: user.ID,
|
||||
})
|
||||
check.Args(database.DeleteWebpushSubscriptionByUserIDAndEndpointParams{
|
||||
UserID: user.ID,
|
||||
Endpoint: push.Endpoint,
|
||||
}).Asserts(rbac.ResourceWebpushSubscription.WithOwner(user.ID.String()), policy.ActionDelete)
|
||||
}))
|
||||
s.Run("DeleteAllWebpushSubscriptions", s.Subtest(func(_ database.Store, check *expects) {
|
||||
check.Args().
|
||||
Asserts(rbac.ResourceWebpushSubscription, policy.ActionDelete)
|
||||
}))
|
||||
|
||||
// Notification templates
|
||||
s.Run("GetNotificationTemplateByID", s.Subtest(func(db database.Store, check *expects) {
|
||||
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
|
||||
|
||||
@@ -479,6 +479,18 @@ func NotificationInbox(t testing.TB, db database.Store, orig database.InsertInbo
|
||||
return notification
|
||||
}
|
||||
|
||||
func WebpushSubscription(t testing.TB, db database.Store, orig database.InsertWebpushSubscriptionParams) database.WebpushSubscription {
|
||||
subscription, err := db.InsertWebpushSubscription(genCtx, database.InsertWebpushSubscriptionParams{
|
||||
CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()),
|
||||
UserID: takeFirst(orig.UserID, uuid.New()),
|
||||
Endpoint: takeFirst(orig.Endpoint, testutil.GetRandomName(t)),
|
||||
EndpointP256dhKey: takeFirst(orig.EndpointP256dhKey, testutil.GetRandomName(t)),
|
||||
EndpointAuthKey: takeFirst(orig.EndpointAuthKey, testutil.GetRandomName(t)),
|
||||
})
|
||||
require.NoError(t, err, "insert webpush subscription")
|
||||
return subscription
|
||||
}
|
||||
|
||||
func Group(t testing.TB, db database.Store, orig database.Group) database.Group {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -246,6 +246,7 @@ type data struct {
|
||||
templates []database.TemplateTable
|
||||
templateUsageStats []database.TemplateUsageStat
|
||||
userConfigs []database.UserConfig
|
||||
webpushSubscriptions []database.WebpushSubscription
|
||||
workspaceAgents []database.WorkspaceAgent
|
||||
workspaceAgentMetadata []database.WorkspaceAgentMetadatum
|
||||
workspaceAgentLogs []database.WorkspaceAgentLog
|
||||
@@ -289,6 +290,8 @@ type data struct {
|
||||
lastLicenseID int32
|
||||
defaultProxyDisplayName string
|
||||
defaultProxyIconURL string
|
||||
webpushVAPIDPublicKey string
|
||||
webpushVAPIDPrivateKey string
|
||||
userStatusChanges []database.UserStatusChange
|
||||
telemetryItems []database.TelemetryItem
|
||||
presets []database.TemplateVersionPreset
|
||||
@@ -1853,6 +1856,14 @@ func (*FakeQuerier) DeleteAllTailnetTunnels(_ context.Context, arg database.Dele
|
||||
return ErrUnimplemented
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) DeleteAllWebpushSubscriptions(_ context.Context) error {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
q.webpushSubscriptions = make([]database.WebpushSubscription, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) DeleteApplicationConnectAPIKeysByUserID(_ context.Context, userID uuid.UUID) error {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
@@ -2422,6 +2433,38 @@ func (*FakeQuerier) DeleteTailnetTunnel(_ context.Context, arg database.DeleteTa
|
||||
return database.DeleteTailnetTunnelRow{}, ErrUnimplemented
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) DeleteWebpushSubscriptionByUserIDAndEndpoint(_ context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
for i, subscription := range q.webpushSubscriptions {
|
||||
if subscription.UserID == arg.UserID && subscription.Endpoint == arg.Endpoint {
|
||||
q.webpushSubscriptions[i] = q.webpushSubscriptions[len(q.webpushSubscriptions)-1]
|
||||
q.webpushSubscriptions = q.webpushSubscriptions[:len(q.webpushSubscriptions)-1]
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) DeleteWebpushSubscriptions(_ context.Context, ids []uuid.UUID) error {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
for i, subscription := range q.webpushSubscriptions {
|
||||
if slices.Contains(ids, subscription.ID) {
|
||||
q.webpushSubscriptions[i] = q.webpushSubscriptions[len(q.webpushSubscriptions)-1]
|
||||
q.webpushSubscriptions = q.webpushSubscriptions[:len(q.webpushSubscriptions)-1]
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) DeleteWorkspaceAgentPortShare(_ context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
@@ -6717,6 +6760,34 @@ func (q *FakeQuerier) GetUsersByIDs(_ context.Context, ids []uuid.UUID) ([]datab
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetWebpushSubscriptionsByUserID(_ context.Context, userID uuid.UUID) ([]database.WebpushSubscription, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
out := make([]database.WebpushSubscription, 0)
|
||||
for _, subscription := range q.webpushSubscriptions {
|
||||
if subscription.UserID == userID {
|
||||
out = append(out, subscription)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetWebpushVAPIDKeys(_ context.Context) (database.GetWebpushVAPIDKeysRow, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
if q.webpushVAPIDPublicKey == "" && q.webpushVAPIDPrivateKey == "" {
|
||||
return database.GetWebpushVAPIDKeysRow{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
return database.GetWebpushVAPIDKeysRow{
|
||||
VapidPublicKey: q.webpushVAPIDPublicKey,
|
||||
VapidPrivateKey: q.webpushVAPIDPrivateKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetWorkspaceAgentAndLatestBuildByAuthToken(_ context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
@@ -9144,6 +9215,27 @@ func (q *FakeQuerier) InsertVolumeResourceMonitor(_ context.Context, arg databas
|
||||
return monitor, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) InsertWebpushSubscription(_ context.Context, arg database.InsertWebpushSubscriptionParams) (database.WebpushSubscription, error) {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
return database.WebpushSubscription{}, err
|
||||
}
|
||||
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
newSub := database.WebpushSubscription{
|
||||
ID: uuid.New(),
|
||||
UserID: arg.UserID,
|
||||
CreatedAt: arg.CreatedAt,
|
||||
Endpoint: arg.Endpoint,
|
||||
EndpointP256dhKey: arg.EndpointP256dhKey,
|
||||
EndpointAuthKey: arg.EndpointAuthKey,
|
||||
}
|
||||
q.webpushSubscriptions = append(q.webpushSubscriptions, newSub)
|
||||
return newSub, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWorkspaceParams) (database.WorkspaceTable, error) {
|
||||
if err := validateDatabaseType(arg); err != nil {
|
||||
return database.WorkspaceTable{}, err
|
||||
@@ -12458,6 +12550,20 @@ TemplateUsageStatsInsertLoop:
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) UpsertWebpushVAPIDKeys(_ context.Context, arg database.UpsertWebpushVAPIDKeysParams) error {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
q.webpushVAPIDPublicKey = arg.VapidPublicKey
|
||||
q.webpushVAPIDPrivateKey = arg.VapidPrivateKey
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) UpsertWorkspaceAgentPortShare(_ context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
|
||||
@@ -221,6 +221,13 @@ func (m queryMetricsStore) DeleteAllTailnetTunnels(ctx context.Context, arg data
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteAllWebpushSubscriptions(ctx context.Context) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.DeleteAllWebpushSubscriptions(ctx)
|
||||
m.queryLatencies.WithLabelValues("DeleteAllWebpushSubscriptions").Observe(time.Since(start).Seconds())
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error {
|
||||
start := time.Now()
|
||||
err := m.s.DeleteApplicationConnectAPIKeysByUserID(ctx, userID)
|
||||
@@ -410,6 +417,20 @@ func (m queryMetricsStore) DeleteTailnetTunnel(ctx context.Context, arg database
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("DeleteWebpushSubscriptionByUserIDAndEndpoint").Observe(time.Since(start).Seconds())
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.DeleteWebpushSubscriptions(ctx, ids)
|
||||
m.queryLatencies.WithLabelValues("DeleteWebpushSubscriptions").Observe(time.Since(start).Seconds())
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.DeleteWorkspaceAgentPortShare(ctx, arg)
|
||||
@@ -1502,6 +1523,20 @@ func (m queryMetricsStore) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) (
|
||||
return users, err
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetWebpushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]database.WebpushSubscription, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetWebpushSubscriptionsByUserID(ctx, userID)
|
||||
m.queryLatencies.WithLabelValues("GetWebpushSubscriptionsByUserID").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetWebpushVAPIDKeys(ctx context.Context) (database.GetWebpushVAPIDKeysRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetWebpushVAPIDKeys(ctx)
|
||||
m.queryLatencies.WithLabelValues("GetWebpushVAPIDKeys").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, authToken)
|
||||
@@ -2146,6 +2181,13 @@ func (m queryMetricsStore) InsertVolumeResourceMonitor(ctx context.Context, arg
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) InsertWebpushSubscription(ctx context.Context, arg database.InsertWebpushSubscriptionParams) (database.WebpushSubscription, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.InsertWebpushSubscription(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("InsertWebpushSubscription").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) InsertWorkspace(ctx context.Context, arg database.InsertWorkspaceParams) (database.WorkspaceTable, error) {
|
||||
start := time.Now()
|
||||
workspace, err := m.s.InsertWorkspace(ctx, arg)
|
||||
@@ -3014,6 +3056,13 @@ func (m queryMetricsStore) UpsertTemplateUsageStats(ctx context.Context) error {
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.UpsertWebpushVAPIDKeys(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpsertWebpushVAPIDKeys").Observe(time.Since(start).Seconds())
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpsertWorkspaceAgentPortShare(ctx context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpsertWorkspaceAgentPortShare(ctx, arg)
|
||||
|
||||
@@ -318,6 +318,20 @@ func (mr *MockStoreMockRecorder) DeleteAllTailnetTunnels(ctx, arg any) *gomock.C
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllTailnetTunnels", reflect.TypeOf((*MockStore)(nil).DeleteAllTailnetTunnels), ctx, arg)
|
||||
}
|
||||
|
||||
// DeleteAllWebpushSubscriptions mocks base method.
|
||||
func (m *MockStore) DeleteAllWebpushSubscriptions(ctx context.Context) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteAllWebpushSubscriptions", ctx)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteAllWebpushSubscriptions indicates an expected call of DeleteAllWebpushSubscriptions.
|
||||
func (mr *MockStoreMockRecorder) DeleteAllWebpushSubscriptions(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllWebpushSubscriptions", reflect.TypeOf((*MockStore)(nil).DeleteAllWebpushSubscriptions), ctx)
|
||||
}
|
||||
|
||||
// DeleteApplicationConnectAPIKeysByUserID mocks base method.
|
||||
func (m *MockStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -702,6 +716,34 @@ func (mr *MockStoreMockRecorder) DeleteTailnetTunnel(ctx, arg any) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTailnetTunnel", reflect.TypeOf((*MockStore)(nil).DeleteTailnetTunnel), ctx, arg)
|
||||
}
|
||||
|
||||
// DeleteWebpushSubscriptionByUserIDAndEndpoint mocks base method.
|
||||
func (m *MockStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteWebpushSubscriptionByUserIDAndEndpoint", ctx, arg)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteWebpushSubscriptionByUserIDAndEndpoint indicates an expected call of DeleteWebpushSubscriptionByUserIDAndEndpoint.
|
||||
func (mr *MockStoreMockRecorder) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWebpushSubscriptionByUserIDAndEndpoint", reflect.TypeOf((*MockStore)(nil).DeleteWebpushSubscriptionByUserIDAndEndpoint), ctx, arg)
|
||||
}
|
||||
|
||||
// DeleteWebpushSubscriptions mocks base method.
|
||||
func (m *MockStore) DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteWebpushSubscriptions", ctx, ids)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteWebpushSubscriptions indicates an expected call of DeleteWebpushSubscriptions.
|
||||
func (mr *MockStoreMockRecorder) DeleteWebpushSubscriptions(ctx, ids any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWebpushSubscriptions", reflect.TypeOf((*MockStore)(nil).DeleteWebpushSubscriptions), ctx, ids)
|
||||
}
|
||||
|
||||
// DeleteWorkspaceAgentPortShare mocks base method.
|
||||
func (m *MockStore) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -3142,6 +3184,36 @@ func (mr *MockStoreMockRecorder) GetUsersByIDs(ctx, ids any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersByIDs", reflect.TypeOf((*MockStore)(nil).GetUsersByIDs), ctx, ids)
|
||||
}
|
||||
|
||||
// GetWebpushSubscriptionsByUserID mocks base method.
|
||||
func (m *MockStore) GetWebpushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]database.WebpushSubscription, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetWebpushSubscriptionsByUserID", ctx, userID)
|
||||
ret0, _ := ret[0].([]database.WebpushSubscription)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetWebpushSubscriptionsByUserID indicates an expected call of GetWebpushSubscriptionsByUserID.
|
||||
func (mr *MockStoreMockRecorder) GetWebpushSubscriptionsByUserID(ctx, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWebpushSubscriptionsByUserID", reflect.TypeOf((*MockStore)(nil).GetWebpushSubscriptionsByUserID), ctx, userID)
|
||||
}
|
||||
|
||||
// GetWebpushVAPIDKeys mocks base method.
|
||||
func (m *MockStore) GetWebpushVAPIDKeys(ctx context.Context) (database.GetWebpushVAPIDKeysRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetWebpushVAPIDKeys", ctx)
|
||||
ret0, _ := ret[0].(database.GetWebpushVAPIDKeysRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetWebpushVAPIDKeys indicates an expected call of GetWebpushVAPIDKeys.
|
||||
func (mr *MockStoreMockRecorder) GetWebpushVAPIDKeys(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWebpushVAPIDKeys", reflect.TypeOf((*MockStore)(nil).GetWebpushVAPIDKeys), ctx)
|
||||
}
|
||||
|
||||
// GetWorkspaceAgentAndLatestBuildByAuthToken mocks base method.
|
||||
func (m *MockStore) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -4527,6 +4599,21 @@ func (mr *MockStoreMockRecorder) InsertVolumeResourceMonitor(ctx, arg any) *gomo
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertVolumeResourceMonitor", reflect.TypeOf((*MockStore)(nil).InsertVolumeResourceMonitor), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertWebpushSubscription mocks base method.
|
||||
func (m *MockStore) InsertWebpushSubscription(ctx context.Context, arg database.InsertWebpushSubscriptionParams) (database.WebpushSubscription, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "InsertWebpushSubscription", ctx, arg)
|
||||
ret0, _ := ret[0].(database.WebpushSubscription)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// InsertWebpushSubscription indicates an expected call of InsertWebpushSubscription.
|
||||
func (mr *MockStoreMockRecorder) InsertWebpushSubscription(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWebpushSubscription", reflect.TypeOf((*MockStore)(nil).InsertWebpushSubscription), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertWorkspace mocks base method.
|
||||
func (m *MockStore) InsertWorkspace(ctx context.Context, arg database.InsertWorkspaceParams) (database.WorkspaceTable, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -6347,6 +6434,20 @@ func (mr *MockStoreMockRecorder) UpsertTemplateUsageStats(ctx any) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTemplateUsageStats", reflect.TypeOf((*MockStore)(nil).UpsertTemplateUsageStats), ctx)
|
||||
}
|
||||
|
||||
// UpsertWebpushVAPIDKeys mocks base method.
|
||||
func (m *MockStore) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpsertWebpushVAPIDKeys", ctx, arg)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UpsertWebpushVAPIDKeys indicates an expected call of UpsertWebpushVAPIDKeys.
|
||||
func (mr *MockStoreMockRecorder) UpsertWebpushVAPIDKeys(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWebpushVAPIDKeys", reflect.TypeOf((*MockStore)(nil).UpsertWebpushVAPIDKeys), ctx, arg)
|
||||
}
|
||||
|
||||
// UpsertWorkspaceAgentPortShare mocks base method.
|
||||
func (m *MockStore) UpsertWorkspaceAgentPortShare(ctx context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+15
@@ -1614,6 +1614,15 @@ CREATE TABLE user_status_changes (
|
||||
|
||||
COMMENT ON TABLE user_status_changes IS 'Tracks the history of user status changes';
|
||||
|
||||
CREATE TABLE webpush_subscriptions (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
endpoint text NOT NULL,
|
||||
endpoint_p256dh_key text NOT NULL,
|
||||
endpoint_auth_key text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE workspace_agent_devcontainers (
|
||||
id uuid NOT NULL,
|
||||
workspace_agent_id uuid NOT NULL,
|
||||
@@ -2305,6 +2314,9 @@ ALTER TABLE ONLY user_status_changes
|
||||
ALTER TABLE ONLY users
|
||||
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY webpush_subscriptions
|
||||
ADD CONSTRAINT webpush_subscriptions_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY workspace_agent_devcontainers
|
||||
ADD CONSTRAINT workspace_agent_devcontainers_pkey PRIMARY KEY (id);
|
||||
|
||||
@@ -2745,6 +2757,9 @@ ALTER TABLE ONLY user_links
|
||||
ALTER TABLE ONLY user_status_changes
|
||||
ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
|
||||
ALTER TABLE ONLY webpush_subscriptions
|
||||
ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY workspace_agent_devcontainers
|
||||
ADD CONSTRAINT workspace_agent_devcontainers_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ const (
|
||||
ForeignKeyUserLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "user_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest);
|
||||
ForeignKeyUserLinksUserID ForeignKeyConstraint = "user_links_user_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyUserStatusChangesUserID ForeignKeyConstraint = "user_status_changes_user_id_fkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
ForeignKeyWebpushSubscriptionsUserID ForeignKeyConstraint = "webpush_subscriptions_user_id_fkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceAgentDevcontainersWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_devcontainers_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceAgentLogSourcesWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_log_sources_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceAgentMemoryResourceMonitorsAgentID ForeignKeyConstraint = "workspace_agent_memory_resource_monitors_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_memory_resource_monitors ADD CONSTRAINT workspace_agent_memory_resource_monitors_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS webpush_subscriptions;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- webpush_subscriptions is a table that stores push notification
|
||||
-- subscriptions for users. These are acquired via the Push API in the browser.
|
||||
CREATE TABLE IF NOT EXISTS webpush_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
-- endpoint is called by coderd to send a push notification to the user.
|
||||
endpoint TEXT NOT NULL,
|
||||
-- endpoint_p256dh_key is the public key for the endpoint.
|
||||
endpoint_p256dh_key TEXT NOT NULL,
|
||||
-- endpoint_auth_key is the authentication key for the endpoint.
|
||||
endpoint_auth_key TEXT NOT NULL
|
||||
);
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- VAPID keys lited from coderd/notifications_test.go.
|
||||
INSERT INTO webpush_subscriptions (id, user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key) VALUES (gen_random_uuid(), (SELECT id FROM users LIMIT 1), NOW(), 'https://example.com', 'BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=', 'zqbxT6JKstKSY9JKibZLSQ==');
|
||||
@@ -3240,6 +3240,15 @@ type VisibleUser struct {
|
||||
AvatarURL string `db:"avatar_url" json:"avatar_url"`
|
||||
}
|
||||
|
||||
type WebpushSubscription struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
Endpoint string `db:"endpoint" json:"endpoint"`
|
||||
EndpointP256dhKey string `db:"endpoint_p256dh_key" json:"endpoint_p256dh_key"`
|
||||
EndpointAuthKey string `db:"endpoint_auth_key" json:"endpoint_auth_key"`
|
||||
}
|
||||
|
||||
// Joins in the display name information such as username, avatar, and organization name.
|
||||
type Workspace struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
|
||||
@@ -69,6 +69,11 @@ type sqlcQuerier interface {
|
||||
DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error
|
||||
DeleteAllTailnetClientSubscriptions(ctx context.Context, arg DeleteAllTailnetClientSubscriptionsParams) error
|
||||
DeleteAllTailnetTunnels(ctx context.Context, arg DeleteAllTailnetTunnelsParams) error
|
||||
// Deletes all existing webpush subscriptions.
|
||||
// This should be called when the VAPID keypair is regenerated, as the old
|
||||
// keypair will no longer be valid and all existing subscriptions will need to
|
||||
// be recreated.
|
||||
DeleteAllWebpushSubscriptions(ctx context.Context) error
|
||||
DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error
|
||||
DeleteCoordinator(ctx context.Context, id uuid.UUID) error
|
||||
DeleteCryptoKey(ctx context.Context, arg DeleteCryptoKeyParams) (CryptoKey, error)
|
||||
@@ -104,6 +109,8 @@ type sqlcQuerier interface {
|
||||
DeleteTailnetClientSubscription(ctx context.Context, arg DeleteTailnetClientSubscriptionParams) error
|
||||
DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error)
|
||||
DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error)
|
||||
DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error
|
||||
DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error
|
||||
DeleteWorkspaceAgentPortShare(ctx context.Context, arg DeleteWorkspaceAgentPortShareParams) error
|
||||
DeleteWorkspaceAgentPortSharesByTemplate(ctx context.Context, templateID uuid.UUID) error
|
||||
// Disable foreign keys and triggers for all tables.
|
||||
@@ -340,6 +347,8 @@ type sqlcQuerier interface {
|
||||
// to look up references to actions. eg. a user could build a workspace
|
||||
// for another user, then be deleted... we still want them to appear!
|
||||
GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User, error)
|
||||
GetWebpushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]WebpushSubscription, error)
|
||||
GetWebpushVAPIDKeys(ctx context.Context) (GetWebpushVAPIDKeysRow, error)
|
||||
GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error)
|
||||
GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error)
|
||||
GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error)
|
||||
@@ -453,6 +462,7 @@ type sqlcQuerier interface {
|
||||
InsertUserGroupsByName(ctx context.Context, arg InsertUserGroupsByNameParams) error
|
||||
InsertUserLink(ctx context.Context, arg InsertUserLinkParams) (UserLink, error)
|
||||
InsertVolumeResourceMonitor(ctx context.Context, arg InsertVolumeResourceMonitorParams) (WorkspaceAgentVolumeResourceMonitor, error)
|
||||
InsertWebpushSubscription(ctx context.Context, arg InsertWebpushSubscriptionParams) (WebpushSubscription, error)
|
||||
InsertWorkspace(ctx context.Context, arg InsertWorkspaceParams) (WorkspaceTable, error)
|
||||
InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspaceAgentParams) (WorkspaceAgent, error)
|
||||
InsertWorkspaceAgentDevcontainers(ctx context.Context, arg InsertWorkspaceAgentDevcontainersParams) ([]WorkspaceAgentDevcontainer, error)
|
||||
@@ -597,6 +607,7 @@ type sqlcQuerier interface {
|
||||
// used to store the data, and the minutes are summed for each user and template
|
||||
// combination. The result is stored in the template_usage_stats table.
|
||||
UpsertTemplateUsageStats(ctx context.Context) error
|
||||
UpsertWebpushVAPIDKeys(ctx context.Context, arg UpsertWebpushVAPIDKeysParams) error
|
||||
UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error)
|
||||
//
|
||||
// The returned boolean, new_or_stale, can be used to deduce if a new session
|
||||
|
||||
@@ -3988,6 +3988,19 @@ func (q *sqlQuerier) BulkMarkNotificationMessagesSent(ctx context.Context, arg B
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const deleteAllWebpushSubscriptions = `-- name: DeleteAllWebpushSubscriptions :exec
|
||||
TRUNCATE TABLE webpush_subscriptions
|
||||
`
|
||||
|
||||
// Deletes all existing webpush subscriptions.
|
||||
// This should be called when the VAPID keypair is regenerated, as the old
|
||||
// keypair will no longer be valid and all existing subscriptions will need to
|
||||
// be recreated.
|
||||
func (q *sqlQuerier) DeleteAllWebpushSubscriptions(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteAllWebpushSubscriptions)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOldNotificationMessages = `-- name: DeleteOldNotificationMessages :exec
|
||||
DELETE
|
||||
FROM notification_messages
|
||||
@@ -4003,6 +4016,31 @@ func (q *sqlQuerier) DeleteOldNotificationMessages(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteWebpushSubscriptionByUserIDAndEndpoint = `-- name: DeleteWebpushSubscriptionByUserIDAndEndpoint :exec
|
||||
DELETE FROM webpush_subscriptions
|
||||
WHERE user_id = $1 AND endpoint = $2
|
||||
`
|
||||
|
||||
type DeleteWebpushSubscriptionByUserIDAndEndpointParams struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Endpoint string `db:"endpoint" json:"endpoint"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteWebpushSubscriptionByUserIDAndEndpoint, arg.UserID, arg.Endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteWebpushSubscriptions = `-- name: DeleteWebpushSubscriptions :exec
|
||||
DELETE FROM webpush_subscriptions
|
||||
WHERE id = ANY($1::uuid[])
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteWebpushSubscriptions, pq.Array(ids))
|
||||
return err
|
||||
}
|
||||
|
||||
const enqueueNotificationMessage = `-- name: EnqueueNotificationMessage :exec
|
||||
INSERT INTO notification_messages (id, notification_template_id, user_id, method, payload, targets, created_by, created_at)
|
||||
VALUES ($1,
|
||||
@@ -4255,6 +4293,76 @@ func (q *sqlQuerier) GetUserNotificationPreferences(ctx context.Context, userID
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getWebpushSubscriptionsByUserID = `-- name: GetWebpushSubscriptionsByUserID :many
|
||||
SELECT id, user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key
|
||||
FROM webpush_subscriptions
|
||||
WHERE user_id = $1::uuid
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetWebpushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]WebpushSubscription, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getWebpushSubscriptionsByUserID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []WebpushSubscription
|
||||
for rows.Next() {
|
||||
var i WebpushSubscription
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.CreatedAt,
|
||||
&i.Endpoint,
|
||||
&i.EndpointP256dhKey,
|
||||
&i.EndpointAuthKey,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const insertWebpushSubscription = `-- name: InsertWebpushSubscription :one
|
||||
INSERT INTO webpush_subscriptions (user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key
|
||||
`
|
||||
|
||||
type InsertWebpushSubscriptionParams struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
Endpoint string `db:"endpoint" json:"endpoint"`
|
||||
EndpointP256dhKey string `db:"endpoint_p256dh_key" json:"endpoint_p256dh_key"`
|
||||
EndpointAuthKey string `db:"endpoint_auth_key" json:"endpoint_auth_key"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertWebpushSubscription(ctx context.Context, arg InsertWebpushSubscriptionParams) (WebpushSubscription, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertWebpushSubscription,
|
||||
arg.UserID,
|
||||
arg.CreatedAt,
|
||||
arg.Endpoint,
|
||||
arg.EndpointP256dhKey,
|
||||
arg.EndpointAuthKey,
|
||||
)
|
||||
var i WebpushSubscription
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.CreatedAt,
|
||||
&i.Endpoint,
|
||||
&i.EndpointP256dhKey,
|
||||
&i.EndpointAuthKey,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateNotificationTemplateMethodByID = `-- name: UpdateNotificationTemplateMethodByID :one
|
||||
UPDATE notification_templates
|
||||
SET method = $1::notification_method
|
||||
@@ -8561,6 +8669,24 @@ func (q *sqlQuerier) GetRuntimeConfig(ctx context.Context, key string) (string,
|
||||
return value, err
|
||||
}
|
||||
|
||||
const getWebpushVAPIDKeys = `-- name: GetWebpushVAPIDKeys :one
|
||||
SELECT
|
||||
COALESCE((SELECT value FROM site_configs WHERE key = 'webpush_vapid_public_key'), '') :: text AS vapid_public_key,
|
||||
COALESCE((SELECT value FROM site_configs WHERE key = 'webpush_vapid_private_key'), '') :: text AS vapid_private_key
|
||||
`
|
||||
|
||||
type GetWebpushVAPIDKeysRow struct {
|
||||
VapidPublicKey string `db:"vapid_public_key" json:"vapid_public_key"`
|
||||
VapidPrivateKey string `db:"vapid_private_key" json:"vapid_private_key"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) GetWebpushVAPIDKeys(ctx context.Context) (GetWebpushVAPIDKeysRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getWebpushVAPIDKeys)
|
||||
var i GetWebpushVAPIDKeysRow
|
||||
err := row.Scan(&i.VapidPublicKey, &i.VapidPrivateKey)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertDERPMeshKey = `-- name: InsertDERPMeshKey :exec
|
||||
INSERT INTO site_configs (key, value) VALUES ('derp_mesh_key', $1)
|
||||
`
|
||||
@@ -8729,6 +8855,25 @@ func (q *sqlQuerier) UpsertRuntimeConfig(ctx context.Context, arg UpsertRuntimeC
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertWebpushVAPIDKeys = `-- name: UpsertWebpushVAPIDKeys :exec
|
||||
INSERT INTO site_configs (key, value)
|
||||
VALUES
|
||||
('webpush_vapid_public_key', $1 :: text),
|
||||
('webpush_vapid_private_key', $2 :: text)
|
||||
ON CONFLICT (key)
|
||||
DO UPDATE SET value = EXCLUDED.value WHERE site_configs.key = EXCLUDED.key
|
||||
`
|
||||
|
||||
type UpsertWebpushVAPIDKeysParams struct {
|
||||
VapidPublicKey string `db:"vapid_public_key" json:"vapid_public_key"`
|
||||
VapidPrivateKey string `db:"vapid_private_key" json:"vapid_private_key"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpsertWebpushVAPIDKeys(ctx context.Context, arg UpsertWebpushVAPIDKeysParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertWebpushVAPIDKeys, arg.VapidPublicKey, arg.VapidPrivateKey)
|
||||
return err
|
||||
}
|
||||
|
||||
const cleanTailnetCoordinators = `-- name: CleanTailnetCoordinators :exec
|
||||
DELETE
|
||||
FROM tailnet_coordinators
|
||||
|
||||
@@ -189,3 +189,28 @@ WHERE
|
||||
INSERT INTO notification_report_generator_logs (notification_template_id, last_generated_at) VALUES (@notification_template_id, @last_generated_at)
|
||||
ON CONFLICT (notification_template_id) DO UPDATE set last_generated_at = EXCLUDED.last_generated_at
|
||||
WHERE notification_report_generator_logs.notification_template_id = EXCLUDED.notification_template_id;
|
||||
|
||||
-- name: GetWebpushSubscriptionsByUserID :many
|
||||
SELECT *
|
||||
FROM webpush_subscriptions
|
||||
WHERE user_id = @user_id::uuid;
|
||||
|
||||
-- name: InsertWebpushSubscription :one
|
||||
INSERT INTO webpush_subscriptions (user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteWebpushSubscriptions :exec
|
||||
DELETE FROM webpush_subscriptions
|
||||
WHERE id = ANY(@ids::uuid[]);
|
||||
|
||||
-- name: DeleteWebpushSubscriptionByUserIDAndEndpoint :exec
|
||||
DELETE FROM webpush_subscriptions
|
||||
WHERE user_id = @user_id AND endpoint = @endpoint;
|
||||
|
||||
-- name: DeleteAllWebpushSubscriptions :exec
|
||||
-- Deletes all existing webpush subscriptions.
|
||||
-- This should be called when the VAPID keypair is regenerated, as the old
|
||||
-- keypair will no longer be valid and all existing subscriptions will need to
|
||||
-- be recreated.
|
||||
TRUNCATE TABLE webpush_subscriptions;
|
||||
|
||||
@@ -131,3 +131,16 @@ SET value = CASE
|
||||
ELSE 'false'
|
||||
END
|
||||
WHERE site_configs.key = 'oauth2_github_default_eligible';
|
||||
|
||||
-- name: UpsertWebpushVAPIDKeys :exec
|
||||
INSERT INTO site_configs (key, value)
|
||||
VALUES
|
||||
('webpush_vapid_public_key', @vapid_public_key :: text),
|
||||
('webpush_vapid_private_key', @vapid_private_key :: text)
|
||||
ON CONFLICT (key)
|
||||
DO UPDATE SET value = EXCLUDED.value WHERE site_configs.key = EXCLUDED.key;
|
||||
|
||||
-- name: GetWebpushVAPIDKeys :one
|
||||
SELECT
|
||||
COALESCE((SELECT value FROM site_configs WHERE key = 'webpush_vapid_public_key'), '') :: text AS vapid_public_key,
|
||||
COALESCE((SELECT value FROM site_configs WHERE key = 'webpush_vapid_private_key'), '') :: text AS vapid_private_key;
|
||||
|
||||
@@ -71,6 +71,7 @@ const (
|
||||
UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type);
|
||||
UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id);
|
||||
UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
||||
UniqueWebpushSubscriptionsPkey UniqueConstraint = "webpush_subscriptions_pkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_pkey PRIMARY KEY (id);
|
||||
UniqueWorkspaceAgentDevcontainersPkey UniqueConstraint = "workspace_agent_devcontainers_pkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_pkey PRIMARY KEY (id);
|
||||
UniqueWorkspaceAgentLogSourcesPkey UniqueConstraint = "workspace_agent_log_sources_pkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_pkey PRIMARY KEY (workspace_agent_id, id);
|
||||
UniqueWorkspaceAgentMemoryResourceMonitorsPkey UniqueConstraint = "workspace_agent_memory_resource_monitors_pkey" // ALTER TABLE ONLY workspace_agent_memory_resource_monitors ADD CONSTRAINT workspace_agent_memory_resource_monitors_pkey PRIMARY KEY (agent_id);
|
||||
|
||||
@@ -280,6 +280,15 @@ var (
|
||||
Type: "user",
|
||||
}
|
||||
|
||||
// ResourceWebpushSubscription
|
||||
// Valid Actions
|
||||
// - "ActionCreate" :: create webpush subscriptions
|
||||
// - "ActionDelete" :: delete webpush subscriptions
|
||||
// - "ActionRead" :: read webpush subscriptions
|
||||
ResourceWebpushSubscription = Object{
|
||||
Type: "webpush_subscription",
|
||||
}
|
||||
|
||||
// ResourceWorkspace
|
||||
// Valid Actions
|
||||
// - "ActionApplicationConnect" :: connect to workspace apps via browser
|
||||
@@ -367,6 +376,7 @@ func AllResources() []Objecter {
|
||||
ResourceTailnetCoordinator,
|
||||
ResourceTemplate,
|
||||
ResourceUser,
|
||||
ResourceWebpushSubscription,
|
||||
ResourceWorkspace,
|
||||
ResourceWorkspaceAgentDevcontainers,
|
||||
ResourceWorkspaceAgentResourceMonitor,
|
||||
|
||||
@@ -280,6 +280,13 @@ var RBACPermissions = map[string]PermissionDefinition{
|
||||
ActionUpdate: actDef("update notification preferences"),
|
||||
},
|
||||
},
|
||||
"webpush_subscription": {
|
||||
Actions: map[Action]ActionDefinition{
|
||||
ActionCreate: actDef("create webpush subscriptions"),
|
||||
ActionRead: actDef("read webpush subscriptions"),
|
||||
ActionDelete: actDef("delete webpush subscriptions"),
|
||||
},
|
||||
},
|
||||
"inbox_notification": {
|
||||
Actions: map[Action]ActionDefinition{
|
||||
ActionCreate: actDef("create inbox notifications"),
|
||||
|
||||
@@ -713,6 +713,16 @@ func TestRolePermissions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
// All users can create, read, and delete their own webpush notification subscriptions.
|
||||
{
|
||||
Name: "WebpushSubscription",
|
||||
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionDelete},
|
||||
Resource: rbac.ResourceWebpushSubscription.WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, memberMe, orgMemberMe},
|
||||
false: {otherOrgMember, orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin},
|
||||
},
|
||||
},
|
||||
// AnyOrganization tests
|
||||
{
|
||||
Name: "CreateOrgMember",
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// @Summary Create user webpush subscription
|
||||
// @ID create-user-webpush-subscription
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Tags Notifications
|
||||
// @Param request body codersdk.WebpushSubscription true "Webpush subscription"
|
||||
// @Param user path string true "User ID, name, or me"
|
||||
// @Router /users/{user}/webpush/subscription [post]
|
||||
// @Success 204
|
||||
// @x-apidocgen {"skip": true}
|
||||
func (api *API) postUserWebpushSubscription(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user := httpmw.UserParam(r)
|
||||
if !api.Experiments.Enabled(codersdk.ExperimentWebPush) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
var req codersdk.WebpushSubscription
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.WebpushDispatcher.Test(ctx, req); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to test webpush subscription",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := api.Database.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{
|
||||
CreatedAt: dbtime.Now(),
|
||||
UserID: user.ID,
|
||||
Endpoint: req.Endpoint,
|
||||
EndpointAuthKey: req.AuthKey,
|
||||
EndpointP256dhKey: req.P256DHKey,
|
||||
}); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to insert push notification subscription.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// @Summary Delete user webpush subscription
|
||||
// @ID delete-user-webpush-subscription
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Tags Notifications
|
||||
// @Param request body codersdk.DeleteWebpushSubscription true "Webpush subscription"
|
||||
// @Param user path string true "User ID, name, or me"
|
||||
// @Router /users/{user}/webpush/subscription [delete]
|
||||
// @Success 204
|
||||
// @x-apidocgen {"skip": true}
|
||||
func (api *API) deleteUserWebpushSubscription(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user := httpmw.UserParam(r)
|
||||
|
||||
if !api.Experiments.Enabled(codersdk.ExperimentWebPush) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
var req codersdk.DeleteWebpushSubscription
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Return NotFound if the subscription does not exist.
|
||||
if existing, err := api.Database.GetWebpushSubscriptionsByUserID(ctx, user.ID); err != nil && errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Webpush subscription not found.",
|
||||
})
|
||||
return
|
||||
} else if idx := slices.IndexFunc(existing, func(s database.WebpushSubscription) bool {
|
||||
return s.Endpoint == req.Endpoint
|
||||
}); idx == -1 {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Webpush subscription not found.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.Database.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, database.DeleteWebpushSubscriptionByUserIDAndEndpointParams{
|
||||
UserID: user.ID,
|
||||
Endpoint: req.Endpoint,
|
||||
}); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Webpush subscription not found.",
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to delete push notification subscription.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// @Summary Send a test push notification
|
||||
// @ID send-a-test-push-notification
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Notifications
|
||||
// @Param user path string true "User ID, name, or me"
|
||||
// @Success 204
|
||||
// @Router /users/{user}/webpush/test [post]
|
||||
// @x-apidocgen {"skip": true}
|
||||
func (api *API) postUserPushNotificationTest(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user := httpmw.UserParam(r)
|
||||
|
||||
if !api.Experiments.Enabled(codersdk.ExperimentWebPush) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
// We need to authorize the user to send a push notification to themselves.
|
||||
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceNotificationMessage.WithOwner(user.ID.String())) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.WebpushDispatcher.Dispatch(ctx, user.ID, codersdk.WebpushMessage{
|
||||
Title: "It's working!",
|
||||
Body: "You've subscribed to push notifications.",
|
||||
}); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to send test notification",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package webpush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// Dispatcher is an interface that can be used to dispatch
|
||||
// web push notifications to clients such as browsers.
|
||||
type Dispatcher interface {
|
||||
// Dispatch sends a web push notification to all subscriptions
|
||||
// for a user. Any notifications that fail to send are silently dropped.
|
||||
Dispatch(ctx context.Context, userID uuid.UUID, notification codersdk.WebpushMessage) error
|
||||
// Test sends a test web push notificatoin to a subscription to ensure it is valid.
|
||||
Test(ctx context.Context, req codersdk.WebpushSubscription) error
|
||||
// PublicKey returns the VAPID public key for the webpush dispatcher.
|
||||
PublicKey() string
|
||||
}
|
||||
|
||||
// New creates a new Dispatcher to dispatch web push notifications.
|
||||
//
|
||||
// This is *not* integrated into the enqueue system unfortunately.
|
||||
// That's because the notifications system has a enqueue system,
|
||||
// and push notifications at time of implementation are being used
|
||||
// for updates inside of a workspace, which we want to be immediate.
|
||||
//
|
||||
// See: https://github.com/coder/internal/issues/528
|
||||
func New(ctx context.Context, log *slog.Logger, db database.Store) (Dispatcher, error) {
|
||||
keys, err := db.GetWebpushVAPIDKeys(ctx)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, xerrors.Errorf("get notification vapid keys: %w", err)
|
||||
}
|
||||
}
|
||||
if keys.VapidPublicKey == "" || keys.VapidPrivateKey == "" {
|
||||
// Generate new VAPID keys. This also deletes all existing push
|
||||
// subscriptions as part of the transaction, as they are no longer
|
||||
// valid.
|
||||
newPrivateKey, newPublicKey, err := RegenerateVAPIDKeys(ctx, db)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("regenerate vapid keys: %w", err)
|
||||
}
|
||||
|
||||
keys.VapidPublicKey = newPublicKey
|
||||
keys.VapidPrivateKey = newPrivateKey
|
||||
}
|
||||
|
||||
return &Webpusher{
|
||||
store: db,
|
||||
log: log,
|
||||
VAPIDPublicKey: keys.VapidPublicKey,
|
||||
VAPIDPrivateKey: keys.VapidPrivateKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type Webpusher struct {
|
||||
store database.Store
|
||||
log *slog.Logger
|
||||
|
||||
VAPIDPublicKey string
|
||||
VAPIDPrivateKey string
|
||||
}
|
||||
|
||||
func (n *Webpusher) Dispatch(ctx context.Context, userID uuid.UUID, msg codersdk.WebpushMessage) error {
|
||||
subscriptions, err := n.store.GetWebpushSubscriptionsByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get web push subscriptions by user ID: %w", err)
|
||||
}
|
||||
if len(subscriptions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
msgJSON, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("marshal webpush notification: %w", err)
|
||||
}
|
||||
|
||||
cleanupSubscriptions := make([]uuid.UUID, 0)
|
||||
var mu sync.Mutex
|
||||
var eg errgroup.Group
|
||||
for _, subscription := range subscriptions {
|
||||
subscription := subscription
|
||||
eg.Go(func() error {
|
||||
// TODO: Implement some retry logic here. For now, this is just a
|
||||
// best-effort attempt.
|
||||
statusCode, body, err := n.webpushSend(ctx, msgJSON, subscription.Endpoint, webpush.Keys{
|
||||
Auth: subscription.EndpointAuthKey,
|
||||
P256dh: subscription.EndpointP256dhKey,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("send webpush notification: %w", err)
|
||||
}
|
||||
|
||||
if statusCode == http.StatusGone {
|
||||
// The subscription is no longer valid, remove it.
|
||||
mu.Lock()
|
||||
cleanupSubscriptions = append(cleanupSubscriptions, subscription.ID)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 200, 201, and 202 are common for successful delivery.
|
||||
if statusCode > http.StatusAccepted {
|
||||
// It's likely the subscription failed to deliver for some reason.
|
||||
return xerrors.Errorf("web push dispatch failed with status code %d: %s", statusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
err = eg.Wait()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("send webpush notifications: %w", err)
|
||||
}
|
||||
|
||||
if len(cleanupSubscriptions) > 0 {
|
||||
// nolint:gocritic // These are known to be invalid subscriptions.
|
||||
err = n.store.DeleteWebpushSubscriptions(dbauthz.AsNotifier(ctx), cleanupSubscriptions)
|
||||
if err != nil {
|
||||
n.log.Error(ctx, "failed to delete stale push subscriptions", slog.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Webpusher) webpushSend(ctx context.Context, msg []byte, endpoint string, keys webpush.Keys) (int, []byte, error) {
|
||||
// Copy the message to avoid modifying the original.
|
||||
cpy := slices.Clone(msg)
|
||||
resp, err := webpush.SendNotificationWithContext(ctx, cpy, &webpush.Subscription{
|
||||
Endpoint: endpoint,
|
||||
Keys: keys,
|
||||
}, &webpush.Options{
|
||||
VAPIDPublicKey: n.VAPIDPublicKey,
|
||||
VAPIDPrivateKey: n.VAPIDPrivateKey,
|
||||
})
|
||||
if err != nil {
|
||||
return -1, nil, xerrors.Errorf("send webpush notification: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return -1, nil, xerrors.Errorf("read response body: %w", err)
|
||||
}
|
||||
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
|
||||
func (n *Webpusher) Test(ctx context.Context, req codersdk.WebpushSubscription) error {
|
||||
msgJSON, err := json.Marshal(codersdk.WebpushMessage{
|
||||
Title: "Test",
|
||||
Body: "This is a test Web Push notification",
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("marshal webpush notification: %w", err)
|
||||
}
|
||||
statusCode, body, err := n.webpushSend(ctx, msgJSON, req.Endpoint, webpush.Keys{
|
||||
Auth: req.AuthKey,
|
||||
P256dh: req.P256DHKey,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("send test webpush notification: %w", err)
|
||||
}
|
||||
|
||||
// 200, 201, and 202 are common for successful delivery.
|
||||
if statusCode > http.StatusAccepted {
|
||||
// It's likely the subscription failed to deliver for some reason.
|
||||
return xerrors.Errorf("web push dispatch failed with status code %d: %s", statusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublicKey returns the VAPID public key for the webpush dispatcher.
|
||||
// Clients need this, so it's exposed via the BuildInfo endpoint.
|
||||
func (n *Webpusher) PublicKey() string {
|
||||
return n.VAPIDPublicKey
|
||||
}
|
||||
|
||||
// NoopWebpusher is a Dispatcher that does nothing except return an error.
|
||||
// This is returned when web push notifications are disabled, or if there was an
|
||||
// error generating the VAPID keys.
|
||||
type NoopWebpusher struct {
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (n *NoopWebpusher) Dispatch(context.Context, uuid.UUID, codersdk.WebpushMessage) error {
|
||||
return xerrors.New(n.Msg)
|
||||
}
|
||||
|
||||
func (n *NoopWebpusher) Test(context.Context, codersdk.WebpushSubscription) error {
|
||||
return xerrors.New(n.Msg)
|
||||
}
|
||||
|
||||
func (*NoopWebpusher) PublicKey() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// RegenerateVAPIDKeys regenerates the VAPID keys and deletes all existing
|
||||
// push subscriptions as part of the transaction, as they are no longer valid.
|
||||
func RegenerateVAPIDKeys(ctx context.Context, db database.Store) (newPrivateKey string, newPublicKey string, err error) {
|
||||
newPrivateKey, newPublicKey, err = webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
return "", "", xerrors.Errorf("generate new vapid keypair: %w", err)
|
||||
}
|
||||
|
||||
if txErr := db.InTx(func(tx database.Store) error {
|
||||
if err := tx.DeleteAllWebpushSubscriptions(ctx); err != nil {
|
||||
return xerrors.Errorf("delete all webpush subscriptions: %w", err)
|
||||
}
|
||||
if err := tx.UpsertWebpushVAPIDKeys(ctx, database.UpsertWebpushVAPIDKeysParams{
|
||||
VapidPrivateKey: newPrivateKey,
|
||||
VapidPublicKey: newPublicKey,
|
||||
}); err != nil {
|
||||
return xerrors.Errorf("upsert notification vapid key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, nil); txErr != nil {
|
||||
return "", "", xerrors.Errorf("regenerate vapid keypair: %w", txErr)
|
||||
}
|
||||
|
||||
return newPrivateKey, newPublicKey, nil
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package webpush_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/webpush"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
const (
|
||||
validEndpointAuthKey = "zqbxT6JKstKSY9JKibZLSQ=="
|
||||
validEndpointP256dhKey = "BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk="
|
||||
)
|
||||
|
||||
func TestPush(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("SuccessfulDelivery", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
user := dbgen.User(t, store, database.User{})
|
||||
sub, err := store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{
|
||||
UserID: user.ID,
|
||||
Endpoint: serverURL,
|
||||
EndpointAuthKey: validEndpointAuthKey,
|
||||
EndpointP256dhKey: validEndpointP256dhKey,
|
||||
CreatedAt: dbtime.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
notification := codersdk.WebpushMessage{
|
||||
Title: "Test Title",
|
||||
Body: "Test Body",
|
||||
Actions: []codersdk.WebpushMessageAction{
|
||||
{Label: "View", URL: "https://coder.com/view"},
|
||||
},
|
||||
Icon: "workspace",
|
||||
}
|
||||
|
||||
err = manager.Dispatch(ctx, user.ID, notification)
|
||||
require.NoError(t, err)
|
||||
|
||||
subscriptions, err := store.GetWebpushSubscriptionsByUserID(ctx, user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, subscriptions, 1, "One subscription should be returned")
|
||||
assert.Equal(t, subscriptions[0].ID, sub.ID, "The subscription should not be deleted")
|
||||
})
|
||||
|
||||
t.Run("ExpiredSubscription", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusGone)
|
||||
})
|
||||
user := dbgen.User(t, store, database.User{})
|
||||
_, err := store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{
|
||||
UserID: user.ID,
|
||||
Endpoint: serverURL,
|
||||
EndpointAuthKey: validEndpointAuthKey,
|
||||
EndpointP256dhKey: validEndpointP256dhKey,
|
||||
CreatedAt: dbtime.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
notification := codersdk.WebpushMessage{
|
||||
Title: "Test Title",
|
||||
Body: "Test Body",
|
||||
}
|
||||
|
||||
err = manager.Dispatch(ctx, user.ID, notification)
|
||||
require.NoError(t, err)
|
||||
|
||||
subscriptions, err := store.GetWebpushSubscriptionsByUserID(ctx, user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, subscriptions, 0, "No subscriptions should be returned")
|
||||
})
|
||||
|
||||
t.Run("FailedDelivery", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("Invalid request"))
|
||||
})
|
||||
|
||||
user := dbgen.User(t, store, database.User{})
|
||||
sub, err := store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{
|
||||
UserID: user.ID,
|
||||
Endpoint: serverURL,
|
||||
EndpointAuthKey: validEndpointAuthKey,
|
||||
EndpointP256dhKey: validEndpointP256dhKey,
|
||||
CreatedAt: dbtime.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
notification := codersdk.WebpushMessage{
|
||||
Title: "Test Title",
|
||||
Body: "Test Body",
|
||||
}
|
||||
|
||||
err = manager.Dispatch(ctx, user.ID, notification)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Invalid request")
|
||||
|
||||
subscriptions, err := store.GetWebpushSubscriptionsByUserID(ctx, user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, subscriptions, 1, "One subscription should be returned")
|
||||
assert.Equal(t, subscriptions[0].ID, sub.ID, "The subscription should not be deleted")
|
||||
})
|
||||
|
||||
t.Run("MultipleSubscriptions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
var okEndpointCalled bool
|
||||
var goneEndpointCalled bool
|
||||
manager, store, serverOKURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
okEndpointCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
serverGone := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
goneEndpointCalled = true
|
||||
w.WriteHeader(http.StatusGone)
|
||||
}))
|
||||
defer serverGone.Close()
|
||||
serverGoneURL := serverGone.URL
|
||||
|
||||
// Setup subscriptions pointing to our test servers
|
||||
user := dbgen.User(t, store, database.User{})
|
||||
|
||||
sub1, err := store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{
|
||||
UserID: user.ID,
|
||||
Endpoint: serverOKURL,
|
||||
EndpointAuthKey: validEndpointAuthKey,
|
||||
EndpointP256dhKey: validEndpointP256dhKey,
|
||||
CreatedAt: dbtime.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{
|
||||
UserID: user.ID,
|
||||
Endpoint: serverGoneURL,
|
||||
EndpointAuthKey: validEndpointAuthKey,
|
||||
EndpointP256dhKey: validEndpointP256dhKey,
|
||||
CreatedAt: dbtime.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
notification := codersdk.WebpushMessage{
|
||||
Title: "Test Title",
|
||||
Body: "Test Body",
|
||||
Actions: []codersdk.WebpushMessageAction{
|
||||
{Label: "View", URL: "https://coder.com/view"},
|
||||
},
|
||||
}
|
||||
|
||||
err = manager.Dispatch(ctx, user.ID, notification)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, okEndpointCalled, "The valid endpoint should be called")
|
||||
assert.True(t, goneEndpointCalled, "The expired endpoint should be called")
|
||||
|
||||
// Assert that sub1 was not deleted.
|
||||
subscriptions, err := store.GetWebpushSubscriptionsByUserID(ctx, user.ID)
|
||||
require.NoError(t, err)
|
||||
if assert.Len(t, subscriptions, 1, "One subscription should be returned") {
|
||||
assert.Equal(t, subscriptions[0].ID, sub1.ID, "The valid subscription should not be deleted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NotificationPayload", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
var requestReceived bool
|
||||
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
requestReceived = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
user := dbgen.User(t, store, database.User{})
|
||||
|
||||
_, err := store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{
|
||||
CreatedAt: dbtime.Now(),
|
||||
UserID: user.ID,
|
||||
Endpoint: serverURL,
|
||||
EndpointAuthKey: validEndpointAuthKey,
|
||||
EndpointP256dhKey: validEndpointP256dhKey,
|
||||
})
|
||||
require.NoError(t, err, "Failed to insert push subscription")
|
||||
|
||||
notification := codersdk.WebpushMessage{
|
||||
Title: "Test Notification",
|
||||
Body: "This is a test notification body",
|
||||
Actions: []codersdk.WebpushMessageAction{
|
||||
{Label: "View Workspace", URL: "https://coder.com/workspace/123"},
|
||||
{Label: "Cancel", URL: "https://coder.com/cancel"},
|
||||
},
|
||||
Icon: "workspace-icon",
|
||||
}
|
||||
|
||||
err = manager.Dispatch(ctx, user.ID, notification)
|
||||
require.NoError(t, err, "The push notification should be dispatched successfully")
|
||||
require.True(t, requestReceived, "The push notification request should have been received by the server")
|
||||
})
|
||||
|
||||
t.Run("NoSubscriptions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
manager, store, _ := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
userID := uuid.New()
|
||||
notification := codersdk.WebpushMessage{
|
||||
Title: "Test Title",
|
||||
Body: "Test Body",
|
||||
}
|
||||
|
||||
err := manager.Dispatch(ctx, userID, notification)
|
||||
require.NoError(t, err)
|
||||
|
||||
subscriptions, err := store.GetWebpushSubscriptionsByUserID(ctx, userID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, subscriptions, "No subscriptions should be returned")
|
||||
})
|
||||
}
|
||||
|
||||
// setupPushTest creates a common test setup for webpush notification tests
|
||||
func setupPushTest(ctx context.Context, t *testing.T, handlerFunc func(w http.ResponseWriter, r *http.Request)) (webpush.Dispatcher, database.Store, string) {
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(handlerFunc))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
manager, err := webpush.New(ctx, &logger, db)
|
||||
require.NoError(t, err, "Failed to create webpush manager")
|
||||
|
||||
return manager, db, server.URL
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package coderd_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
const (
|
||||
// These are valid keys for a web push subscription.
|
||||
// DO NOT REUSE THESE IN ANY REAL CODE.
|
||||
validEndpointAuthKey = "zqbxT6JKstKSY9JKibZLSQ=="
|
||||
validEndpointP256dhKey = "BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk="
|
||||
)
|
||||
|
||||
func TestWebpushSubscribeUnsubscribe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.Experiments = []string{string(codersdk.ExperimentWebPush)}
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
DeploymentValues: dv,
|
||||
})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
_, anotherMember := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
handlerCalled := make(chan bool, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
handlerCalled <- true
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := memberClient.PostWebpushSubscription(ctx, "me", codersdk.WebpushSubscription{
|
||||
Endpoint: server.URL,
|
||||
AuthKey: validEndpointAuthKey,
|
||||
P256DHKey: validEndpointP256dhKey,
|
||||
})
|
||||
require.NoError(t, err, "create webpush subscription")
|
||||
require.True(t, <-handlerCalled, "handler should have been called")
|
||||
|
||||
err = memberClient.PostTestWebpushMessage(ctx)
|
||||
require.NoError(t, err, "test webpush message")
|
||||
require.True(t, <-handlerCalled, "handler should have been called again")
|
||||
|
||||
err = memberClient.DeleteWebpushSubscription(ctx, "me", codersdk.DeleteWebpushSubscription{
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
require.NoError(t, err, "delete webpush subscription")
|
||||
|
||||
// Deleting the subscription for a non-existent endpoint should return a 404
|
||||
err = memberClient.DeleteWebpushSubscription(ctx, "me", codersdk.DeleteWebpushSubscription{
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
var sdkError *codersdk.Error
|
||||
require.Error(t, err)
|
||||
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
|
||||
require.Equal(t, http.StatusNotFound, sdkError.StatusCode())
|
||||
|
||||
// Creating a subscription for another user should not be allowed.
|
||||
err = memberClient.PostWebpushSubscription(ctx, anotherMember.ID.String(), codersdk.WebpushSubscription{
|
||||
Endpoint: server.URL,
|
||||
AuthKey: validEndpointAuthKey,
|
||||
P256DHKey: validEndpointP256dhKey,
|
||||
})
|
||||
require.Error(t, err, "create webpush subscription for another user")
|
||||
|
||||
// Deleting a subscription for another user should not be allowed.
|
||||
err = memberClient.DeleteWebpushSubscription(ctx, anotherMember.ID.String(), codersdk.DeleteWebpushSubscription{
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
require.Error(t, err, "delete webpush subscription for another user")
|
||||
}
|
||||
@@ -2968,6 +2968,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
|
||||
Hidden: true, // Hidden because most operators should not need to modify this.
|
||||
},
|
||||
// Push notifications.
|
||||
}
|
||||
|
||||
return opts
|
||||
@@ -3147,6 +3148,9 @@ type BuildInfoResponse struct {
|
||||
|
||||
// DeploymentID is the unique identifier for this deployment.
|
||||
DeploymentID string `json:"deployment_id"`
|
||||
|
||||
// WebPushPublicKey is the public key for push notifications via Web Push.
|
||||
WebPushPublicKey string `json:"webpush_public_key,omitempty"`
|
||||
}
|
||||
|
||||
type WorkspaceProxyBuildInfo struct {
|
||||
@@ -3189,6 +3193,7 @@ const (
|
||||
ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature.
|
||||
ExperimentNotifications Experiment = "notifications" // Sends notifications via SMTP and webhooks following certain events.
|
||||
ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking.
|
||||
ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser.
|
||||
)
|
||||
|
||||
// ExperimentsAll should include all experiments that are safe for
|
||||
|
||||
@@ -213,3 +213,70 @@ type UpdateNotificationTemplateMethod struct {
|
||||
type UpdateUserNotificationPreferences struct {
|
||||
TemplateDisabledMap map[string]bool `json:"template_disabled_map"`
|
||||
}
|
||||
|
||||
type WebpushMessageAction struct {
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type WebpushMessage struct {
|
||||
Icon string `json:"icon"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Actions []WebpushMessageAction `json:"actions"`
|
||||
}
|
||||
|
||||
type WebpushSubscription struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
AuthKey string `json:"auth_key"`
|
||||
P256DHKey string `json:"p256dh_key"`
|
||||
}
|
||||
|
||||
type DeleteWebpushSubscription struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
// PostWebpushSubscription creates a push notification subscription for a given user.
|
||||
func (c *Client) PostWebpushSubscription(ctx context.Context, user string, req WebpushSubscription) error {
|
||||
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/webpush/subscription", user), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWebpushSubscription deletes a push notification subscription for a given user.
|
||||
// Think of this as an unsubscribe, but for a specific push notification subscription.
|
||||
func (c *Client) DeleteWebpushSubscription(ctx context.Context, user string, req DeleteWebpushSubscription) error {
|
||||
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s/webpush/subscription", user), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) PostTestWebpushMessage(ctx context.Context) error {
|
||||
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/webpush/test", Me), WebpushMessage{
|
||||
Title: "It's working!",
|
||||
Body: "You've subscribed to push notifications.",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const (
|
||||
ResourceTailnetCoordinator RBACResource = "tailnet_coordinator"
|
||||
ResourceTemplate RBACResource = "template"
|
||||
ResourceUser RBACResource = "user"
|
||||
ResourceWebpushSubscription RBACResource = "webpush_subscription"
|
||||
ResourceWorkspace RBACResource = "workspace"
|
||||
ResourceWorkspaceAgentDevcontainers RBACResource = "workspace_agent_devcontainers"
|
||||
ResourceWorkspaceAgentResourceMonitor RBACResource = "workspace_agent_resource_monitor"
|
||||
@@ -93,6 +94,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{
|
||||
ResourceTailnetCoordinator: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
|
||||
ResourceTemplate: {ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionUse, ActionViewInsights},
|
||||
ResourceUser: {ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal},
|
||||
ResourceWebpushSubscription: {ActionCreate, ActionDelete, ActionRead},
|
||||
ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate},
|
||||
ResourceWorkspaceAgentDevcontainers: {ActionCreate},
|
||||
ResourceWorkspaceAgentResourceMonitor: {ActionCreate, ActionRead, ActionUpdate},
|
||||
|
||||
Generated
+1
@@ -61,6 +61,7 @@ curl -X GET http://coder-server:8080/api/v2/buildinfo \
|
||||
"telemetry": true,
|
||||
"upgrade_message": "string",
|
||||
"version": "string",
|
||||
"webpush_public_key": "string",
|
||||
"workspace_proxy": true
|
||||
}
|
||||
```
|
||||
|
||||
Generated
+5
@@ -210,6 +210,7 @@ Status Code **200**
|
||||
| `resource_type` | `tailnet_coordinator` |
|
||||
| `resource_type` | `template` |
|
||||
| `resource_type` | `user` |
|
||||
| `resource_type` | `webpush_subscription` |
|
||||
| `resource_type` | `workspace` |
|
||||
| `resource_type` | `workspace_agent_devcontainers` |
|
||||
| `resource_type` | `workspace_agent_resource_monitor` |
|
||||
@@ -375,6 +376,7 @@ Status Code **200**
|
||||
| `resource_type` | `tailnet_coordinator` |
|
||||
| `resource_type` | `template` |
|
||||
| `resource_type` | `user` |
|
||||
| `resource_type` | `webpush_subscription` |
|
||||
| `resource_type` | `workspace` |
|
||||
| `resource_type` | `workspace_agent_devcontainers` |
|
||||
| `resource_type` | `workspace_agent_resource_monitor` |
|
||||
@@ -540,6 +542,7 @@ Status Code **200**
|
||||
| `resource_type` | `tailnet_coordinator` |
|
||||
| `resource_type` | `template` |
|
||||
| `resource_type` | `user` |
|
||||
| `resource_type` | `webpush_subscription` |
|
||||
| `resource_type` | `workspace` |
|
||||
| `resource_type` | `workspace_agent_devcontainers` |
|
||||
| `resource_type` | `workspace_agent_resource_monitor` |
|
||||
@@ -674,6 +677,7 @@ Status Code **200**
|
||||
| `resource_type` | `tailnet_coordinator` |
|
||||
| `resource_type` | `template` |
|
||||
| `resource_type` | `user` |
|
||||
| `resource_type` | `webpush_subscription` |
|
||||
| `resource_type` | `workspace` |
|
||||
| `resource_type` | `workspace_agent_devcontainers` |
|
||||
| `resource_type` | `workspace_agent_resource_monitor` |
|
||||
@@ -1030,6 +1034,7 @@ Status Code **200**
|
||||
| `resource_type` | `tailnet_coordinator` |
|
||||
| `resource_type` | `template` |
|
||||
| `resource_type` | `user` |
|
||||
| `resource_type` | `webpush_subscription` |
|
||||
| `resource_type` | `workspace` |
|
||||
| `resource_type` | `workspace_agent_devcontainers` |
|
||||
| `resource_type` | `workspace_agent_resource_monitor` |
|
||||
|
||||
Generated
+36
@@ -964,6 +964,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
"telemetry": true,
|
||||
"upgrade_message": "string",
|
||||
"version": "string",
|
||||
"webpush_public_key": "string",
|
||||
"workspace_proxy": true
|
||||
}
|
||||
```
|
||||
@@ -980,6 +981,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
| `telemetry` | boolean | false | | Telemetry is a boolean that indicates whether telemetry is enabled. |
|
||||
| `upgrade_message` | string | false | | Upgrade message is the message displayed to users when an outdated client is detected. |
|
||||
| `version` | string | false | | Version returns the semantic version of the build. |
|
||||
| `webpush_public_key` | string | false | | Webpush public key is the public key for push notifications via Web Push. |
|
||||
| `workspace_proxy` | boolean | false | | |
|
||||
|
||||
## codersdk.BuildReason
|
||||
@@ -1755,6 +1757,20 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
| `allow_path_app_sharing` | boolean | false | | |
|
||||
| `allow_path_app_site_owner_access` | boolean | false | | |
|
||||
|
||||
## codersdk.DeleteWebpushSubscription
|
||||
|
||||
```json
|
||||
{
|
||||
"endpoint": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|------------|--------|----------|--------------|-------------|
|
||||
| `endpoint` | string | false | | |
|
||||
|
||||
## codersdk.DeleteWorkspaceAgentPortShareRequest
|
||||
|
||||
```json
|
||||
@@ -2804,6 +2820,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
| `auto-fill-parameters` |
|
||||
| `notifications` |
|
||||
| `workspace-usage` |
|
||||
| `web-push` |
|
||||
|
||||
## codersdk.ExternalAuth
|
||||
|
||||
@@ -5344,6 +5361,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
|
||||
| `tailnet_coordinator` |
|
||||
| `template` |
|
||||
| `user` |
|
||||
| `webpush_subscription` |
|
||||
| `workspace` |
|
||||
| `workspace_agent_devcontainers` |
|
||||
| `workspace_agent_resource_monitor` |
|
||||
@@ -7470,6 +7488,24 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
| `name` | string | false | | |
|
||||
| `value` | string | false | | |
|
||||
|
||||
## codersdk.WebpushSubscription
|
||||
|
||||
```json
|
||||
{
|
||||
"auth_key": "string",
|
||||
"endpoint": "string",
|
||||
"p256dh_key": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|--------------|--------|----------|--------------|-------------|
|
||||
| `auth_key` | string | false | | |
|
||||
| `endpoint` | string | false | | |
|
||||
| `p256dh_key` | string | false | | |
|
||||
|
||||
## codersdk.Workspace
|
||||
|
||||
```json
|
||||
|
||||
+7
-7
@@ -6,13 +6,13 @@ USAGE:
|
||||
Start a Coder server
|
||||
|
||||
SUBCOMMANDS:
|
||||
create-admin-user Create a new admin user with the given username,
|
||||
email and password and adds it to every
|
||||
organization.
|
||||
dbcrypt Manage database encryption.
|
||||
postgres-builtin-serve Run the built-in PostgreSQL deployment.
|
||||
postgres-builtin-url Output the connection URL for the built-in
|
||||
PostgreSQL deployment.
|
||||
create-admin-user Create a new admin user with the given username,
|
||||
email and password and adds it to every
|
||||
organization.
|
||||
dbcrypt Manage database encryption.
|
||||
postgres-builtin-serve Run the built-in PostgreSQL deployment.
|
||||
postgres-builtin-url Output the connection URL for the built-in
|
||||
PostgreSQL deployment.
|
||||
|
||||
OPTIONS:
|
||||
--allow-workspace-renames bool, $CODER_ALLOW_WORKSPACE_RENAMES (default: false)
|
||||
|
||||
@@ -471,9 +471,12 @@ require (
|
||||
|
||||
require github.com/coder/clistat v1.0.0
|
||||
|
||||
require github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
)
|
||||
|
||||
@@ -66,6 +66,8 @@ github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8
|
||||
github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
|
||||
github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk=
|
||||
github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
|
||||
github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY=
|
||||
@@ -452,6 +454,8 @@ github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTH
|
||||
github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
@@ -1062,7 +1066,11 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI=
|
||||
@@ -1074,6 +1082,9 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -1087,6 +1098,9 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||
@@ -1098,6 +1112,10 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -1136,17 +1154,26 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -1158,7 +1185,10 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
@@ -1170,6 +1200,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f
|
||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -157,6 +157,11 @@ export const RBACResourceActions: Partial<
|
||||
update: "update an existing user",
|
||||
update_personal: "update personal data",
|
||||
},
|
||||
webpush_subscription: {
|
||||
create: "create webpush subscriptions",
|
||||
delete: "delete webpush subscriptions",
|
||||
read: "read webpush subscriptions",
|
||||
},
|
||||
workspace: {
|
||||
application_connect: "connect to workspace apps via browser",
|
||||
create: "create a new workspace",
|
||||
|
||||
Generated
+30
@@ -263,6 +263,7 @@ export interface BuildInfoResponse {
|
||||
readonly provisioner_api_version: string;
|
||||
readonly upgrade_message: string;
|
||||
readonly deployment_id: string;
|
||||
readonly webpush_public_key?: string;
|
||||
}
|
||||
|
||||
// From codersdk/workspacebuilds.go
|
||||
@@ -597,6 +598,11 @@ export interface DatabaseReport extends BaseReport {
|
||||
readonly threshold_ms: number;
|
||||
}
|
||||
|
||||
// From codersdk/notifications.go
|
||||
export interface DeleteWebpushSubscription {
|
||||
readonly endpoint: string;
|
||||
}
|
||||
|
||||
// From codersdk/workspaceagentportshare.go
|
||||
export interface DeleteWorkspaceAgentPortShareRequest {
|
||||
readonly agent_name: string;
|
||||
@@ -745,6 +751,7 @@ export type Experiment =
|
||||
| "auto-fill-parameters"
|
||||
| "example"
|
||||
| "notifications"
|
||||
| "web-push"
|
||||
| "workspace-usage";
|
||||
|
||||
// From codersdk/deployment.go
|
||||
@@ -1973,6 +1980,7 @@ export type RBACResource =
|
||||
| "tailnet_coordinator"
|
||||
| "template"
|
||||
| "user"
|
||||
| "webpush_subscription"
|
||||
| "*"
|
||||
| "workspace"
|
||||
| "workspace_agent_devcontainers"
|
||||
@@ -2010,6 +2018,7 @@ export const RBACResources: RBACResource[] = [
|
||||
"tailnet_coordinator",
|
||||
"template",
|
||||
"user",
|
||||
"webpush_subscription",
|
||||
"*",
|
||||
"workspace",
|
||||
"workspace_agent_devcontainers",
|
||||
@@ -2993,6 +3002,27 @@ export interface VariableValue {
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
// From codersdk/notifications.go
|
||||
export interface WebpushMessage {
|
||||
readonly icon: string;
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly actions: readonly WebpushMessageAction[];
|
||||
}
|
||||
|
||||
// From codersdk/notifications.go
|
||||
export interface WebpushMessageAction {
|
||||
readonly label: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
// From codersdk/notifications.go
|
||||
export interface WebpushSubscription {
|
||||
readonly endpoint: string;
|
||||
readonly auth_key: string;
|
||||
readonly p256dh_key: string;
|
||||
}
|
||||
|
||||
// From healthsdk/healthsdk.go
|
||||
export interface WebsocketReport extends BaseReport {
|
||||
readonly healthy: boolean;
|
||||
|
||||
Reference in New Issue
Block a user