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:
Cian Johnston
2025-03-27 10:03:53 +00:00
committed by GitHub
parent 006600ea3e
commit 06e5d9ef21
43 changed files with 2136 additions and 20 deletions
+23 -1
View File
@@ -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
+112
View File
@@ -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(&regenVapidKeypairDBURL),
},
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(&regenVapidKeypairPgAuth, codersdk.PostgresAuthDrivers...),
},
)
return regenerateVapidKeypairCommand
}
+118
View File
@@ -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
View File
@@ -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)
+148 -2
View File
@@ -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": {
+138 -2
View File
@@ -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
View File
@@ -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]
+12
View File
@@ -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,
+51
View File
@@ -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 {
+49
View File
@@ -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)
+12
View File
@@ -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()
+106
View File
@@ -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 {
+49
View File
@@ -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)
+101
View File
@@ -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()
+15
View File
@@ -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
);
@@ -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==');
+9
View File
@@ -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"`
+11
View File
@@ -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
+145
View File
@@ -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
+25
View File
@@ -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;
+13
View File
@@ -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;
+1
View File
@@ -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);
+10
View File
@@ -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,
+7
View File
@@ -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"),
+10
View File
@@ -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",
+160
View File
@@ -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)
}
+240
View File
@@ -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
}
+257
View File
@@ -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
}
+82
View File
@@ -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")
}
+5
View File
@@ -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
+67
View File
@@ -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
}
+2
View File
@@ -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},
+1
View File
@@ -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
}
```
+5
View File
@@ -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` |
+36
View File
@@ -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
View File
@@ -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)
+3
View File
@@ -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
)
+32
View File
@@ -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=
+5
View File
@@ -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",
+30
View File
@@ -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;