forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

Compare changes

Choose any two refs to compare.

Changed files
+270 -30
appview
config
state
guard
idresolver
knotserver
local-infra
spindle
+5
appview/config/config.go
··· 28 Jwks string `env:"JWKS"` 29 } 30 31 type JetstreamConfig struct { 32 Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"` 33 } ··· 103 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 104 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 105 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 106 Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 107 Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 108 }
··· 28 Jwks string `env:"JWKS"` 29 } 30 31 + type PlcConfig struct { 32 + PLCURL string `env:"URL, default=https://plc.directory"` 33 + } 34 + 35 type JetstreamConfig struct { 36 Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"` 37 } ··· 107 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 108 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 109 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 110 + Plc PlcConfig `env:",prefix=TANGLED_PLC_"` 111 Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 112 Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 113 }
+2 -2
appview/state/state.go
··· 70 return nil, fmt.Errorf("failed to create enforcer: %w", err) 71 } 72 73 - res, err := idresolver.RedisResolver(config.Redis.ToURL()) 74 if err != nil { 75 logger.Error("failed to create redis resolver", "err", err) 76 - res = idresolver.DefaultResolver() 77 } 78 79 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
··· 70 return nil, fmt.Errorf("failed to create enforcer: %w", err) 71 } 72 73 + res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL) 74 if err != nil { 75 logger.Error("failed to create redis resolver", "err", err) 76 + res = idresolver.DefaultResolver(config.Plc.PLCURL) 77 } 78 79 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
+9 -3
guard/guard.go
··· 17 "github.com/urfave/cli/v3" 18 "tangled.org/core/idresolver" 19 "tangled.org/core/log" 20 ) 21 22 func Command() *cli.Command { ··· 56 57 func Run(ctx context.Context, cmd *cli.Command) error { 58 l := log.FromContext(ctx) 59 60 incomingUser := cmd.String("user") 61 gitDir := cmd.String("git-dir") ··· 122 } 123 124 didOrHandle := components[0] 125 - identity := resolveIdentity(ctx, l, didOrHandle) 126 did := identity.DID.String() 127 repoName := components[1] 128 qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName) ··· 195 return nil 196 } 197 198 - func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity { 199 - resolver := idresolver.DefaultResolver() 200 ident, err := resolver.ResolveIdent(ctx, didOrHandle) 201 if err != nil { 202 l.Error("Error resolving handle", "error", err, "handle", didOrHandle)
··· 17 "github.com/urfave/cli/v3" 18 "tangled.org/core/idresolver" 19 "tangled.org/core/log" 20 + "tangled.org/core/knotserver/config" 21 ) 22 23 func Command() *cli.Command { ··· 57 58 func Run(ctx context.Context, cmd *cli.Command) error { 59 l := log.FromContext(ctx) 60 + 61 + c, err := config.Load(ctx) 62 + if err != nil { 63 + return fmt.Errorf("failed to load config: %w", err) 64 + } 65 66 incomingUser := cmd.String("user") 67 gitDir := cmd.String("git-dir") ··· 128 } 129 130 didOrHandle := components[0] 131 + identity := resolveIdentity(ctx, c, l, didOrHandle) 132 did := identity.DID.String() 133 repoName := components[1] 134 qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName) ··· 201 return nil 202 } 203 204 + func resolveIdentity(ctx context.Context, c *config.Config, l *slog.Logger, didOrHandle string) *identity.Identity { 205 + resolver := idresolver.DefaultResolver(c.Server.PlcUrl) 206 ident, err := resolver.ResolveIdent(ctx, didOrHandle) 207 if err != nil { 208 l.Error("Error resolving handle", "error", err, "handle", didOrHandle)
+17 -8
idresolver/resolver.go
··· 17 directory identity.Directory 18 } 19 20 - func BaseDirectory() identity.Directory { 21 base := identity.BaseDirectory{ 22 - PLCURL: identity.DefaultPLCURL, 23 HTTPClient: http.Client{ 24 Timeout: time.Second * 10, 25 Transport: &http.Transport{ ··· 42 return &base 43 } 44 45 - func RedisDirectory(url string) (identity.Directory, error) { 46 hitTTL := time.Hour * 24 47 errTTL := time.Second * 30 48 invalidHandleTTL := time.Minute * 5 49 - return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000) 50 } 51 52 - func DefaultResolver() *Resolver { 53 return &Resolver{ 54 - directory: identity.DefaultDirectory(), 55 } 56 } 57 58 - func RedisResolver(redisUrl string) (*Resolver, error) { 59 - directory, err := RedisDirectory(redisUrl) 60 if err != nil { 61 return nil, err 62 }
··· 17 directory identity.Directory 18 } 19 20 + func BaseDirectory(plcUrl string) identity.Directory { 21 base := identity.BaseDirectory{ 22 + PLCURL: plcUrl, 23 HTTPClient: http.Client{ 24 Timeout: time.Second * 10, 25 Transport: &http.Transport{ ··· 42 return &base 43 } 44 45 + func RedisDirectory(url, plcUrl string) (identity.Directory, error) { 46 hitTTL := time.Hour * 24 47 errTTL := time.Second * 30 48 invalidHandleTTL := time.Minute * 5 49 + return redisdir.NewRedisDirectory( 50 + BaseDirectory(plcUrl), 51 + url, 52 + hitTTL, 53 + errTTL, 54 + invalidHandleTTL, 55 + 10000, 56 + ) 57 } 58 59 + func DefaultResolver(plcUrl string) *Resolver { 60 + base := BaseDirectory(plcUrl) 61 + cached := identity.NewCacheDirectory(base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5) 62 return &Resolver{ 63 + directory: &cached, 64 } 65 } 66 67 + func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) { 68 + directory, err := RedisDirectory(redisUrl, plcUrl) 69 if err != nil { 70 return nil, err 71 }
+1
knotserver/config/config.go
··· 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 DBPath string `env:"DB_PATH, default=knotserver.db"` 21 Hostname string `env:"HOSTNAME, required"` 22 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 23 Owner string `env:"OWNER, required"` 24 LogDids bool `env:"LOG_DIDS, default=true"`
··· 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 DBPath string `env:"DB_PATH, default=knotserver.db"` 21 Hostname string `env:"HOSTNAME, required"` 22 + PlcUrl string `env:"PLC_URL, default=plc.directory"` 23 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 24 Owner string `env:"OWNER, required"` 25 LogDids bool `env:"LOG_DIDS, default=true"`
+3 -7
knotserver/ingester.go
··· 16 "github.com/bluesky-social/jetstream/pkg/models" 17 securejoin "github.com/cyphar/filepath-securejoin" 18 "tangled.org/core/api/tangled" 19 - "tangled.org/core/idresolver" 20 "tangled.org/core/knotserver/db" 21 "tangled.org/core/knotserver/git" 22 "tangled.org/core/log" ··· 120 } 121 122 // resolve this aturi to extract the repo record 123 - resolver := idresolver.DefaultResolver() 124 - ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 125 if err != nil || ident.Handle.IsInvalidHandle() { 126 return fmt.Errorf("failed to resolve handle: %w", err) 127 } ··· 233 return err 234 } 235 236 - resolver := idresolver.DefaultResolver() 237 - 238 - subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 239 if err != nil || subjectId.Handle.IsInvalidHandle() { 240 return err 241 } 242 243 // TODO: fix this for good, we need to fetch the record here unfortunately 244 // resolve this aturi to extract the repo record 245 - owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 246 if err != nil || owner.Handle.IsInvalidHandle() { 247 return fmt.Errorf("failed to resolve handle: %w", err) 248 }
··· 16 "github.com/bluesky-social/jetstream/pkg/models" 17 securejoin "github.com/cyphar/filepath-securejoin" 18 "tangled.org/core/api/tangled" 19 "tangled.org/core/knotserver/db" 20 "tangled.org/core/knotserver/git" 21 "tangled.org/core/log" ··· 119 } 120 121 // resolve this aturi to extract the repo record 122 + ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 123 if err != nil || ident.Handle.IsInvalidHandle() { 124 return fmt.Errorf("failed to resolve handle: %w", err) 125 } ··· 231 return err 232 } 233 234 + subjectId, err := h.resolver.ResolveIdent(ctx, record.Subject) 235 if err != nil || subjectId.Handle.IsInvalidHandle() { 236 return err 237 } 238 239 // TODO: fix this for good, we need to fetch the record here unfortunately 240 // resolve this aturi to extract the repo record 241 + owner, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 242 if err != nil || owner.Handle.IsInvalidHandle() { 243 return fmt.Errorf("failed to resolve handle: %w", err) 244 }
+1 -1
knotserver/internal.go
··· 145 146 func (h *InternalHandle) replyCompare(line git.PostReceiveLine, repoOwner string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) { 147 l := h.l.With("handler", "replyCompare") 148 - userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, repoOwner) 149 user := repoOwner 150 if err != nil { 151 l.Error("Failed to fetch user identity", "err", err)
··· 145 146 func (h *InternalHandle) replyCompare(line git.PostReceiveLine, repoOwner string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) { 147 l := h.l.With("handler", "replyCompare") 148 + userIdent, err := idresolver.DefaultResolver(h.c.Server.PlcUrl).ResolveIdent(ctx, repoOwner) 149 user := repoOwner 150 if err != nil { 151 l.Error("Failed to fetch user identity", "err", err)
+1 -1
knotserver/router.go
··· 36 l: log.FromContext(ctx), 37 jc: jc, 38 n: n, 39 - resolver: idresolver.DefaultResolver(), 40 } 41 42 err := e.AddKnot(rbac.ThisServer)
··· 36 l: log.FromContext(ctx), 37 jc: jc, 38 n: n, 39 + resolver: idresolver.DefaultResolver(c.Server.PlcUrl), 40 } 41 42 err := e.AddKnot(rbac.ThisServer)
+54
local-infra/Caddyfile
···
··· 1 + { 2 + storage file_system /data/ 3 + debug 4 + pki { 5 + ca localtangled { 6 + name "LocalTangledCA" 7 + } 8 + } 9 + } 10 + 11 + plc.tngl.boltless.dev { 12 + tls { 13 + issuer internal { 14 + ca localtangled 15 + } 16 + } 17 + reverse_proxy http://plc:8080 18 + } 19 + 20 + *.pds.tngl.boltless.dev, pds.tngl.boltless.dev { 21 + tls { 22 + issuer internal { 23 + ca localtangled 24 + } 25 + } 26 + reverse_proxy http://pds:3000 27 + } 28 + 29 + jetstream.tngl.boltless.dev { 30 + tls { 31 + issuer internal { 32 + ca localtangled 33 + } 34 + } 35 + reverse_proxy http://jetstream:6008 36 + } 37 + 38 + knot.tngl.boltless.dev { 39 + tls { 40 + issuer internal { 41 + ca localtangled 42 + } 43 + } 44 + reverse_proxy http://host.docker.internal:6000 45 + } 46 + 47 + spindle.tngl.boltless.dev { 48 + tls { 49 + issuer internal { 50 + ca localtangled 51 + } 52 + } 53 + reverse_proxy http://host.docker.internal:6555 54 + }
+78
local-infra/docker-compose.yml
···
··· 1 + name: tangled-local-infra 2 + services: 3 + caddy: 4 + container_name: caddy 5 + image: caddy:2 6 + depends_on: 7 + - pds 8 + restart: unless-stopped 9 + cap_add: 10 + - NET_ADMIN 11 + ports: 12 + - "80:80" 13 + - "443:443" 14 + - "443:443/udp" 15 + volumes: 16 + - ./Caddyfile:/etc/caddy/Caddyfile 17 + - caddy_data:/data 18 + - caddy_config:/config 19 + 20 + plc: 21 + image: ghcr.io/bluesky-social/did-method-plc:plc-f2ab7516bac5bc0f3f86842fa94e996bd1b3815b 22 + # did-method-plc only provides linux/amd64 23 + platform: linux/amd64 24 + container_name: plc 25 + restart: unless-stopped 26 + ports: 27 + - "4000:8080" 28 + depends_on: 29 + - plc_db 30 + environment: 31 + DEBUG_MODE: 1 32 + LOG_ENABLED: "true" 33 + LOG_LEVEL: "debug" 34 + LOG_DESTINATION: 1 35 + DB_CREDS_JSON: &DB_CREDS_JSON '{"username":"pg","password":"password","host":"plc_db","port":5432}' 36 + DB_MIGRATE_CREDS_JSON: *DB_CREDS_JSON 37 + PLC_VERSION: 0.0.1 38 + PORT: 8080 39 + 40 + plc_db: 41 + image: postgres:14.4-alpine 42 + container_name: plc_db 43 + environment: 44 + - POSTGRES_USER=pg 45 + - POSTGRES_PASSWORD=password 46 + - PGPORT=5432 47 + volumes: 48 + - plc:/var/lib/postgresql/data 49 + 50 + pds: 51 + container_name: pds 52 + image: ghcr.io/bluesky-social/pds:0.4 53 + restart: unless-stopped 54 + ports: 55 + - "4001:3000" 56 + volumes: 57 + - pds:/pds 58 + env_file: 59 + - ./pds.env 60 + 61 + jetstream: 62 + container_name: jetstream 63 + image: ghcr.io/bluesky-social/jetstream:sha-0ab10bd 64 + restart: unless-stopped 65 + volumes: 66 + - jetstream:/data 67 + environment: 68 + - JETSTREAM_DATA_DIR=/data 69 + # livness check interval to restart when no events are received (default: 15sec) 70 + - JETSTREAM_LIVENESS_TTL=300s 71 + - JETSTREAM_WS_URL=ws://pds:3000/xrpc/com.atproto.sync.subscribeRepos 72 + 73 + volumes: 74 + caddy_config: 75 + caddy_data: 76 + plc: 77 + pds: 78 + jetstream:
+17
local-infra/pds.env
···
··· 1 + PDS_JWT_SECRET=8cae8bffcc73d9932819650791e4e89a 2 + PDS_ADMIN_PASSWORD=d6a902588cd93bee1af83f924f60cfd3 3 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=2e92e336a50a618458e1097d94a1db86ec3fd8829d7735020cbae80625c761d7 4 + 5 + LOG_ENABLED=true 6 + 7 + # PDS_BSKY_APP_VIEW_DID=did:web:api.bsky.app 8 + # PDS_BSKY_APP_VIEW_URL=https://api.bsky.app 9 + 10 + PDS_DATA_DIRECTORY=/pds 11 + PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks 12 + 13 + # PDS_DID_PLC_URL=http://plc:8080 14 + PDS_HOSTNAME=pds.tngl.boltless.dev 15 + 16 + # PDS_REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac 17 + # PDS_REPORT_SERVICE_URL=https://mod.bsky.app
+14
local-infra/readme.md
···
··· 1 + run compose 2 + ``` 3 + docker compose up -d 4 + ``` 5 + 6 + copy the self-signed certificate to host machine 7 + ``` 8 + docker cp caddy:/data/pki/authorities/localtangled/root.crt localtangled.crt 9 + ``` 10 + 11 + trust the cert (macOS) 12 + ``` 13 + sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ./localtangled.crt 14 + ```
+63
local-infra/scripts/create-test-account.sh
···
··· 1 + #!/bin/bash 2 + set -o errexit 3 + set -o nounset 4 + set -o pipefail 5 + 6 + source "$(dirname "$0")/../pds.env" 7 + 8 + # curl a URL and fail if the request fails. 9 + function curl_cmd_get { 10 + curl --fail --silent --show-error "$@" 11 + } 12 + 13 + # curl a URL and fail if the request fails. 14 + function curl_cmd_post { 15 + curl --fail --silent --show-error --request POST --header "Content-Type: application/json" "$@" 16 + } 17 + 18 + # curl a URL but do not fail if the request fails. 19 + function curl_cmd_post_nofail { 20 + curl --silent --show-error --request POST --header "Content-Type: application/json" "$@" 21 + } 22 + 23 + USERNAME="${1:-}" 24 + 25 + if [[ "${USERNAME}" == "" ]]; then 26 + read -p "Enter a username: " USERNAME 27 + fi 28 + 29 + if [[ "${USERNAME}" == "" ]]; then 30 + echo "ERROR: missing USERNAME parameter." >/dev/stderr 31 + echo "Usage: $0 ${SUBCOMMAND} <USERNAME>" >/dev/stderr 32 + exit 1 33 + fi 34 + 35 + PASSWORD="password" 36 + INVITE_CODE="$(curl_cmd_post \ 37 + --user "admin:${PDS_ADMIN_PASSWORD}" \ 38 + --data '{"useCount": 1}' \ 39 + "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createInviteCode" | jq --raw-output '.code' 40 + )" 41 + RESULT="$(curl_cmd_post_nofail \ 42 + --data "{\"email\":\"${USERNAME}@${PDS_HOSTNAME}\", \"handle\":\"${USERNAME}.${PDS_HOSTNAME}\", \"password\":\"${PASSWORD}\", \"inviteCode\":\"${INVITE_CODE}\"}" \ 43 + "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createAccount" 44 + )" 45 + 46 + DID="$(echo $RESULT | jq --raw-output '.did')" 47 + if [[ "${DID}" != did:* ]]; then 48 + ERR="$(echo ${RESULT} | jq --raw-output '.message')" 49 + echo "ERROR: ${ERR}" >/dev/stderr 50 + echo "Usage: $0 <EMAIL> <HANDLE>" >/dev/stderr 51 + exit 1 52 + fi 53 + 54 + echo 55 + echo "Account created successfully!" 56 + echo "-----------------------------" 57 + echo "Handle : ${USERNAME}.${PDS_HOSTNAME}" 58 + echo "DID : ${DID}" 59 + echo "Password : ${PASSWORD}" 60 + echo "-----------------------------" 61 + echo "This is a test account with an insecure password." 62 + echo "Make sure it's only used for development." 63 + echo
+1
spindle/config/config.go
··· 13 DBPath string `env:"DB_PATH, default=spindle.db"` 14 Hostname string `env:"HOSTNAME, required"` 15 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 Dev bool `env:"DEV, default=false"` 17 Owner string `env:"OWNER, required"` 18 Secrets Secrets `env:",prefix=SECRETS_"`
··· 13 DBPath string `env:"DB_PATH, default=spindle.db"` 14 Hostname string `env:"HOSTNAME, required"` 15 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 + PlcUrl string `env:"PLC_URL, default=plc.directory"` 17 Dev bool `env:"DEV, default=false"` 18 Owner string `env:"OWNER, required"` 19 Secrets Secrets `env:",prefix=SECRETS_"`
+3 -7
spindle/ingester.go
··· 9 10 "tangled.org/core/api/tangled" 11 "tangled.org/core/eventconsumer" 12 - "tangled.org/core/idresolver" 13 "tangled.org/core/rbac" 14 "tangled.org/core/spindle/db" 15 ··· 142 func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 143 var err error 144 did := e.Did 145 - resolver := idresolver.DefaultResolver() 146 147 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 148 ··· 190 } 191 192 // add collaborators to rbac 193 - owner, err := resolver.ResolveIdent(ctx, did) 194 if err != nil || owner.Handle.IsInvalidHandle() { 195 return err 196 } ··· 225 return err 226 } 227 228 - resolver := idresolver.DefaultResolver() 229 - 230 - subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 231 if err != nil || subjectId.Handle.IsInvalidHandle() { 232 return err 233 } ··· 240 241 // TODO: get rid of this entirely 242 // resolve this aturi to extract the repo record 243 - owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 244 if err != nil || owner.Handle.IsInvalidHandle() { 245 return fmt.Errorf("failed to resolve handle: %w", err) 246 }
··· 9 10 "tangled.org/core/api/tangled" 11 "tangled.org/core/eventconsumer" 12 "tangled.org/core/rbac" 13 "tangled.org/core/spindle/db" 14 ··· 141 func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 142 var err error 143 did := e.Did 144 145 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 146 ··· 188 } 189 190 // add collaborators to rbac 191 + owner, err := s.res.ResolveIdent(ctx, did) 192 if err != nil || owner.Handle.IsInvalidHandle() { 193 return err 194 } ··· 223 return err 224 } 225 226 + subjectId, err := s.res.ResolveIdent(ctx, record.Subject) 227 if err != nil || subjectId.Handle.IsInvalidHandle() { 228 return err 229 } ··· 236 237 // TODO: get rid of this entirely 238 // resolve this aturi to extract the repo record 239 + owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String()) 240 if err != nil || owner.Handle.IsInvalidHandle() { 241 return fmt.Errorf("failed to resolve handle: %w", err) 242 }
+1 -1
spindle/server.go
··· 123 jc.AddDid(d) 124 } 125 126 - resolver := idresolver.DefaultResolver() 127 128 spindle := Spindle{ 129 jc: jc,
··· 123 jc.AddDid(d) 124 } 125 126 + resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 127 128 spindle := Spindle{ 129 jc: jc,